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

@@ -38,10 +38,10 @@ final class HotkeyActions implements HotkeyEngine.Handler {
case CONNECT_NEW_TAB -> frame.connectBookmark(hotkey.argument, true);
case DISCONNECT_CURRENT -> {
ServerTab tab = frame.selectedTab();
if (tab != null) tab.disconnect();
if (tab != null) tab.session().disconnect();
}
case DISCONNECT_ALL -> {
for (ServerTab tab : frame.allTabs()) tab.disconnect();
for (ServerTab tab : frame.allTabs()) tab.session().disconnect();
}
case MIC_ACTIVATE -> frame.moveMicrophoneToSelectedTab();
@@ -78,7 +78,7 @@ final class HotkeyActions implements HotkeyEngine.Handler {
}
case CHANNEL_SWITCH -> {
for (ServerTab tab : targets) tab.joinChannelPath(hotkey.argument);
for (ServerTab tab : targets) tab.session().joinChannelPath(hotkey.argument);
}
case SERVER_TAB_SELECT -> frame.selectTabNumber(parseIndex(hotkey.argument));
case SERVER_TAB_NEXT -> frame.stepTab(1);
@@ -105,48 +105,48 @@ final class HotkeyActions implements HotkeyEngine.Handler {
}
private void setMicMuted(List<ServerTab> targets, boolean muted) {
for (ServerTab tab : targets) tab.setMicMuted(muted);
for (ServerTab tab : targets) tab.session().setMicMuted(muted);
frame.refreshAfterHotkey();
}
private void setMicLocalMuted(List<ServerTab> targets, boolean muted) {
for (ServerTab tab : targets) tab.setMicLocalMuted(muted);
for (ServerTab tab : targets) tab.session().setMicLocalMuted(muted);
frame.refreshAfterHotkey();
}
private void setDeafened(List<ServerTab> targets, boolean deaf) {
for (ServerTab tab : targets) tab.setDeafened(deaf);
for (ServerTab tab : targets) tab.session().setDeafened(deaf);
frame.refreshAfterHotkey();
}
private void setAway(List<ServerTab> targets, boolean away, String message) {
for (ServerTab tab : targets) tab.setAway(away, message == null ? "" : message);
for (ServerTab tab : targets) tab.session().setAway(away, message == null ? "" : message);
frame.refreshAfterHotkey();
}
private void setCommander(List<ServerTab> targets, boolean commander) {
for (ServerTab tab : targets) tab.setCommander(commander);
for (ServerTab tab : targets) tab.session().setCommander(commander);
frame.refreshAfterHotkey();
}
private static boolean anyMicMuted(List<ServerTab> tabs) {
return tabs.stream().anyMatch(ServerTab::isMicMuted);
return tabs.stream().anyMatch(t -> t.session().isMicMuted());
}
private static boolean anyMicLocalMuted(List<ServerTab> tabs) {
return tabs.stream().anyMatch(ServerTab::isMicLocalMuted);
return tabs.stream().anyMatch(t -> t.session().isMicLocalMuted());
}
private static boolean anyDeafened(List<ServerTab> tabs) {
return tabs.stream().anyMatch(ServerTab::isDeafened);
return tabs.stream().anyMatch(t -> t.session().isDeafened());
}
private static boolean anyAway(List<ServerTab> tabs) {
return tabs.stream().anyMatch(ServerTab::isAway);
return tabs.stream().anyMatch(t -> t.session().isAway());
}
private static boolean anyCommander(List<ServerTab> tabs) {
return tabs.stream().anyMatch(ServerTab::isCommander);
return tabs.stream().anyMatch(t -> t.session().isCommander());
}
/** @return the 1-based tab number in the argument, or 1 when it is not a number */

View File

@@ -12,6 +12,7 @@ import com.ts3client.config.Settings;
import com.ts3client.teamspeak.TeamSpeakImporter;
import com.ts3client.contacts.ContactStore;
import com.ts3client.net.ChannelNode;
import com.ts3client.session.SelfState;
import com.ts3client.sound.SoundNotifier;
import com.ts3client.sound.SoundPlayer;
@@ -201,7 +202,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
@Override
public void onCommanderToggle(boolean commander) {
if (selected != null) selected.setCommander(commander);
if (selected != null) selected.session().setCommander(commander);
}
@Override
@@ -211,7 +212,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
@Override
public void onSetAvatar() {
if (selected != null && selected.isConnected()) selected.showAvatarDialog();
if (selected != null && selected.session().isConnected()) selected.showAvatarDialog();
}
@Override
@@ -226,7 +227,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
@Override
public boolean canViewBanList() {
return selected != null && selected.canViewBanList();
return selected != null && selected.session().canViewBanList();
}
@Override
@@ -283,14 +284,14 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
@Override
public void onMicMuteToggle(boolean muted) {
if (selected == null) return;
selected.setMicMuted(muted);
selected.session().setMicMuted(muted);
updateToolbar();
}
@Override
public void onDeafenToggle(boolean deafened) {
if (selected == null) return;
selected.setDeafened(deafened);
selected.session().setDeafened(deafened);
updateToolbar();
}
@@ -350,7 +351,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
/** 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;
if (selected != null && !selected.session().isBusy()) return selected;
ServerTab tab = newTab();
selectTab(tab);
return tab;
@@ -369,11 +370,11 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
@Override
public void closeTab(ServerTab tab) {
if (tab == null) return;
tab.disconnect();
tab.session().disconnect();
if (tabs.size() == 1) return; // always keep one view around
tabs.remove(tab);
tab.dispose();
tab.session().dispose();
tabPane.removeTab(tab);
if (micTab == tab) micTab = null;
if (selected == tab) {
@@ -394,17 +395,17 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
updateTray();
// 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);
if (previous != null) previous.session().setMicrophoneActive(false);
if (tab != null) tab.session().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;
if (micTab != null && tabs.contains(micTab) && micTab.session().isConnected()) return;
micTab = null;
for (ServerTab t : tabs) {
if (t.isConnected()) {
if (t.session().isConnected()) {
setMicTab(t);
return;
}
@@ -458,11 +459,11 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
List<ServerTab> hotkeyTargets(boolean activeServerOnly) {
List<ServerTab> out = new ArrayList<>();
if (activeServerOnly) {
if (selected != null && selected.isConnected()) out.add(selected);
if (selected != null && selected.session().isConnected()) out.add(selected);
return out;
}
for (ServerTab tab : tabs) {
if (tab.isConnected()) out.add(tab);
if (tab.session().isConnected()) out.add(tab);
}
return out;
}
@@ -479,21 +480,21 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
void setPushToTalk(boolean talking) {
pttPressed = talking;
ServerTab tab = micTab;
if (tab == null || !tab.isConnected() || tab.connection().getMicrophone() == null) return;
if (tab == null || !tab.session().isConnected() || tab.connection().getMicrophone() == null) return;
tab.connection().getMicrophone().setPushToTalk(talking);
}
/** Tells every connected microphone about a key press, as TS3 tells all its connections. */
private void keyPressed() {
for (ServerTab tab : tabs) {
if (tab.isConnected() && tab.connection().getMicrophone() != null) {
if (tab.session().isConnected() && tab.connection().getMicrophone() != null) {
tab.connection().getMicrophone().keyPressed();
}
}
}
void moveMicrophoneToSelectedTab() {
if (selected != null && selected.isConnected()) setMicTab(selected);
if (selected != null && selected.session().isConnected()) setMicTab(selected);
updateToolbar();
}
@@ -545,12 +546,12 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
}
settings.nickname = nickname.trim();
settings.save();
for (ServerTab tab : tabs) tab.setNickname(settings.nickname);
for (ServerTab tab : tabs) tab.session().setNickname(settings.nickname);
}
void browseCurrentChannel() {
if (selected == null || !selected.isConnected()) return;
ChannelNode channel = selected.currentChannel();
if (selected == null || !selected.session().isConnected()) return;
ChannelNode channel = selected.session().currentChannel();
if (channel != null) selected.browseFiles(channel);
}
@@ -588,8 +589,8 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
}
}
case CHANNEL -> {
if (selected != null && selected.isConnected()) {
out.addAll(selected.channelPaths());
if (selected != null && selected.session().isConnected()) {
out.addAll(selected.session().channelPaths());
}
}
default -> {
@@ -642,7 +643,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
// The first connection to come up takes the microphone; later ones are muted
// until the user activates them.
if (micTab == null) setMicTab(tab);
tab.switchTo(address, port, nickname, password, identityId, channel, channelPassword);
tab.session().switchTo(address, port, nickname, password, identityId, channel, channelPassword);
}
/** Connects on the current tab, leaving whatever server it is on. */
@@ -652,16 +653,16 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
}
private void addCurrentServerBookmark() {
if (selected == null || !selected.isConnected()) {
if (selected == null || !selected.session().isConnected()) {
JOptionPane.showMessageDialog(this, "Connect to a server first.",
"Add bookmark", JOptionPane.INFORMATION_MESSAGE);
return;
}
String addr = selected.address();
int port = selected.port();
String channelPath = selected.currentChannelPath();
String addr = selected.session().address();
int port = selected.session().port();
String channelPath = selected.session().currentChannelPath();
JTextField labelField = new JTextField(selected.title());
JTextField labelField = new JTextField(selected.session().title());
JCheckBox joinChannel = new JCheckBox("Join \"" + channelPath + "\" on connect", !channelPath.isEmpty());
JPanel form = new JPanel(new GridLayout(0, 1, 0, UIScale.scale(2)));
form.add(new JLabel("Bookmark label:"));
@@ -673,7 +674,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
}
Bookmark bookmark = new Bookmark(labelField.getText().trim(), addr, port,
settings.nickname, settings.serverPassword);
bookmark.identityId = selected.identityId();
bookmark.identityId = selected.session().identityId();
if (joinChannel.isSelected()) bookmark.channel = channelPath;
bookmarks.add(bookmark);
bookmarks.save();
@@ -683,7 +684,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
/** The away button's drop-down: the global actions, the presets and their editor. */
private JPopupMenu buildAwayMenu() {
JPopupMenu menu = new JPopupMenu();
boolean anyConnected = tabs.stream().anyMatch(ServerTab::isConnected);
boolean anyConnected = tabs.stream().anyMatch(t -> t.session().isConnected());
JCheckBoxMenuItem globally = new JCheckBoxMenuItem("Set Globally Away", Icons.of("AWAY"), isGloballyAway());
globally.setEnabled(anyConnected);
@@ -716,22 +717,22 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
private void toggleAway(boolean away) {
if (selected == null) return;
selected.setAway(away, "");
selected.session().setAway(away, "");
updateToolbar();
}
/** Sets the away message on the selected server only. */
private void setAwayStatus() {
if (selected == null) return;
String message = askAwayMessage(selected.awayMessage());
String message = askAwayMessage(selected.session().awayMessage());
if (message == null) return;
selected.setAway(true, message);
selected.session().setAway(true, message);
updateToolbar();
}
private void setAwayEverywhere(boolean away, String message) {
for (ServerTab tab : tabs) {
if (tab.isConnected()) tab.setAway(away, message);
if (tab.session().isConnected()) tab.session().setAway(away, message);
}
updateToolbar();
}
@@ -740,18 +741,18 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
private boolean isGloballyAway() {
boolean any = false;
for (ServerTab tab : tabs) {
if (!tab.isConnected()) continue;
if (!tab.session().isConnected()) continue;
any = true;
if (!tab.isAway()) return false;
if (!tab.session().isAway()) return false;
}
return any;
}
/** The message to preload the prompt with: the selected tab's, else any set one. */
private String currentAwayMessage() {
if (selected != null && !selected.awayMessage().isEmpty()) return selected.awayMessage();
if (selected != null && !selected.session().awayMessage().isEmpty()) return selected.session().awayMessage();
for (ServerTab tab : tabs) {
if (tab.isConnected() && !tab.awayMessage().isEmpty()) return tab.awayMessage();
if (tab.session().isConnected() && !tab.session().awayMessage().isEmpty()) return tab.session().awayMessage();
}
return "";
}
@@ -763,7 +764,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
}
private void doDisconnect() {
if (selected != null) selected.disconnect();
if (selected != null) selected.session().disconnect();
}
/**
@@ -782,7 +783,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
// Already shutting down (hook itself is running); nothing to remove.
}
for (ServerTab tab : new ArrayList<>(tabs)) {
tab.shutdown();
tab.session().shutdown();
}
soundPlayer.shutdown();
hotkeys.dispose();
@@ -860,7 +861,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
if (n != null && !n.trim().isEmpty()) {
settings.nickname = n.trim();
settings.save();
for (ServerTab tab : tabs) tab.setNickname(settings.nickname);
for (ServerTab tab : tabs) tab.session().setNickname(settings.nickname);
}
}
@@ -877,14 +878,14 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
// ---- toolbar / status ----
private void updateToolbar() {
boolean connected = selected != null && selected.isConnected();
boolean anyConnected = tabs.stream().anyMatch(ServerTab::isConnected);
boolean connected = selected != null && selected.session().isConnected();
boolean anyConnected = tabs.stream().anyMatch(t -> t.session().isConnected());
boolean micMuted = connected && selected.isMicMuted();
boolean deaf = connected && selected.isDeafened();
boolean micMuted = connected && selected.session().isMicMuted();
boolean deaf = connected && selected.session().isDeafened();
boolean active = selected != null && selected == micTab;
boolean away = connected && selected.isAway();
boolean commander = connected && selected.isCommander();
boolean away = connected && selected.session().isAway();
boolean commander = connected && selected.session().isCommander();
toolbar.refresh(connected, anyConnected, micMuted, deaf, active, away);
menuBar.refresh(connected, away, commander);
updateTray();
@@ -895,8 +896,8 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
* visible one when nobody is capturing.
*/
private ServerTab trayTab() {
if (micTab != null && micTab.isConnected()) return micTab;
return selected != null && selected.isConnected() ? selected : null;
if (micTab != null && micTab.session().isConnected()) return micTab;
return selected != null && selected.session().isConnected() ? selected : null;
}
private void updateTray() {
@@ -905,17 +906,17 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
if (tab == null) {
tray.update(SelfState.DISCONNECTED, null);
} else {
tray.update(tab.selfState(), tab.title());
tray.update(tab.session().selfState(), tab.session().title());
}
}
private void updateStatusLabel() {
statusBar.setStatus(selected == null ? "Not connected" : selected.status());
statusBar.setStatus(selected == null ? "Not connected" : selected.session().status());
}
private void updateConnectionStatus() {
ServerTab tab = selected;
if (tab == null || !tab.isConnected()) return;
if (tab == null || !tab.session().isConnected()) return;
int users = tab.connection().getModel().clientCount();
StringBuilder s = new StringBuilder("Connected to ")
.append(tab.connection().getModel().getServerName())

View File

@@ -1,55 +0,0 @@
package com.ts3client.ui;
import javax.swing.ImageIcon;
/**
* 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.
*/
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;
}
String label() {
return label;
}
/** The icon this state is drawn with, from the active icon pack. */
ImageIcon icon(int size) {
switch (this) {
case DISCONNECTED:
return Icons.app(size);
case DEAFENED:
return Icons.speakerMuted(size);
case MIC_MUTED:
return Icons.micMuted(size);
case MIC_LOCAL_MUTED:
return Icons.micLocalMuted(size);
case AWAY:
return Icons.clientAway(size);
case COMMANDER_TALKING:
return Icons.clientCommanderTalking(size);
case COMMANDER:
return Icons.clientCommander(size);
case TALKING:
return Icons.clientTalking(size);
default:
return Icons.clientIdle(size);
}
}
}

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. */

