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

View File

@@ -38,10 +38,10 @@ final class HotkeyActions implements HotkeyEngine.Handler {
case CONNECT_NEW_TAB -> frame.connectBookmark(hotkey.argument, true); case CONNECT_NEW_TAB -> frame.connectBookmark(hotkey.argument, true);
case DISCONNECT_CURRENT -> { case DISCONNECT_CURRENT -> {
ServerTab tab = frame.selectedTab(); ServerTab tab = frame.selectedTab();
if (tab != null) tab.disconnect(); if (tab != null) tab.session().disconnect();
} }
case DISCONNECT_ALL -> { case DISCONNECT_ALL -> {
for (ServerTab tab : frame.allTabs()) tab.disconnect(); for (ServerTab tab : frame.allTabs()) tab.session().disconnect();
} }
case MIC_ACTIVATE -> frame.moveMicrophoneToSelectedTab(); case MIC_ACTIVATE -> frame.moveMicrophoneToSelectedTab();
@@ -78,7 +78,7 @@ final class HotkeyActions implements HotkeyEngine.Handler {
} }
case CHANNEL_SWITCH -> { 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_SELECT -> frame.selectTabNumber(parseIndex(hotkey.argument));
case SERVER_TAB_NEXT -> frame.stepTab(1); 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) { 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(); frame.refreshAfterHotkey();
} }
private void setMicLocalMuted(List<ServerTab> targets, boolean muted) { 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(); frame.refreshAfterHotkey();
} }
private void setDeafened(List<ServerTab> targets, boolean deaf) { 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(); frame.refreshAfterHotkey();
} }
private void setAway(List<ServerTab> targets, boolean away, String message) { 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(); frame.refreshAfterHotkey();
} }
private void setCommander(List<ServerTab> targets, boolean commander) { 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(); frame.refreshAfterHotkey();
} }
private static boolean anyMicMuted(List<ServerTab> tabs) { 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) { 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) { 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) { 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) { 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 */ /** @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.teamspeak.TeamSpeakImporter;
import com.ts3client.contacts.ContactStore; import com.ts3client.contacts.ContactStore;
import com.ts3client.net.ChannelNode; import com.ts3client.net.ChannelNode;
import com.ts3client.session.SelfState;
import com.ts3client.sound.SoundNotifier; import com.ts3client.sound.SoundNotifier;
import com.ts3client.sound.SoundPlayer; import com.ts3client.sound.SoundPlayer;
@@ -201,7 +202,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
@Override @Override
public void onCommanderToggle(boolean commander) { public void onCommanderToggle(boolean commander) {
if (selected != null) selected.setCommander(commander); if (selected != null) selected.session().setCommander(commander);
} }
@Override @Override
@@ -211,7 +212,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
@Override @Override
public void onSetAvatar() { public void onSetAvatar() {
if (selected != null && selected.isConnected()) selected.showAvatarDialog(); if (selected != null && selected.session().isConnected()) selected.showAvatarDialog();
} }
@Override @Override
@@ -226,7 +227,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
@Override @Override
public boolean canViewBanList() { public boolean canViewBanList() {
return selected != null && selected.canViewBanList(); return selected != null && selected.session().canViewBanList();
} }
@Override @Override
@@ -283,14 +284,14 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
@Override @Override
public void onMicMuteToggle(boolean muted) { public void onMicMuteToggle(boolean muted) {
if (selected == null) return; if (selected == null) return;
selected.setMicMuted(muted); selected.session().setMicMuted(muted);
updateToolbar(); updateToolbar();
} }
@Override @Override
public void onDeafenToggle(boolean deafened) { public void onDeafenToggle(boolean deafened) {
if (selected == null) return; if (selected == null) return;
selected.setDeafened(deafened); selected.session().setDeafened(deafened);
updateToolbar(); 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. */ /** The tab a new connection should use: the current one if it is free, else a new one. */
private ServerTab tabForNewConnection() { private ServerTab tabForNewConnection() {
if (selected != null && !selected.isBusy()) return selected; if (selected != null && !selected.session().isBusy()) return selected;
ServerTab tab = newTab(); ServerTab tab = newTab();
selectTab(tab); selectTab(tab);
return tab; return tab;
@@ -369,11 +370,11 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
@Override @Override
public void closeTab(ServerTab tab) { public void closeTab(ServerTab tab) {
if (tab == null) return; if (tab == null) return;
tab.disconnect(); tab.session().disconnect();
if (tabs.size() == 1) return; // always keep one view around if (tabs.size() == 1) return; // always keep one view around
tabs.remove(tab); tabs.remove(tab);
tab.dispose(); tab.session().dispose();
tabPane.removeTab(tab); tabPane.removeTab(tab);
if (micTab == tab) micTab = null; if (micTab == tab) micTab = null;
if (selected == tab) { if (selected == tab) {
@@ -394,17 +395,17 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
updateTray(); updateTray();
// Closing and reopening the capture line can block briefly; keep it off the EDT. // Closing and reopening the capture line can block briefly; keep it off the EDT.
new Thread(() -> { new Thread(() -> {
if (previous != null) previous.setMicrophoneActive(false); if (previous != null) previous.session().setMicrophoneActive(false);
if (tab != null) tab.setMicrophoneActive(true); if (tab != null) tab.session().setMicrophoneActive(true);
}, "mic-handover").start(); }, "mic-handover").start();
} }
/** Gives the microphone to some connected tab when nobody holds it. */ /** Gives the microphone to some connected tab when nobody holds it. */
private void assignMicrophoneIfFree() { private void assignMicrophoneIfFree() {
if (micTab != null && tabs.contains(micTab) && micTab.isConnected()) return; if (micTab != null && tabs.contains(micTab) && micTab.session().isConnected()) return;
micTab = null; micTab = null;
for (ServerTab t : tabs) { for (ServerTab t : tabs) {
if (t.isConnected()) { if (t.session().isConnected()) {
setMicTab(t); setMicTab(t);
return; return;
} }
@@ -458,11 +459,11 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
List<ServerTab> hotkeyTargets(boolean activeServerOnly) { List<ServerTab> hotkeyTargets(boolean activeServerOnly) {
List<ServerTab> out = new ArrayList<>(); List<ServerTab> out = new ArrayList<>();
if (activeServerOnly) { if (activeServerOnly) {
if (selected != null && selected.isConnected()) out.add(selected); if (selected != null && selected.session().isConnected()) out.add(selected);
return out; return out;
} }
for (ServerTab tab : tabs) { for (ServerTab tab : tabs) {
if (tab.isConnected()) out.add(tab); if (tab.session().isConnected()) out.add(tab);
} }
return out; return out;
} }
@@ -479,21 +480,21 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
void setPushToTalk(boolean talking) { void setPushToTalk(boolean talking) {
pttPressed = talking; pttPressed = talking;
ServerTab tab = micTab; 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); tab.connection().getMicrophone().setPushToTalk(talking);
} }
/** Tells every connected microphone about a key press, as TS3 tells all its connections. */ /** Tells every connected microphone about a key press, as TS3 tells all its connections. */
private void keyPressed() { private void keyPressed() {
for (ServerTab tab : tabs) { for (ServerTab tab : tabs) {
if (tab.isConnected() && tab.connection().getMicrophone() != null) { if (tab.session().isConnected() && tab.connection().getMicrophone() != null) {
tab.connection().getMicrophone().keyPressed(); tab.connection().getMicrophone().keyPressed();
} }
} }
} }
void moveMicrophoneToSelectedTab() { void moveMicrophoneToSelectedTab() {
if (selected != null && selected.isConnected()) setMicTab(selected); if (selected != null && selected.session().isConnected()) setMicTab(selected);
updateToolbar(); updateToolbar();
} }
@@ -545,12 +546,12 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
} }
settings.nickname = nickname.trim(); settings.nickname = nickname.trim();
settings.save(); settings.save();
for (ServerTab tab : tabs) tab.setNickname(settings.nickname); for (ServerTab tab : tabs) tab.session().setNickname(settings.nickname);
} }
void browseCurrentChannel() { void browseCurrentChannel() {
if (selected == null || !selected.isConnected()) return; if (selected == null || !selected.session().isConnected()) return;
ChannelNode channel = selected.currentChannel(); ChannelNode channel = selected.session().currentChannel();
if (channel != null) selected.browseFiles(channel); if (channel != null) selected.browseFiles(channel);
} }
@@ -588,8 +589,8 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
} }
} }
case CHANNEL -> { case CHANNEL -> {
if (selected != null && selected.isConnected()) { if (selected != null && selected.session().isConnected()) {
out.addAll(selected.channelPaths()); out.addAll(selected.session().channelPaths());
} }
} }
default -> { 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 // The first connection to come up takes the microphone; later ones are muted
// until the user activates them. // until the user activates them.
if (micTab == null) setMicTab(tab); 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. */ /** 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() { private void addCurrentServerBookmark() {
if (selected == null || !selected.isConnected()) { if (selected == null || !selected.session().isConnected()) {
JOptionPane.showMessageDialog(this, "Connect to a server first.", JOptionPane.showMessageDialog(this, "Connect to a server first.",
"Add bookmark", JOptionPane.INFORMATION_MESSAGE); "Add bookmark", JOptionPane.INFORMATION_MESSAGE);
return; return;
} }
String addr = selected.address(); String addr = selected.session().address();
int port = selected.port(); int port = selected.session().port();
String channelPath = selected.currentChannelPath(); 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()); JCheckBox joinChannel = new JCheckBox("Join \"" + channelPath + "\" on connect", !channelPath.isEmpty());
JPanel form = new JPanel(new GridLayout(0, 1, 0, UIScale.scale(2))); JPanel form = new JPanel(new GridLayout(0, 1, 0, UIScale.scale(2)));
form.add(new JLabel("Bookmark label:")); 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, Bookmark bookmark = new Bookmark(labelField.getText().trim(), addr, port,
settings.nickname, settings.serverPassword); settings.nickname, settings.serverPassword);
bookmark.identityId = selected.identityId(); bookmark.identityId = selected.session().identityId();
if (joinChannel.isSelected()) bookmark.channel = channelPath; if (joinChannel.isSelected()) bookmark.channel = channelPath;
bookmarks.add(bookmark); bookmarks.add(bookmark);
bookmarks.save(); 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. */ /** The away button's drop-down: the global actions, the presets and their editor. */
private JPopupMenu buildAwayMenu() { private JPopupMenu buildAwayMenu() {
JPopupMenu menu = new JPopupMenu(); 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()); JCheckBoxMenuItem globally = new JCheckBoxMenuItem("Set Globally Away", Icons.of("AWAY"), isGloballyAway());
globally.setEnabled(anyConnected); globally.setEnabled(anyConnected);
@@ -716,22 +717,22 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
private void toggleAway(boolean away) { private void toggleAway(boolean away) {
if (selected == null) return; if (selected == null) return;
selected.setAway(away, ""); selected.session().setAway(away, "");
updateToolbar(); updateToolbar();
} }
/** Sets the away message on the selected server only. */ /** Sets the away message on the selected server only. */
private void setAwayStatus() { private void setAwayStatus() {
if (selected == null) return; if (selected == null) return;
String message = askAwayMessage(selected.awayMessage()); String message = askAwayMessage(selected.session().awayMessage());
if (message == null) return; if (message == null) return;
selected.setAway(true, message); selected.session().setAway(true, message);
updateToolbar(); updateToolbar();
} }
private void setAwayEverywhere(boolean away, String message) { private void setAwayEverywhere(boolean away, String message) {
for (ServerTab tab : tabs) { for (ServerTab tab : tabs) {
if (tab.isConnected()) tab.setAway(away, message); if (tab.session().isConnected()) tab.session().setAway(away, message);
} }
updateToolbar(); updateToolbar();
} }
@@ -740,18 +741,18 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
private boolean isGloballyAway() { private boolean isGloballyAway() {
boolean any = false; boolean any = false;
for (ServerTab tab : tabs) { for (ServerTab tab : tabs) {
if (!tab.isConnected()) continue; if (!tab.session().isConnected()) continue;
any = true; any = true;
if (!tab.isAway()) return false; if (!tab.session().isAway()) return false;
} }
return any; return any;
} }
/** The message to preload the prompt with: the selected tab's, else any set one. */ /** The message to preload the prompt with: the selected tab's, else any set one. */
private String currentAwayMessage() { 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) { 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 ""; return "";
} }
@@ -763,7 +764,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
} }
private void doDisconnect() { 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. // Already shutting down (hook itself is running); nothing to remove.
} }
for (ServerTab tab : new ArrayList<>(tabs)) { for (ServerTab tab : new ArrayList<>(tabs)) {
tab.shutdown(); tab.session().shutdown();
} }
soundPlayer.shutdown(); soundPlayer.shutdown();
hotkeys.dispose(); hotkeys.dispose();
@@ -860,7 +861,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
if (n != null && !n.trim().isEmpty()) { if (n != null && !n.trim().isEmpty()) {
settings.nickname = n.trim(); settings.nickname = n.trim();
settings.save(); 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 ---- // ---- toolbar / status ----
private void updateToolbar() { private void updateToolbar() {
boolean connected = selected != null && selected.isConnected(); boolean connected = selected != null && selected.session().isConnected();
boolean anyConnected = tabs.stream().anyMatch(ServerTab::isConnected); boolean anyConnected = tabs.stream().anyMatch(t -> t.session().isConnected());
boolean micMuted = connected && selected.isMicMuted(); boolean micMuted = connected && selected.session().isMicMuted();
boolean deaf = connected && selected.isDeafened(); boolean deaf = connected && selected.session().isDeafened();
boolean active = selected != null && selected == micTab; boolean active = selected != null && selected == micTab;
boolean away = connected && selected.isAway(); boolean away = connected && selected.session().isAway();
boolean commander = connected && selected.isCommander(); boolean commander = connected && selected.session().isCommander();
toolbar.refresh(connected, anyConnected, micMuted, deaf, active, away); toolbar.refresh(connected, anyConnected, micMuted, deaf, active, away);
menuBar.refresh(connected, away, commander); menuBar.refresh(connected, away, commander);
updateTray(); updateTray();
@@ -895,8 +896,8 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
* visible one when nobody is capturing. * visible one when nobody is capturing.
*/ */
private ServerTab trayTab() { private ServerTab trayTab() {
if (micTab != null && micTab.isConnected()) return micTab; if (micTab != null && micTab.session().isConnected()) return micTab;
return selected != null && selected.isConnected() ? selected : null; return selected != null && selected.session().isConnected() ? selected : null;
} }
private void updateTray() { private void updateTray() {
@@ -905,17 +906,17 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
if (tab == null) { if (tab == null) {
tray.update(SelfState.DISCONNECTED, null); tray.update(SelfState.DISCONNECTED, null);
} else { } else {
tray.update(tab.selfState(), tab.title()); tray.update(tab.session().selfState(), tab.session().title());
} }
} }
private void updateStatusLabel() { private void updateStatusLabel() {
statusBar.setStatus(selected == null ? "Not connected" : selected.status()); statusBar.setStatus(selected == null ? "Not connected" : selected.session().status());
} }
private void updateConnectionStatus() { private void updateConnectionStatus() {
ServerTab tab = selected; ServerTab tab = selected;
if (tab == null || !tab.isConnected()) return; if (tab == null || !tab.session().isConnected()) return;
int users = tab.connection().getModel().clientCount(); int users = tab.connection().getModel().clientCount();
StringBuilder s = new StringBuilder("Connected to ") StringBuilder s = new StringBuilder("Connected to ")
.append(tab.connection().getModel().getServerName()) .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; package com.ts3client.ui;
import com.ts3client.audio.AudioBackend; import com.ts3client.audio.AudioBackend;
import com.ts3client.config.IdentityEntry;
import com.ts3client.config.IdentityStore;
import com.ts3client.config.BanReasonPresets; import com.ts3client.config.BanReasonPresets;
import com.ts3client.config.IdentityStore;
import com.ts3client.config.Settings; import com.ts3client.config.Settings;
import com.ts3client.contacts.ContactStore; import com.ts3client.contacts.ContactStore;
import com.ts3client.net.ChannelNode; import com.ts3client.net.ChannelNode;
import com.ts3client.net.ClientEntry; import com.ts3client.net.ConnectionListener.ChatScope;
import com.ts3client.net.ServerModel;
import com.ts3client.net.TeamspeakConnection; import com.ts3client.net.TeamspeakConnection;
import com.ts3client.session.ServerSession;
import com.ts3client.sound.SoundNotifier; import com.ts3client.sound.SoundNotifier;
import com.ts3client.text.TsLink; import com.ts3client.text.TsLink;
import javax.swing.JComponent; import javax.swing.JComponent;
import javax.swing.JSplitPane; import javax.swing.JSplitPane;
import javax.swing.SwingUtilities;
import java.util.ArrayList;
import java.util.List;
import java.awt.Component; import java.awt.Component;
import java.awt.event.ComponentAdapter; import java.awt.event.ComponentAdapter;
import java.awt.event.ComponentEvent; 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 * 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. * 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 * <p>The connection and the client's own state on it live in the {@link ServerSession};
* (title/status); the mic/away/deafen flags live in {@link ServerTabSelfState}, * ServerTab owns the panels, tree context-menu actions and selection live in
* tree context-menu actions and selection in {@link ServerTabTreeActions}, and * {@link ServerTabTreeActions}, and session callbacks in {@link ServerTabConnectionEvents}.
* {@link com.ts3client.net.ConnectionListener} callbacks in {@link ServerTabConnectionEvents}.
*/ */
final class ServerTab implements ServerTabConnectionEvents.Listener { final class ServerTab {
private final MainFrame host; private final MainFrame host;
private final Settings settings; private final Settings settings;
private final IdentityStore identities;
private final ServerSession session;
private final TeamspeakConnection conn; private final TeamspeakConnection conn;
private final GroupIcons groupIcons; private final GroupIcons groupIcons;
private final ServerTabConnectionEvents events;
private final ServerTabSelfState selfState;
private final ServerTabTreeActions treeActions; private final ServerTabTreeActions treeActions;
private final ServerTreePanel treePanel; private final ServerTreePanel treePanel;
private final ChatPanel chatPanel; 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. */ /** Whether the saved divider share has been applied; before that, layout is not the user's doing. */
private boolean splitRestored; 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. */ /** The one open ban list window for this server, if any. */
private BanListDialog banList; private BanListDialog banList;
@@ -68,21 +53,20 @@ final class ServerTab implements ServerTabConnectionEvents.Listener {
SoundNotifier sounds, ContactStore contacts) { SoundNotifier sounds, ContactStore contacts) {
this.host = host; this.host = host;
this.settings = settings; this.settings = settings;
this.identities = identities;
// ServerTabConnectionEvents must exist before the connection (which needs a // ServerTabConnectionEvents must exist before the session (which needs a listener
// listener up front), and TeamspeakConnection must exist before the tree/chat // up front), and the session must exist before the tree/chat panels and the other
// panels and the other tab helpers that read from it — so wiring finishes with // tab helpers that read from it — so wiring finishes with an explicit attach() once
// an explicit attach() once everything is built. Nothing fires callbacks before then. // everything is built. Nothing fires callbacks before then.
this.events = new ServerTabConnectionEvents(host, this, this); ServerTabConnectionEvents events = new ServerTabConnectionEvents(host, this, settings);
this.conn = new TeamspeakConnection(settings, audio, events, sounds, contacts); this.session = new ServerSession(settings, identities, audio, sounds, contacts, events);
this.conn = session.connection();
this.groupIcons = new GroupIcons(conn.getIcons()); this.groupIcons = new GroupIcons(conn.getIcons());
this.selfState = new ServerTabSelfState(conn);
this.chatPanel = new ChatPanel(); this.chatPanel = new ChatPanel();
this.treeActions = new ServerTabTreeActions(host, this, conn, chatPanel, infoPanel, groupIcons); this.treeActions = new ServerTabTreeActions(host, this, conn, chatPanel, infoPanel, groupIcons);
this.treePanel = new ServerTreePanel(conn.getModel(), groupIcons, contacts, settings, treeActions); this.treePanel = new ServerTreePanel(conn.getModel(), groupIcons, contacts, settings, treeActions);
treeActions.attach(treePanel); treeActions.attach(treePanel);
events.attach(conn, treePanel, chatPanel, selfState, treeActions); events.attach(session, treePanel, chatPanel, treeActions);
chatPanel.setSendHandler(this::onSendChat); chatPanel.setSendHandler(this::onSendChat);
chatPanel.setLinkHandler(new ChatPanel.LinkHandler() { chatPanel.setLinkHandler(new ChatPanel.LinkHandler() {
@@ -143,6 +127,10 @@ final class ServerTab implements ServerTabConnectionEvents.Listener {
return component; return component;
} }
ServerSession session() {
return session;
}
TeamspeakConnection connection() { TeamspeakConnection connection() {
return conn; return conn;
} }
@@ -151,140 +139,9 @@ final class ServerTab implements ServerTabConnectionEvents.Listener {
return chatPanel; 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. */ /** Opens the server's ban list, or brings the already open one forward. */
void showBanList(BanReasonPresets presets) { void showBanList(BanReasonPresets presets) {
if (!conn.isConnected()) return; if (!session.isConnected()) return;
if (banList == null || !banList.isDisplayable()) { if (banList == null || !banList.isDisplayable()) {
banList = new BanListDialog(host, conn, settings, presets); banList = new BanListDialog(host, conn, settings, presets);
} }
@@ -306,68 +163,7 @@ final class ServerTab implements ServerTabConnectionEvents.Listener {
treeActions.renderInfo(); treeActions.renderInfo();
} }
/** Releases the background resources of a tab that is being thrown away. */ // ---- dialogs ----
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);
}
void showAvatarDialog() { void showAvatarDialog() {
if (conn.isConnected()) new AvatarDialog(host, conn).setVisible(true); if (conn.isConnected()) new AvatarDialog(host, conn).setVisible(true);
@@ -376,58 +172,12 @@ final class ServerTab implements ServerTabConnectionEvents.Listener {
// ---- chat ---- // ---- chat ----
private void onSendChat(ChatPanel.Target target, int clientId, String text) { private void onSendChat(ChatPanel.Target target, int clientId, String text) {
if (!conn.isConnected()) { ChatScope scope = switch (target) {
chatPanel.appendSystem("Not connected."); case SERVER -> ChatScope.SERVER;
return; case PRIVATE -> ChatScope.PRIVATE;
} default -> ChatScope.CHANNEL;
String me = settings.nickname + " (you)"; };
int myId = conn.getSelfClientId(); session.sendChat(scope, clientId, text);
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);
} }
/** Opens the file repository browser for a channel, as the "Browse Files" hotkey/menu action does. */ /** 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; package com.ts3client.ui;
import com.ts3client.config.Settings;
import com.ts3client.net.ChannelNode; import com.ts3client.net.ChannelNode;
import com.ts3client.net.ClientEntry;
import com.ts3client.net.ConnectionListener;
import com.ts3client.net.TeamspeakConnection; import com.ts3client.net.TeamspeakConnection;
import com.ts3client.session.ServerSession;
import javax.swing.JOptionPane; import javax.swing.JOptionPane;
import javax.swing.SwingUtilities; import javax.swing.SwingUtilities;
@@ -11,75 +11,78 @@ import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicBoolean;
/** /**
* Marshals {@link ConnectionListener} callbacks onto the EDT and fans them out to * Marshals {@link ServerSession} callbacks onto the EDT and fans them out to this tab's
* this tab's views (tree, chat, info panel) and to {@link MainFrame}. * views (tree, chat, info panel) and to {@link MainFrame}.
* *
* <p>Constructed before the {@link TeamspeakConnection} it listens to exists — the * <p>Constructed before the session it listens to exists — the session's constructor
* connection's constructor needs a listener up front — so {@link #attach} wires the * needs a listener up front — so {@link #attach} wires the rest of the tab in once
* rest of the tab in once everything else has been built. * everything else has been built.
*/ */
final class ServerTabConnectionEvents implements ConnectionListener { final class ServerTabConnectionEvents implements ServerSession.Listener {
/** 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);
}
private final MainFrame host; private final MainFrame host;
private final ServerTab tab; private final ServerTab tab;
private final Listener listener; private final Settings settings;
private final AtomicBoolean modelChangePending = new AtomicBoolean(); private final AtomicBoolean modelChangePending = new AtomicBoolean();
private ServerSession session;
private TeamspeakConnection conn; private TeamspeakConnection conn;
private ServerTreePanel treePanel; private ServerTreePanel treePanel;
private ChatPanel chatPanel; private ChatPanel chatPanel;
private ServerTabSelfState selfState;
private ServerTabTreeActions treeActions; private ServerTabTreeActions treeActions;
ServerTabConnectionEvents(MainFrame host, ServerTab tab, Listener listener) { ServerTabConnectionEvents(MainFrame host, ServerTab tab, Settings settings) {
this.host = host; this.host = host;
this.tab = tab; this.tab = tab;
this.listener = listener; this.settings = settings;
} }
void attach(TeamspeakConnection conn, ServerTreePanel treePanel, ChatPanel chatPanel, void attach(ServerSession session, ServerTreePanel treePanel, ChatPanel chatPanel,
ServerTabSelfState selfState, ServerTabTreeActions treeActions) { ServerTabTreeActions treeActions) {
this.conn = conn; this.session = session;
this.conn = session.connection();
this.treePanel = treePanel; this.treePanel = treePanel;
this.chatPanel = chatPanel; this.chatPanel = chatPanel;
this.selfState = selfState;
this.treeActions = treeActions; 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 @Override
public void onStatus(String text) { public void onSessionChanged() {
SwingUtilities.invokeLater(() -> { SwingUtilities.invokeLater(() -> {
listener.setStatus(text); if (session.isConnected()) tab.banListServerChanged(true);
host.tabUpdated(tab); 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 @Override
public void onConnected() { public void onConnected() {
SwingUtilities.invokeLater(() -> { SwingUtilities.invokeLater(() -> {
listener.setConnecting(false);
treePanel.setSelfClientId(conn.getSelfClientId()); treePanel.setSelfClientId(conn.getSelfClientId());
selfState.resetOnConnect();
chatPanel.setInputEnabled(true); chatPanel.setInputEnabled(true);
chatPanel.appendSystem("Connected.");
host.tabConnected(tab); host.tabConnected(tab);
}); });
} }
@@ -87,8 +90,6 @@ final class ServerTabConnectionEvents implements ConnectionListener {
@Override @Override
public void onDisconnected(String reason) { public void onDisconnected(String reason) {
SwingUtilities.invokeLater(() -> { SwingUtilities.invokeLater(() -> {
listener.setConnecting(false);
conn.getModel().clear();
treePanel.showDisconnected(); treePanel.showDisconnected();
treeActions.clearSelection(); treeActions.clearSelection();
chatPanel.setInputEnabled(false); chatPanel.setInputEnabled(false);
@@ -96,7 +97,6 @@ final class ServerTabConnectionEvents implements ConnectionListener {
chatPanel.closeNoteTabs(); chatPanel.closeNoteTabs();
chatPanel.setServerName(null); chatPanel.setServerName(null);
chatPanel.setChannelName(null); chatPanel.setChannelName(null);
chatPanel.appendSystem("Disconnected" + (reason == null || reason.isEmpty() ? "." : ": " + reason));
tab.banListServerChanged(false); tab.banListServerChanged(false);
host.tabDisconnected(tab); host.tabDisconnected(tab);
}); });
@@ -111,15 +111,10 @@ final class ServerTabConnectionEvents implements ConnectionListener {
treePanel.rebuild(); treePanel.rebuild();
treeActions.renderInfo(); treeActions.renderInfo();
treeActions.refreshGroupsDialog(); treeActions.refreshGroupsDialog();
String name = conn.getModel().getServerName(); if (session.isConnected()) {
if (conn.isConnected() && name != null && !name.isBlank() && !name.equals(tab.title())) { ChannelNode channel = session.currentChannel();
listener.setTitle(name); chatPanel.setServerName(conn.getModel().getServerName());
host.tabUpdated(tab); chatPanel.setChannelName(channel == null ? null : channel.name);
tab.banListServerChanged(true);
}
if (conn.isConnected()) {
chatPanel.setServerName(name);
chatPanel.setChannelName(currentChannelName());
} }
}); });
} }
@@ -138,20 +133,13 @@ final class ServerTabConnectionEvents implements ConnectionListener {
} }
@Override @Override
public void onChat(ChatScope scope, int fromClientId, String fromUniqueId, String nickname, String message) { public void onChat(ChatScope scope, int fromClientId, String fromUniqueId, String fromName, String message) {
String fromName = conn.getContacts().displayName(fromUniqueId, nickname);
SwingUtilities.invokeLater(() -> { SwingUtilities.invokeLater(() -> {
switch (scope) { switch (scope) {
case PRIVATE: case PRIVATE -> chatPanel.appendPrivateMessage(fromClientId, fromUniqueId, fromName,
chatPanel.appendPrivateMessage(fromClientId, fromUniqueId, fromName, fromClientId, fromUniqueId, fromName, message);
fromClientId, fromUniqueId, fromName, message); case SERVER -> chatPanel.appendServerMessage(fromClientId, fromUniqueId, fromName, message);
break; default -> chatPanel.appendChannelMessage(fromClientId, fromUniqueId, fromName, message);
case SERVER:
chatPanel.appendServerMessage(fromClientId, fromUniqueId, fromName, message);
break;
default:
chatPanel.appendChannelMessage(fromClientId, fromUniqueId, fromName, message);
break;
} }
}); });
} }
@@ -179,11 +167,7 @@ final class ServerTabConnectionEvents implements ConnectionListener {
@Override @Override
public void onError(String message) { public void onError(String message) {
SwingUtilities.invokeLater(() -> { // Reaches the chat through onSystemMessage and the tab through onSessionChanged.
chatPanel.appendSystem("Error: " + message);
listener.setStatus(message);
host.tabUpdated(tab);
});
} }
@Override @Override
@@ -199,7 +183,6 @@ final class ServerTabConnectionEvents implements ConnectionListener {
@Override @Override
public void onPoke(String fromName, String message) { public void onPoke(String fromName, String message) {
SwingUtilities.invokeLater(() -> { SwingUtilities.invokeLater(() -> {
chatPanel.appendSystem("You were poked by " + fromName + ": " + message);
host.selectTab(tab); host.selectTab(tab);
JOptionPane.showMessageDialog(host, fromName + " poked you:\n\n" + message, JOptionPane.showMessageDialog(host, fromName + " poked you:\n\n" + message,
"Poke", JOptionPane.INFORMATION_MESSAGE); "Poke", JOptionPane.INFORMATION_MESSAGE);

View File

@@ -69,7 +69,7 @@ final class ServerTabPane extends JPanel {
updating = true; updating = true;
try { try {
tabbed.removeAll(); 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)); if (selected != null) tabbed.setSelectedIndex(tabs.indexOf(selected));
} finally { } finally {
updating = false; updating = false;
@@ -121,8 +121,8 @@ final class ServerTabPane extends JPanel {
if (!tabbedMode) return; if (!tabbedMode) return;
for (int i = 0; i < tabs.size(); i++) { for (int i = 0; i < tabs.size(); i++) {
ServerTab tab = tabs.get(i); ServerTab tab = tabs.get(i);
tabbed.setTitleAt(i, tab.title()); tabbed.setTitleAt(i, tab.session().title());
tabbed.setToolTipTextAt(i, tab.status()); tabbed.setToolTipTextAt(i, tab.session().status());
tabbed.setTabComponentAt(i, buildTabComponent(tab, tab == micTab)); tabbed.setTabComponentAt(i, buildTabComponent(tab, tab == micTab));
} }
} }
@@ -138,7 +138,7 @@ final class ServerTabPane extends JPanel {
removeAll(); removeAll();
tabbed.removeAll(); tabbed.removeAll();
if (wantTabs) { 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); add(tabbed, BorderLayout.CENTER);
} else if (!tabs.isEmpty()) { } else if (!tabs.isEmpty()) {
add(tabs.get(0).component(), BorderLayout.CENTER); add(tabs.get(0).component(), BorderLayout.CENTER);
@@ -157,9 +157,9 @@ final class ServerTabPane extends JPanel {
cell.setOpaque(false); cell.setOpaque(false);
cell.setBorder(new ScaledEmptyBorder(1, 0, 1, 0)); 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.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. // A label with a tooltip swallows mouse events, so select the tab explicitly.
label.addMouseListener(new MouseAdapter() { label.addMouseListener(new MouseAdapter() {
@Override @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; package com.ts3client.ui;
import com.ts3client.session.SelfState;
import com.ts3client.ui.tray.AwtTray; import com.ts3client.ui.tray.AwtTray;
import com.ts3client.ui.tray.TrayBackend; import com.ts3client.ui.tray.TrayBackend;
import com.ts3client.ui.tray.X11Tray; import com.ts3client.ui.tray.X11Tray;
import javax.swing.ImageIcon;
import javax.swing.JComponent; import javax.swing.JComponent;
import javax.swing.JDialog; import javax.swing.JDialog;
import javax.swing.JMenuItem; import javax.swing.JMenuItem;
@@ -76,7 +78,7 @@ final class TrayController {
// -Dts3j.tray=awt|x11 pins the backend; by default the transparent one is // -Dts3j.tray=awt|x11 pins the backend; by default the transparent one is
// tried first and AWT's picks up whatever it leaves. // tried first and AWT's picks up whatever it leaves.
String choice = System.getProperty("ts3j.tray", "auto"); 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); TrayBackend backend = choice.equals("awt") ? null : X11Tray.create(listener, initial);
if (backend == null && !choice.equals("x11")) { if (backend == null && !choice.equals("x11")) {
backend = AwtTray.create(listener, buildAwtMenu(), initial); backend = AwtTray.create(listener, buildAwtMenu(), initial);
@@ -93,7 +95,7 @@ final class TrayController {
if (newState == state && newTooltip.equals(tooltip)) return; if (newState == state && newTooltip.equals(tooltip)) return;
state = newState; state = newState;
tooltip = newTooltip; tooltip = newTooltip;
tray.setIcon(size -> newState.icon(size).getImage()); tray.setIcon(size -> icon(newState, size).getImage());
tray.setTooltip(newTooltip); tray.setTooltip(newTooltip);
} }
@@ -101,13 +103,28 @@ final class TrayController {
void refreshIcon() { void refreshIcon() {
if (tray == null || state == null) return; if (tray == null || state == null) return;
SelfState current = state; SelfState current = state;
tray.setIcon(size -> current.icon(size).getImage()); tray.setIcon(size -> icon(current, size).getImage());
} }
void dispose() { void dispose() {
if (tray != null) tray.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 ---- // ---- menus ----
/** The menu for AWT's icon, which shows one of its own. */ /** The menu for AWT's icon, which shows one of its own. */