From a69b7bde741c6248056af20a5f25f9c086de34f3 Mon Sep 17 00:00:00 2001 From: ericek111 Date: Thu, 13 Aug 2026 13:49:54 +0000 Subject: [PATCH] Add default channel to bookmarks Bookmarks can store a channel to join on connect, given either as a "/"-separated path or a channel id, plus an optional channel password. "Add bookmark" offers to remember the channel you are currently in. Co-Authored-By: Claude Opus 5 --- .../java/com/ts3client/config/Bookmark.java | 7 ++ .../java/com/ts3client/config/Bookmarks.java | 4 + .../java/com/ts3client/net/ServerModel.java | 41 +++++++++ .../ts3client/net/TeamspeakConnection.java | 88 ++++++++++++++++++- .../com/ts3client/ui/BookmarksDialog.java | 10 +++ .../main/java/com/ts3client/ui/MainFrame.java | 43 +++++++-- 6 files changed, 183 insertions(+), 10 deletions(-) diff --git a/ts3-client/core/src/main/java/com/ts3client/config/Bookmark.java b/ts3-client/core/src/main/java/com/ts3client/config/Bookmark.java index 83786e3..07eb56a 100644 --- a/ts3-client/core/src/main/java/com/ts3client/config/Bookmark.java +++ b/ts3-client/core/src/main/java/com/ts3client/config/Bookmark.java @@ -9,6 +9,13 @@ public final class Bookmark { public String password = ""; /** Identity to connect with; empty means "use the default identity". */ public String identityId = ""; + /** + * Channel to join on connect, as a "/"-separated path ("Lobby/Games") or + * "/<channelId>". Empty means the server's default channel. + */ + public String channel = ""; + /** Password for {@link #channel}, if it is protected. */ + public String channelPassword = ""; public Bookmark() { } diff --git a/ts3-client/core/src/main/java/com/ts3client/config/Bookmarks.java b/ts3-client/core/src/main/java/com/ts3client/config/Bookmarks.java index a8febd4..433f7e2 100644 --- a/ts3-client/core/src/main/java/com/ts3client/config/Bookmarks.java +++ b/ts3-client/core/src/main/java/com/ts3client/config/Bookmarks.java @@ -49,6 +49,8 @@ public final class Bookmarks { bm.nickname = p.getProperty(prefix + "nickname", ""); bm.password = p.getProperty(prefix + "password", ""); bm.identityId = p.getProperty(prefix + "identityId", ""); + bm.channel = p.getProperty(prefix + "channel", ""); + bm.channelPassword = p.getProperty(prefix + "channelPassword", ""); if (bm.address != null && !bm.address.isBlank()) b.entries.add(bm); } return b; @@ -66,6 +68,8 @@ public final class Bookmarks { p.setProperty(prefix + "nickname", nullToEmpty(bm.nickname)); p.setProperty(prefix + "password", nullToEmpty(bm.password)); p.setProperty(prefix + "identityId", nullToEmpty(bm.identityId)); + p.setProperty(prefix + "channel", nullToEmpty(bm.channel)); + p.setProperty(prefix + "channelPassword", nullToEmpty(bm.channelPassword)); } try { if (!DIR.isDirectory()) { diff --git a/ts3-client/core/src/main/java/com/ts3client/net/ServerModel.java b/ts3-client/core/src/main/java/com/ts3client/net/ServerModel.java index 45ea941..19895a5 100644 --- a/ts3-client/core/src/main/java/com/ts3client/net/ServerModel.java +++ b/ts3-client/core/src/main/java/com/ts3client/net/ServerModel.java @@ -85,6 +85,47 @@ public final class ServerModel { channels.remove(id); } + /** The "/"-separated path of a channel from the root, e.g. {@code "Lobby/Games"}. */ + public synchronized String channelPath(int id) { + ChannelNode c = channels.get(id); + if (c == null) return ""; + StringBuilder sb = new StringBuilder(); + for (int guard = 0; c != null && guard < 64; guard++) { + if (sb.length() > 0) sb.insert(0, '/'); + sb.insert(0, c.name == null ? "" : c.name); + c = channels.get(c.parentId); + } + return sb.toString(); + } + + /** + * Resolves a channel reference as stored in a bookmark: either a "/"-separated + * path (matched case-insensitively) or {@code "/"}. + * + * @return the channel, or {@code null} if the server has no such channel + */ + public synchronized ChannelNode findChannelByPath(String path) { + if (path == null) return null; + String trimmed = path.trim(); + while (trimmed.startsWith("/")) trimmed = trimmed.substring(1); + while (trimmed.endsWith("/")) trimmed = trimmed.substring(0, trimmed.length() - 1); + if (trimmed.isEmpty()) return null; + + if (trimmed.chars().allMatch(Character::isDigit)) { + ChannelNode byId = channels.get(Integer.parseInt(trimmed)); + if (byId != null) return byId; + } + for (ChannelNode c : channels.values()) { + if (channelPath(c.id).equalsIgnoreCase(trimmed)) return c; + } + // Fall back to a plain name match so a bookmark keeps working when the + // channel is moved within the tree. + for (ChannelNode c : channels.values()) { + if (c.name != null && c.name.equalsIgnoreCase(trimmed)) return c; + } + return null; + } + // ---- clients ---- public synchronized ClientEntry putClient(int id, String nickname, int channelId) { diff --git a/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java b/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java index 1635172..fa8dc32 100644 --- a/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java +++ b/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java @@ -11,6 +11,7 @@ import com.github.manevolent.ts3j.protocol.ProtocolRole; import com.github.manevolent.ts3j.protocol.packet.statistics.PacketStatistics; import com.github.manevolent.ts3j.protocol.socket.client.LocalTeamspeakClientSocket; import com.github.manevolent.ts3j.util.Pair; +import com.github.manevolent.ts3j.util.Ts3Crypt; import com.ts3client.audio.AudioBackend; import com.ts3client.audio.VoiceInput; import com.ts3client.audio.VoiceOutput; @@ -90,10 +91,25 @@ public final class TeamspeakConnection implements TS3Listener { * @param identity identity to authenticate with; see {@link com.ts3client.config.IdentityStore} */ public void connect(String address, int port, String nickname, String password, LocalIdentity identity) { - new Thread(() -> doConnect(address, port, nickname, password, identity), "ts3j-connect").start(); + connect(address, port, nickname, password, identity, null, null); } - private void doConnect(String address, int port, String nickname, String password, LocalIdentity withIdentity) { + /** + * Connects in the background, joining a specific channel instead of the server's + * default one. + * + * @param identity identity to authenticate with; see {@link com.ts3client.config.IdentityStore} + * @param channel channel path ("Lobby/Games") or "/<channelId>"; null/empty for the default channel + * @param channelPassword password for that channel, or null if it has none + */ + public void connect(String address, int port, String nickname, String password, LocalIdentity identity, + String channel, String channelPassword) { + new Thread(() -> doConnect(address, port, nickname, password, identity, channel, channelPassword), + "ts3j-connect").start(); + } + + private void doConnect(String address, int port, String nickname, String password, LocalIdentity withIdentity, + String channel, String channelPassword) { try { identity = withIdentity; @@ -129,6 +145,8 @@ public final class TeamspeakConnection implements TS3Listener { ui.onError("Network error: " + rootMessage(t)); }); + applyDefaultChannel(channel, channelPassword); + ui.onStatus("Connecting to " + address + ":" + port + "…"); client.connect(new InetSocketAddress(address, port), (password == null || password.isEmpty()) ? null : password, @@ -146,6 +164,7 @@ public final class TeamspeakConnection implements TS3Listener { ui.onStatus("Retrieving channels…"); syncAll(); ui.onModelChanged(); + joinDefaultChannelIfNeeded(channel, channelPassword); ui.onStatus("Connected to " + model.getServerName()); try { @@ -161,6 +180,71 @@ public final class TeamspeakConnection implements TS3Listener { } } + /** + * Asks the server, as part of {@code clientinit}, to place us in a specific + * channel right away instead of the default one. This is the path a real client + * takes for bookmarks, and it avoids being briefly visible in the wrong channel. + */ + private void applyDefaultChannel(String channel, String channelPassword) { + String path = normaliseChannelRef(channel); + if (path == null) return; + client.setOption("client.default_channel", path); + if (channelPassword != null && !channelPassword.isEmpty()) { + client.setOption("client.default_channel_password", Ts3Crypt.hashPassword(channelPassword)); + } + } + + /** + * Fallback for when {@code clientinit} did not put us in the requested channel: + * the path may not have matched exactly, or the channel may have been created + * after the bookmark was saved. Resolves the reference against the now-known + * channel list and moves there. + */ + private void joinDefaultChannelIfNeeded(String channel, String channelPassword) { + if (normaliseChannelRef(channel) == null) return; + ChannelNode target = model.findChannelByPath(channel); + if (target == null) { + ui.onError("Channel \"" + channel.trim() + "\" not found on this server."); + return; + } + if (currentChannelId() == target.id) return; // clientinit already put us there + try { + client.joinChannel(target.id, (channelPassword == null || channelPassword.isEmpty()) + ? null : channelPassword); + } catch (Exception e) { + ui.onError("Could not join channel \"" + target.name + "\": " + rootMessage(e)); + } + } + + /** + * The channel we are in, taken from the model and, if the client list was not + * readable, asked from the server directly. + * + * @return the channel id, or -1 when unknown + */ + private int currentChannelId() { + ClientEntry self = model.getClient(selfClientId); + if (self != null) return self.channelId; + try { + return client.getClientInfo(selfClientId).getChannelId(); + } catch (Exception e) { + return -1; + } + } + + /** + * @return the channel reference in {@code clientinit} form ("/<id>" for a + * numeric id, otherwise the path), or null when no channel was requested + */ + private static String normaliseChannelRef(String channel) { + if (channel == null) return null; + String path = channel.trim(); + while (path.startsWith("/")) path = path.substring(1); + while (path.endsWith("/")) path = path.substring(0, path.length() - 1); + if (path.isEmpty()) return null; + return path.chars().allMatch(Character::isDigit) ? "/" + path : path; + } + /** * Populates the model from the server. Each step is best-effort: a restricted * guest group may deny {@code channelsubscribeall} or the list commands without diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/BookmarksDialog.java b/ts3-client/swing/src/main/java/com/ts3client/ui/BookmarksDialog.java index 093c33d..4e73529 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/BookmarksDialog.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/BookmarksDialog.java @@ -124,6 +124,10 @@ public final class BookmarksDialog extends JDialog { JTextField port = new JTextField(Integer.toString(b.port)); JTextField nick = new JTextField(b.nickname == null ? "" : b.nickname); JPasswordField password = new JPasswordField(b.password == null ? "" : b.password); + JTextField channel = new JTextField(b.channel == null ? "" : b.channel); + channel.setToolTipText("Channel path, e.g. \"Lobby/Games\", or a channel id. " + + "Leave empty for the server's default channel."); + JPasswordField channelPassword = new JPasswordField(b.channelPassword == null ? "" : b.channelPassword); IdentityChooser identity = new IdentityChooser(identities, true, b.identityId); JPanel form = new JPanel(new GridLayout(0, 1, 0, 2)); @@ -137,6 +141,10 @@ public final class BookmarksDialog extends JDialog { form.add(nick); form.add(new JLabel("Password (optional):")); form.add(password); + form.add(new JLabel("Default channel (path or id, optional):")); + form.add(channel); + form.add(new JLabel("Channel password (optional):")); + form.add(channelPassword); form.add(new JLabel("Identity:")); form.add(identity); @@ -157,6 +165,8 @@ public final class BookmarksDialog extends JDialog { } b.nickname = nick.getText().trim(); b.password = new String(password.getPassword()); + b.channel = channel.getText().trim(); + b.channelPassword = new String(channelPassword.getPassword()); b.identityId = identity.getSelectedIdentityId(); return true; } 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 20ef2c3..8f7f667 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 @@ -14,6 +14,7 @@ import com.ts3client.net.TeamspeakConnection; import javax.swing.BorderFactory; import javax.swing.JButton; +import javax.swing.JCheckBox; import javax.swing.JCheckBoxMenuItem; import javax.swing.JFrame; import javax.swing.JLabel; @@ -23,6 +24,7 @@ import javax.swing.JMenuItem; import javax.swing.JOptionPane; import javax.swing.JPanel; import javax.swing.JSplitPane; +import javax.swing.JTextField; import javax.swing.JToggleButton; import javax.swing.JToolBar; import javax.swing.KeyStroke; @@ -30,6 +32,7 @@ import javax.swing.SwingUtilities; import java.awt.BorderLayout; import java.awt.Component; import java.awt.Dimension; +import java.awt.GridLayout; import java.awt.KeyEventDispatcher; import java.awt.KeyboardFocusManager; import java.awt.event.KeyEvent; @@ -304,13 +307,17 @@ public final class MainFrame extends JFrame implements ConnectionListener, Serve ConnectDialog dlg = new ConnectDialog(this, settings, identities); dlg.setVisible(true); if (!dlg.isConfirmed()) return; - startConnection(dlg.getAddress(), dlg.getPort(), dlg.getNickname(), dlg.getPassword(), dlg.getIdentityId()); + startConnection(dlg.getAddress(), dlg.getPort(), dlg.getNickname(), dlg.getPassword(), dlg.getIdentityId(), + "", ""); } /** - * @param identityId identity to use, or empty for the default one + * @param identityId identity to use, or empty for the default one + * @param channel channel path to join on connect, or empty for the server's default channel + * @param channelPassword password for that channel, if any */ - private void startConnection(String address, int port, String nickname, String password, String identityId) { + private void startConnection(String address, int port, String nickname, String password, String identityId, + String channel, String channelPassword) { if (conn.isConnected()) { JOptionPane.showMessageDialog(this, "Already connected. Disconnect first.", "Connect", JOptionPane.INFORMATION_MESSAGE); @@ -320,7 +327,8 @@ public final class MainFrame extends JFrame implements ConnectionListener, Serve settings.nickname = nickname; settings.serverPassword = password; settings.save(); - chatPanel.appendSystem("Connecting to " + address + ":" + port + " …"); + chatPanel.appendSystem("Connecting to " + address + ":" + port + + (channel == null || channel.isBlank() ? "" : " (channel \"" + channel + "\")") + " …"); // Resolving may have to generate a first identity, so keep it off the EDT. onStatus("Loading identity…"); @@ -336,13 +344,20 @@ public final class MainFrame extends JFrame implements ConnectionListener, Serve currentIdentityId = entry.getId(); chatPanel.appendSystem("Using identity \"" + entry.getName() + "\"."); }); - conn.connect(address, port, nickname, password, entry.getIdentity()); + conn.connect(address, port, nickname, password, entry.getIdentity(), channel, channelPassword); }, "identity-resolve").start(); } private void connectToBookmark(Bookmark b) { String nick = (b.nickname != null && !b.nickname.isBlank()) ? b.nickname : settings.nickname; - startConnection(b.address, b.port, nick, b.password, b.identityId); + startConnection(b.address, b.port, nick, b.password, b.identityId, b.channel, b.channelPassword); + } + + /** Path of the channel we are currently in, or empty when not connected. */ + private String currentChannelPath() { + if (conn == null || !conn.isConnected()) return ""; + ClientEntry self = conn.getModel().getClient(conn.getSelfClientId()); + return self == null ? "" : conn.getModel().channelPath(self.channelId); } private void addCurrentServerBookmark() { @@ -356,10 +371,22 @@ public final class MainFrame extends JFrame implements ConnectionListener, Serve } catch (NumberFormatException ignored) { } } - String label = JOptionPane.showInputDialog(this, "Bookmark label:", addr); - if (label == null) return; + String channelPath = currentChannelPath(); + + JTextField labelField = new JTextField(addr); + JCheckBox joinChannel = new JCheckBox("Join \"" + channelPath + "\" on connect", !channelPath.isEmpty()); + JPanel form = new JPanel(new GridLayout(0, 1, 0, 2)); + form.add(new JLabel("Bookmark label:")); + form.add(labelField); + if (!channelPath.isEmpty()) form.add(joinChannel); + if (JOptionPane.showConfirmDialog(this, form, "Add bookmark", + JOptionPane.OK_CANCEL_OPTION, JOptionPane.PLAIN_MESSAGE) != JOptionPane.OK_OPTION) { + return; + } + String label = labelField.getText(); Bookmark bookmark = new Bookmark(label.trim(), addr, port, settings.nickname, settings.serverPassword); bookmark.identityId = currentIdentityId; + if (joinChannel.isSelected()) bookmark.channel = channelPath; bookmarks.add(bookmark); bookmarks.save(); rebuildBookmarksMenu();