diff --git a/ts3-client/core/src/main/java/com/ts3client/session/SelfState.java b/ts3-client/core/src/main/java/com/ts3client/session/SelfState.java new file mode 100644 index 0000000..20c11c5 --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/session/SelfState.java @@ -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; + } +} diff --git a/ts3-client/core/src/main/java/com/ts3client/session/ServerSession.java b/ts3-client/core/src/main/java/com/ts3client/session/ServerSession.java new file mode 100644 index 0000000..8c54500 --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/session/ServerSession.java @@ -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}. + * + *

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 channelPaths() { + List 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 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 htmlEntries) { + listener.onChatHistory(scope, htmlEntries); + } + + @Override + public void onPrivateChatHistory(int clientId, String nickname, List 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); + } + } +} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/HotkeyActions.java b/ts3-client/swing/src/main/java/com/ts3client/ui/HotkeyActions.java index a371ebc..d2adcdb 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/HotkeyActions.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/HotkeyActions.java @@ -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 targets, boolean muted) { - for (ServerTab tab : targets) tab.setMicMuted(muted); + for (ServerTab tab : targets) tab.session().setMicMuted(muted); frame.refreshAfterHotkey(); } private void setMicLocalMuted(List targets, boolean muted) { - for (ServerTab tab : targets) tab.setMicLocalMuted(muted); + for (ServerTab tab : targets) tab.session().setMicLocalMuted(muted); frame.refreshAfterHotkey(); } private void setDeafened(List targets, boolean deaf) { - for (ServerTab tab : targets) tab.setDeafened(deaf); + for (ServerTab tab : targets) tab.session().setDeafened(deaf); frame.refreshAfterHotkey(); } private void setAway(List 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 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 tabs) { - return tabs.stream().anyMatch(ServerTab::isMicMuted); + return tabs.stream().anyMatch(t -> t.session().isMicMuted()); } private static boolean anyMicLocalMuted(List tabs) { - return tabs.stream().anyMatch(ServerTab::isMicLocalMuted); + return tabs.stream().anyMatch(t -> t.session().isMicLocalMuted()); } private static boolean anyDeafened(List tabs) { - return tabs.stream().anyMatch(ServerTab::isDeafened); + return tabs.stream().anyMatch(t -> t.session().isDeafened()); } private static boolean anyAway(List tabs) { - return tabs.stream().anyMatch(ServerTab::isAway); + return tabs.stream().anyMatch(t -> t.session().isAway()); } private static boolean anyCommander(List 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 */ diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java b/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java index 673ec57..8cfac6f 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java @@ -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 hotkeyTargets(boolean activeServerOnly) { List 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()) diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/SelfState.java b/ts3-client/swing/src/main/java/com/ts3client/ui/SelfState.java deleted file mode 100644 index 204c3c2..0000000 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/SelfState.java +++ /dev/null @@ -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); - } - } -} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTab.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTab.java index a69f7fd..ade9230 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTab.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTab.java @@ -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. * - *

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}. + *

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 channelPaths() { - List 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 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. */ diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabConnectionEvents.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabConnectionEvents.java index d77d70e..6b98eac 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabConnectionEvents.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabConnectionEvents.java @@ -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}. * - *

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

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); diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabPane.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabPane.java index 132e0fd..98712e3 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabPane.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabPane.java @@ -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 diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabSelfState.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabSelfState.java deleted file mode 100644 index b800291..0000000 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabSelfState.java +++ /dev/null @@ -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; - } -} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/TrayController.java b/ts3-client/swing/src/main/java/com/ts3client/ui/TrayController.java index 5af22b6..66e5295 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/TrayController.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/TrayController.java @@ -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 initial = size -> SelfState.DISCONNECTED.icon(size).getImage(); + IntFunction 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. */