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

@@ -0,0 +1,29 @@
package com.ts3client.session;
/**
* The state of the local client on one server, as the tree shows it for any other
* client — the states are listed in the order the official client gives them
* priority, the strongest one first.
*/
public enum SelfState {
DISCONNECTED("Not connected"),
DEAFENED("Speakers muted"),
MIC_MUTED("Microphone muted"),
MIC_LOCAL_MUTED("Microphone locally muted"),
AWAY("Away"),
COMMANDER_TALKING("Talking (channel commander)"),
COMMANDER("Channel commander"),
TALKING("Talking"),
IDLE("Connected");
private final String label;
SelfState(String label) {
this.label = label;
}
public String label() {
return label;
}
}

View File

@@ -0,0 +1,457 @@
package com.ts3client.session;
import com.ts3client.audio.AudioBackend;
import com.ts3client.config.IdentityEntry;
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.ConnectionListener;
import com.ts3client.net.ServerModel;
import com.ts3client.net.TeamspeakConnection;
import com.ts3client.sound.SoundNotifier;
import java.util.ArrayList;
import java.util.List;
/**
* One server connection and the local client's state on it, independent of any UI: the
* connection lifecycle, the microphone/speaker/away flags, chat sending and channel
* lookups. A frontend keeps one per server tab and hears from it through {@link Listener}.
*
* <p>Listener callbacks arrive on connection threads, so frontends marshal them onto their
* UI thread. The getters may be called from any thread.
*/
public final class ServerSession {
/** Everything a {@link ConnectionListener} hears, plus what the session adds. */
public interface Listener extends ConnectionListener {
/**
* The session's title, status, connecting state or self flags changed. Also
* delivers what {@link #onStatus} does, so frontends usually only need this one.
*/
void onSessionChanged();
/** A line for the chat's own log: connection progress, errors, pokes. */
void onSystemMessage(String text);
/**
* The local client sent a chat message.
*
* @param peerClientId the recipient of a private message, else 0
* @param peerName the recipient's display name for a private message, else empty
*/
void onOwnChat(ChatScope scope, int peerClientId, String peerName, String text);
}
private final Settings settings;
private final IdentityStore identities;
private final Listener listener;
private final TeamspeakConnection conn;
private volatile String title = "New connection";
private volatile String status = "Not connected";
private volatile boolean connecting;
/** Identity used for the current connection, so it can be saved into a bookmark. */
private volatile String identityId = "";
private volatile boolean micMuted;
private volatile boolean micLocalMuted;
private volatile boolean deafened;
private volatile boolean away;
/** Away message currently published, empty when away carries no message. */
private volatile String awayMessage = "";
private volatile boolean commander;
public ServerSession(Settings settings, IdentityStore identities, AudioBackend audio,
SoundNotifier sounds, ContactStore contacts, Listener listener) {
this.settings = settings;
this.identities = identities;
this.listener = listener;
this.conn = new TeamspeakConnection(settings, audio, new Events(), sounds, contacts);
}
public TeamspeakConnection connection() {
return conn;
}
/** The server's name once known, the address before that. */
public String title() {
return title;
}
public String status() {
return status;
}
public boolean isConnected() {
return conn.isConnected();
}
/** Connected or still connecting — i.e. this session is not free for a new server. */
public boolean isBusy() {
return connecting || conn.isConnected();
}
public String address() {
return conn.getServerHost();
}
public int port() {
return conn.getServerPort();
}
public String identityId() {
return identityId;
}
// ---- 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
*/
public void connect(String address, int port, String nickname, String password, String identityId,
String channel, String channelPassword) {
synchronized (this) {
if (isBusy()) return;
connecting = true;
}
title = address + ":" + port;
listener.onSystemMessage("Connecting to " + address + ":" + port
+ (channel == null || channel.isBlank() ? "" : " (channel \"" + channel + "\")") + " …");
setStatus("Loading identity…");
// Resolving may have to generate a first identity, which takes a while.
new Thread(() -> {
final IdentityEntry entry;
try {
entry = identities.resolve(settings, identityId);
} catch (Exception e) {
connecting = false;
reportError("Could not load identity: " + e.getMessage());
return;
}
this.identityId = entry.getId();
listener.onSystemMessage("Using identity \"" + entry.getName() + "\".");
conn.connect(address, port, nickname, password, entry.getIdentity(), channel, channelPassword);
}, "identity-resolve").start();
}
/**
* Replaces this session's connection: leaves the server it is on, then connects to the
* new one in its place — what left-clicking a bookmark does.
*/
public 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(() -> {
// Reports the disconnect before returning, which clears the connecting flag.
conn.disconnectBlocking("Switching servers");
connect(address, port, nickname, password, identityId, channel, channelPassword);
}, "ts3j-switch-server").start();
}
public void disconnect() {
if (conn.isConnected()) conn.disconnect();
}
/** Synchronous teardown for shutdown paths, so the server sees us leave. */
public void shutdown() {
if (conn.isConnected()) conn.disconnectBlocking("Leaving");
}
/** Releases the background resources of a session that is being thrown away. */
public void dispose() {
conn.getIcons().shutdown();
}
// ---- self state ----
public boolean isMicMuted() {
return micMuted;
}
public boolean isMicLocalMuted() {
return micLocalMuted;
}
public boolean isDeafened() {
return deafened;
}
public boolean isAway() {
return away;
}
public String awayMessage() {
return awayMessage;
}
public boolean isCommander() {
return commander;
}
public void setMicMuted(boolean muted) {
micMuted = muted;
conn.setMicMuted(muted);
listener.onSessionChanged();
}
/** TS3's "Local Mic Mute": silences capture without publishing a status change. */
public void setMicLocalMuted(boolean muted) {
micLocalMuted = muted;
conn.setMicLocalMuted(muted);
listener.onSessionChanged();
}
public void setDeafened(boolean deaf) {
deafened = deaf;
conn.setDeafened(deaf);
listener.onSessionChanged();
}
/**
* @param message the away message, or null to keep the one already set — the
* message outlives coming back, so toggling away again restores it
*/
public void setAway(boolean away, String message) {
this.away = away;
if (message != null) this.awayMessage = message;
conn.setAway(away, awayMessage);
listener.onSessionChanged();
}
public void setCommander(boolean commander) {
this.commander = commander;
conn.setChannelCommander(commander);
listener.onSessionChanged();
}
/** Hands the capture device to (or takes it from) this connection. */
public void setMicrophoneActive(boolean active) {
conn.setMicrophoneActive(active);
}
public void setNickname(String nickname) {
if (conn.isConnected()) conn.setNickname(nickname);
}
/** What the local client looks like on this server. */
public SelfState selfState() {
if (!conn.isConnected()) return SelfState.DISCONNECTED;
if (deafened) return SelfState.DEAFENED;
if (micMuted) return SelfState.MIC_MUTED;
if (micLocalMuted) return SelfState.MIC_LOCAL_MUTED;
if (away) return SelfState.AWAY;
ClientEntry self = self();
boolean talking = self != null && self.talking;
if (commander) return talking ? SelfState.COMMANDER_TALKING : SelfState.COMMANDER;
return talking ? SelfState.TALKING : SelfState.IDLE;
}
/** Clears all flags on a fresh connection; the server starts us out clean, so nothing to publish. */
private void resetSelfState() {
micMuted = false;
micLocalMuted = false;
deafened = false;
away = false;
awayMessage = "";
commander = false;
}
private ClientEntry self() {
return conn.getModel().getClient(conn.getSelfClientId());
}
// ---- chat ----
/**
* Sends a chat message and reports it back through {@link Listener#onOwnChat}.
*
* @param clientId the recipient of a private message; ignored otherwise
*/
public void sendChat(ConnectionListener.ChatScope scope, int clientId, String text) {
if (!conn.isConnected()) {
listener.onSystemMessage("Not connected.");
return;
}
switch (scope) {
case SERVER -> {
conn.sendServerMessage(text);
listener.onOwnChat(scope, 0, "", text);
}
case PRIVATE -> {
conn.sendPrivateMessage(clientId, text);
listener.onOwnChat(scope, clientId, peerName(clientId), text);
}
default -> {
conn.sendChannelMessage(text);
listener.onOwnChat(scope, 0, "", text);
}
}
}
private String peerName(int clientId) {
ClientEntry c = conn.getModel().getClient(clientId);
return c != null ? conn.displayName(c) : "Client " + clientId;
}
// ---- channels ----
/** The channel we are in, or null when not connected. */
public ChannelNode currentChannel() {
if (!conn.isConnected()) return null;
ClientEntry self = self();
return self == null ? null : conn.getModel().getChannel(self.channelId);
}
/** Path of the channel we are in, or empty when not connected. */
public String currentChannelPath() {
if (!conn.isConnected()) return "";
ClientEntry self = self();
return self == null ? "" : conn.getModel().channelPath(self.channelId);
}
/** Joins the channel at a "/"-separated path, as the hotkey action names it. */
public 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. */
public 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);
}
// ---- permissions ----
/**
* 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.
*/
public boolean canViewBanList() {
if (!conn.isConnected()) return false;
ServerModel model = conn.getModel();
return !model.hasPermissionNames() || model.selfPermissionValue("b_client_ban_list") > 0;
}
// ---- connection events ----
private void setStatus(String text) {
status = text;
listener.onStatus(text);
listener.onSessionChanged();
}
private void reportError(String message) {
listener.onSystemMessage("Error: " + message);
listener.onError(message);
setStatus(message);
}
/** Applies what a connection event means for the session, then passes it on. */
private final class Events implements ConnectionListener {
@Override
public void onStatus(String text) {
setStatus(text);
}
@Override
public void onConnected() {
connecting = false;
resetSelfState();
listener.onConnected();
listener.onSystemMessage("Connected.");
listener.onSessionChanged();
}
@Override
public void onDisconnected(String reason) {
connecting = false;
conn.getModel().clear();
listener.onDisconnected(reason);
listener.onSystemMessage("Disconnected" + (reason == null || reason.isEmpty() ? "." : ": " + reason));
listener.onSessionChanged();
}
@Override
public void onModelChanged() {
String name = conn.getModel().getServerName();
if (conn.isConnected() && name != null && !name.isBlank() && !name.equals(title)) {
title = name;
listener.onSessionChanged();
}
listener.onModelChanged();
}
@Override
public void onInfoUpdated() {
listener.onInfoUpdated();
}
@Override
public void onIconsUpdated() {
listener.onIconsUpdated();
}
/** Passes the sender on under their contact display name. */
@Override
public void onChat(ChatScope scope, int fromClientId, String fromUniqueId, String fromName, String message) {
String name = conn.getContacts().displayName(fromUniqueId, fromName);
listener.onChat(scope, fromClientId, fromUniqueId, name, message);
}
@Override
public void onChatHistory(ChatScope scope, List<String> htmlEntries) {
listener.onChatHistory(scope, htmlEntries);
}
@Override
public void onPrivateChatHistory(int clientId, String nickname, List<String> htmlEntries) {
listener.onPrivateChatHistory(clientId, nickname, htmlEntries);
}
@Override
public void onTalkStateChanged(int clientId, boolean talking) {
listener.onTalkStateChanged(clientId, talking);
}
@Override
public void onError(String message) {
reportError(message);
}
@Override
public void onPoke(String fromName, String message) {
listener.onSystemMessage("You were poked by " + fromName + ": " + message);
listener.onPoke(fromName, message);
}
@Override
public void onServerLog(String message) {
listener.onServerLog(message);
}
@Override
public void onServerLogHtml(String html) {
listener.onServerLogHtml(html);
}
}
}