View File

@@ -1,9 +1,9 @@
package com.ts3client.ui;
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.session.ServerSession;
import javax.swing.JOptionPane;
import javax.swing.SwingUtilities;
@@ -11,75 +11,78 @@ import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* Marshals {@link ConnectionListener} callbacks onto the EDT and fans them out to
* this tab's views (tree, chat, info panel) and to {@link MainFrame}.
* Marshals {@link ServerSession} callbacks onto the EDT and fans them out to this tab's
* views (tree, chat, info panel) and to {@link MainFrame}.
*
* <p>Constructed before the {@link TeamspeakConnection} it listens to exists — the
* connection's constructor needs a listener up front — so {@link #attach} wires the
* rest of the tab in once everything else has been built.
* <p>Constructed before the session it listens to exists — the session's constructor
* needs a listener up front — so {@link #attach} wires the rest of the tab in once
* everything else has been built.
*/
final class ServerTabConnectionEvents implements ConnectionListener {
/** The handful of {@link ServerTab} fields this class updates but can't reach directly. */
interface Listener {
void setStatus(String status);
void setTitle(String title);
void setConnecting(boolean connecting);
}
final class ServerTabConnectionEvents implements ServerSession.Listener {
private final MainFrame host;
private final ServerTab tab;
private final Listener listener;
private final Settings settings;
private final AtomicBoolean modelChangePending = new AtomicBoolean();
private ServerSession session;
private TeamspeakConnection conn;
private ServerTreePanel treePanel;
private ChatPanel chatPanel;
private ServerTabSelfState selfState;
private ServerTabTreeActions treeActions;
ServerTabConnectionEvents(MainFrame host, ServerTab tab, Listener listener) {
ServerTabConnectionEvents(MainFrame host, ServerTab tab, Settings settings) {
this.host = host;
this.tab = tab;
this.listener = listener;
this.settings = settings;
}
void attach(TeamspeakConnection conn, ServerTreePanel treePanel, ChatPanel chatPanel,
ServerTabSelfState selfState, ServerTabTreeActions treeActions) {
this.conn = conn;
void attach(ServerSession session, ServerTreePanel treePanel, ChatPanel chatPanel,
ServerTabTreeActions treeActions) {
this.session = session;
this.conn = session.connection();
this.treePanel = treePanel;
this.chatPanel = chatPanel;
this.selfState = selfState;
this.treeActions = treeActions;
}
/** Name of the channel we are in, or null while that is not known yet. */
private String currentChannelName() {
ClientEntry self = conn.getModel().getClient(conn.getSelfClientId());
if (self == null) return null;
ChannelNode ch = conn.getModel().getChannel(self.channelId);
return ch == null ? null : ch.name;
}
@Override
public void onStatus(String text) {
public void onSessionChanged() {
SwingUtilities.invokeLater(() -> {
listener.setStatus(text);
if (session.isConnected()) tab.banListServerChanged(true);
host.tabUpdated(tab);
});
}
@Override
public void onSystemMessage(String text) {
SwingUtilities.invokeLater(() -> chatPanel.appendSystem(text));
}
@Override
public void onOwnChat(ChatScope scope, int peerClientId, String peerName, String text) {
String me = settings.nickname + " (you)";
int myId = conn.getSelfClientId();
SwingUtilities.invokeLater(() -> {
switch (scope) {
case SERVER -> chatPanel.appendServerMessage(myId, me, text);
case PRIVATE -> chatPanel.appendPrivateMessage(peerClientId, peerName, myId, me, text);
default -> chatPanel.appendChannelMessage(myId, me, text);
}
});
}
@Override
public void onStatus(String text) {
// Reaches the tab through onSessionChanged.
}
@Override
public void onConnected() {
SwingUtilities.invokeLater(() -> {
listener.setConnecting(false);
treePanel.setSelfClientId(conn.getSelfClientId());
selfState.resetOnConnect();
chatPanel.setInputEnabled(true);
chatPanel.appendSystem("Connected.");
host.tabConnected(tab);
});
}
@@ -87,8 +90,6 @@ final class ServerTabConnectionEvents implements ConnectionListener {
@Override
public void onDisconnected(String reason) {
SwingUtilities.invokeLater(() -> {
listener.setConnecting(false);
conn.getModel().clear();
treePanel.showDisconnected();
treeActions.clearSelection();
chatPanel.setInputEnabled(false);
@@ -96,7 +97,6 @@ final class ServerTabConnectionEvents implements ConnectionListener {
chatPanel.closeNoteTabs();
chatPanel.setServerName(null);
chatPanel.setChannelName(null);
chatPanel.appendSystem("Disconnected" + (reason == null || reason.isEmpty() ? "." : ": " + reason));
tab.banListServerChanged(false);
host.tabDisconnected(tab);
});
@@ -111,15 +111,10 @@ final class ServerTabConnectionEvents implements ConnectionListener {
treePanel.rebuild();
treeActions.renderInfo();
treeActions.refreshGroupsDialog();
String name = conn.getModel().getServerName();
if (conn.isConnected() && name != null && !name.isBlank() && !name.equals(tab.title())) {
listener.setTitle(name);
host.tabUpdated(tab);
tab.banListServerChanged(true);
}
if (conn.isConnected()) {
chatPanel.setServerName(name);
chatPanel.setChannelName(currentChannelName());
if (session.isConnected()) {
ChannelNode channel = session.currentChannel();
chatPanel.setServerName(conn.getModel().getServerName());
chatPanel.setChannelName(channel == null ? null : channel.name);
}
});
}
@@ -138,20 +133,13 @@ final class ServerTabConnectionEvents implements ConnectionListener {
}
@Override
public void onChat(ChatScope scope, int fromClientId, String fromUniqueId, String nickname, String message) {
String fromName = conn.getContacts().displayName(fromUniqueId, nickname);
public void onChat(ChatScope scope, int fromClientId, String fromUniqueId, String fromName, String message) {
SwingUtilities.invokeLater(() -> {
switch (scope) {
case PRIVATE:
chatPanel.appendPrivateMessage(fromClientId, fromUniqueId, fromName,
fromClientId, fromUniqueId, fromName, message);
break;
case SERVER:
chatPanel.appendServerMessage(fromClientId, fromUniqueId, fromName, message);
break;
default:
chatPanel.appendChannelMessage(fromClientId, fromUniqueId, fromName, message);
break;
case PRIVATE -> chatPanel.appendPrivateMessage(fromClientId, fromUniqueId, fromName,
fromClientId, fromUniqueId, fromName, message);
case SERVER -> chatPanel.appendServerMessage(fromClientId, fromUniqueId, fromName, message);
default -> chatPanel.appendChannelMessage(fromClientId, fromUniqueId, fromName, message);
}
});
}
@@ -179,11 +167,7 @@ final class ServerTabConnectionEvents implements ConnectionListener {
@Override
public void onError(String message) {
SwingUtilities.invokeLater(() -> {
chatPanel.appendSystem("Error: " + message);
listener.setStatus(message);
host.tabUpdated(tab);
});
// Reaches the chat through onSystemMessage and the tab through onSessionChanged.
}
@Override
@@ -199,7 +183,6 @@ final class ServerTabConnectionEvents implements ConnectionListener {
@Override
public void onPoke(String fromName, String message) {
SwingUtilities.invokeLater(() -> {
chatPanel.appendSystem("You were poked by " + fromName + ": " + message);
host.selectTab(tab);
JOptionPane.showMessageDialog(host, fromName + " poked you:\n\n" + message,
"Poke", JOptionPane.INFORMATION_MESSAGE);

View File

@@ -69,7 +69,7 @@ final class ServerTabPane extends JPanel {
updating = true;
try {
tabbed.removeAll();
for (ServerTab tab : tabs) tabbed.addTab(tab.title(), tab.component());
for (ServerTab tab : tabs) tabbed.addTab(tab.session().title(), tab.component());
if (selected != null) tabbed.setSelectedIndex(tabs.indexOf(selected));
} finally {
updating = false;
@@ -121,8 +121,8 @@ final class ServerTabPane extends JPanel {
if (!tabbedMode) return;
for (int i = 0; i < tabs.size(); i++) {
ServerTab tab = tabs.get(i);
tabbed.setTitleAt(i, tab.title());
tabbed.setToolTipTextAt(i, tab.status());
tabbed.setTitleAt(i, tab.session().title());
tabbed.setToolTipTextAt(i, tab.session().status());
tabbed.setTabComponentAt(i, buildTabComponent(tab, tab == micTab));
}
}
@@ -138,7 +138,7 @@ final class ServerTabPane extends JPanel {
removeAll();
tabbed.removeAll();
if (wantTabs) {
for (ServerTab tab : tabs) tabbed.addTab(tab.title(), tab.component());
for (ServerTab tab : tabs) tabbed.addTab(tab.session().title(), tab.component());
add(tabbed, BorderLayout.CENTER);
} else if (!tabs.isEmpty()) {
add(tabs.get(0).component(), BorderLayout.CENTER);
@@ -157,9 +157,9 @@ final class ServerTabPane extends JPanel {
cell.setOpaque(false);
cell.setBorder(new ScaledEmptyBorder(1, 0, 1, 0));
JLabel label = new JLabel(tab.title(), hasMic ? Icons.micActiveSmall() : null, JLabel.LEADING);
JLabel label = new JLabel(tab.session().title(), hasMic ? Icons.micActiveSmall() : null, JLabel.LEADING);
label.setFont(Theme.uiFont());
label.setToolTipText(hasMic ? "Speaking on this server" : tab.status());
label.setToolTipText(hasMic ? "Speaking on this server" : tab.session().status());
// A label with a tooltip swallows mouse events, so select the tab explicitly.
label.addMouseListener(new MouseAdapter() {
@Override

View File

@@ -1,105 +0,0 @@
package com.ts3client.ui;
import com.ts3client.net.ClientEntry;
import com.ts3client.net.TeamspeakConnection;
/**
* Mic/speaker/away/commander flags for one connection, plus forwarding them into
* {@link TeamspeakConnection}. Split out of {@link ServerTab} because the flags
* are reset together in one place (on (re)connect) and read from several
* (tray icon, toolbar, menu checkmarks).
*/
final class ServerTabSelfState {
private final TeamspeakConnection conn;
private boolean micMuted;
private boolean micLocalMuted;
private boolean deafened;
private boolean away;
/** Away message currently published, empty when away carries no message. */
private String awayMessage = "";
private boolean commander;
ServerTabSelfState(TeamspeakConnection conn) {
this.conn = conn;
}
boolean isMicMuted() {
return micMuted;
}
boolean isMicLocalMuted() {
return micLocalMuted;
}
boolean isDeafened() {
return deafened;
}
boolean isAway() {
return away;
}
String awayMessage() {
return awayMessage;
}
boolean isCommander() {
return commander;
}
void setMicMuted(boolean muted) {
micMuted = muted;
conn.setMicMuted(muted);
}
/** TS3's "Local Mic Mute": silences capture without publishing a status change. */
void setMicLocalMuted(boolean muted) {
micLocalMuted = muted;
conn.setMicLocalMuted(muted);
}
void setDeafened(boolean deaf) {
deafened = deaf;
conn.setDeafened(deaf);
}
/**
* @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) {
this.away = away;
if (message != null) this.awayMessage = message;
conn.setAway(away, awayMessage);
}
void setCommander(boolean commander) {
this.commander = commander;
conn.setChannelCommander(commander);
}
/** Clears all flags on a fresh connection; the server starts us out clean, so nothing to publish. */
void resetOnConnect() {
micMuted = false;
micLocalMuted = false;
deafened = false;
away = false;
awayMessage = "";
commander = false;
}
/** What the local client looks like on this server, for the tray icon. */
SelfState compute() {
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 = conn.getModel().getClient(conn.getSelfClientId());
boolean talking = self != null && self.talking;
if (commander) return talking ? SelfState.COMMANDER_TALKING : SelfState.COMMANDER;
return talking ? SelfState.TALKING : SelfState.IDLE;
}
}

View File

@@ -1,9 +1,11 @@
package com.ts3client.ui;
import com.ts3client.session.SelfState;
import com.ts3client.ui.tray.AwtTray;
import com.ts3client.ui.tray.TrayBackend;
import com.ts3client.ui.tray.X11Tray;
import javax.swing.ImageIcon;
import javax.swing.JComponent;
import javax.swing.JDialog;
import javax.swing.JMenuItem;
@@ -76,7 +78,7 @@ final class TrayController {
// -Dts3j.tray=awt|x11 pins the backend; by default the transparent one is
// tried first and AWT's picks up whatever it leaves.
String choice = System.getProperty("ts3j.tray", "auto");
IntFunction<Image> initial = size -> SelfState.DISCONNECTED.icon(size).getImage();
IntFunction<Image> initial = size -> icon(SelfState.DISCONNECTED, size).getImage();
TrayBackend backend = choice.equals("awt") ? null : X11Tray.create(listener, initial);
if (backend == null && !choice.equals("x11")) {
backend = AwtTray.create(listener, buildAwtMenu(), initial);
@@ -93,7 +95,7 @@ final class TrayController {
if (newState == state && newTooltip.equals(tooltip)) return;
state = newState;
tooltip = newTooltip;
tray.setIcon(size -> newState.icon(size).getImage());
tray.setIcon(size -> icon(newState, size).getImage());
tray.setTooltip(newTooltip);
}
@@ -101,13 +103,28 @@ final class TrayController {
void refreshIcon() {
if (tray == null || state == null) return;
SelfState current = state;
tray.setIcon(size -> current.icon(size).getImage());
tray.setIcon(size -> icon(current, size).getImage());
}
void dispose() {
if (tray != null) tray.dispose();
}
/** The icon a state is drawn with, from the active icon pack. */
private static ImageIcon icon(SelfState state, int size) {
return switch (state) {
case DISCONNECTED -> Icons.app(size);
case DEAFENED -> Icons.speakerMuted(size);
case MIC_MUTED -> Icons.micMuted(size);
case MIC_LOCAL_MUTED -> Icons.micLocalMuted(size);
case AWAY -> Icons.clientAway(size);
case COMMANDER_TALKING -> Icons.clientCommanderTalking(size);
case COMMANDER -> Icons.clientCommander(size);
case TALKING -> Icons.clientTalking(size);
case IDLE -> Icons.clientIdle(size);
};
}
// ---- menus ----
/** The menu for AWT's icon, which shows one of its own. */