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 <noreply@anthropic.com>
This commit is contained in:
2026-08-13 13:49:54 +00:00
parent 17ad6cde1c
commit a69b7bde74
6 changed files with 183 additions and 10 deletions

View File

@@ -9,6 +9,13 @@ public final class Bookmark {
public String password = ""; public String password = "";
/** Identity to connect with; empty means "use the default identity". */ /** Identity to connect with; empty means "use the default identity". */
public String identityId = ""; public String identityId = "";
/**
* Channel to join on connect, as a "/"-separated path ("Lobby/Games") or
* "/&lt;channelId&gt;". Empty means the server's default channel.
*/
public String channel = "";
/** Password for {@link #channel}, if it is protected. */
public String channelPassword = "";
public Bookmark() { public Bookmark() {
} }

View File

@@ -49,6 +49,8 @@ public final class Bookmarks {
bm.nickname = p.getProperty(prefix + "nickname", ""); bm.nickname = p.getProperty(prefix + "nickname", "");
bm.password = p.getProperty(prefix + "password", ""); bm.password = p.getProperty(prefix + "password", "");
bm.identityId = p.getProperty(prefix + "identityId", ""); 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); if (bm.address != null && !bm.address.isBlank()) b.entries.add(bm);
} }
return b; return b;
@@ -66,6 +68,8 @@ public final class Bookmarks {
p.setProperty(prefix + "nickname", nullToEmpty(bm.nickname)); p.setProperty(prefix + "nickname", nullToEmpty(bm.nickname));
p.setProperty(prefix + "password", nullToEmpty(bm.password)); p.setProperty(prefix + "password", nullToEmpty(bm.password));
p.setProperty(prefix + "identityId", nullToEmpty(bm.identityId)); p.setProperty(prefix + "identityId", nullToEmpty(bm.identityId));
p.setProperty(prefix + "channel", nullToEmpty(bm.channel));
p.setProperty(prefix + "channelPassword", nullToEmpty(bm.channelPassword));
} }
try { try {
if (!DIR.isDirectory()) { if (!DIR.isDirectory()) {

View File

@@ -85,6 +85,47 @@ public final class ServerModel {
channels.remove(id); 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 "/<channelId>"}.
*
* @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 ---- // ---- clients ----
public synchronized ClientEntry putClient(int id, String nickname, int channelId) { public synchronized ClientEntry putClient(int id, String nickname, int channelId) {

View File

@@ -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.packet.statistics.PacketStatistics;
import com.github.manevolent.ts3j.protocol.socket.client.LocalTeamspeakClientSocket; import com.github.manevolent.ts3j.protocol.socket.client.LocalTeamspeakClientSocket;
import com.github.manevolent.ts3j.util.Pair; import com.github.manevolent.ts3j.util.Pair;
import com.github.manevolent.ts3j.util.Ts3Crypt;
import com.ts3client.audio.AudioBackend; import com.ts3client.audio.AudioBackend;
import com.ts3client.audio.VoiceInput; import com.ts3client.audio.VoiceInput;
import com.ts3client.audio.VoiceOutput; 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} * @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) { 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 "/&lt;channelId&gt;"; 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 { try {
identity = withIdentity; identity = withIdentity;
@@ -129,6 +145,8 @@ public final class TeamspeakConnection implements TS3Listener {
ui.onError("Network error: " + rootMessage(t)); ui.onError("Network error: " + rootMessage(t));
}); });
applyDefaultChannel(channel, channelPassword);
ui.onStatus("Connecting to " + address + ":" + port + ""); ui.onStatus("Connecting to " + address + ":" + port + "");
client.connect(new InetSocketAddress(address, port), client.connect(new InetSocketAddress(address, port),
(password == null || password.isEmpty()) ? null : password, (password == null || password.isEmpty()) ? null : password,
@@ -146,6 +164,7 @@ public final class TeamspeakConnection implements TS3Listener {
ui.onStatus("Retrieving channels…"); ui.onStatus("Retrieving channels…");
syncAll(); syncAll();
ui.onModelChanged(); ui.onModelChanged();
joinDefaultChannelIfNeeded(channel, channelPassword);
ui.onStatus("Connected to " + model.getServerName()); ui.onStatus("Connected to " + model.getServerName());
try { 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 ("/&lt;id&gt;" 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 * Populates the model from the server. Each step is best-effort: a restricted
* guest group may deny {@code channelsubscribeall} or the list commands without * guest group may deny {@code channelsubscribeall} or the list commands without

View File

@@ -124,6 +124,10 @@ public final class BookmarksDialog extends JDialog {
JTextField port = new JTextField(Integer.toString(b.port)); JTextField port = new JTextField(Integer.toString(b.port));
JTextField nick = new JTextField(b.nickname == null ? "" : b.nickname); JTextField nick = new JTextField(b.nickname == null ? "" : b.nickname);
JPasswordField password = new JPasswordField(b.password == null ? "" : b.password); 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); IdentityChooser identity = new IdentityChooser(identities, true, b.identityId);
JPanel form = new JPanel(new GridLayout(0, 1, 0, 2)); 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(nick);
form.add(new JLabel("Password (optional):")); form.add(new JLabel("Password (optional):"));
form.add(password); 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(new JLabel("Identity:"));
form.add(identity); form.add(identity);
@@ -157,6 +165,8 @@ public final class BookmarksDialog extends JDialog {
} }
b.nickname = nick.getText().trim(); b.nickname = nick.getText().trim();
b.password = new String(password.getPassword()); b.password = new String(password.getPassword());
b.channel = channel.getText().trim();
b.channelPassword = new String(channelPassword.getPassword());
b.identityId = identity.getSelectedIdentityId(); b.identityId = identity.getSelectedIdentityId();
return true; return true;
} }

View File

@@ -14,6 +14,7 @@ import com.ts3client.net.TeamspeakConnection;
import javax.swing.BorderFactory; import javax.swing.BorderFactory;
import javax.swing.JButton; import javax.swing.JButton;
import javax.swing.JCheckBox;
import javax.swing.JCheckBoxMenuItem; import javax.swing.JCheckBoxMenuItem;
import javax.swing.JFrame; import javax.swing.JFrame;
import javax.swing.JLabel; import javax.swing.JLabel;
@@ -23,6 +24,7 @@ import javax.swing.JMenuItem;
import javax.swing.JOptionPane; import javax.swing.JOptionPane;
import javax.swing.JPanel; import javax.swing.JPanel;
import javax.swing.JSplitPane; import javax.swing.JSplitPane;
import javax.swing.JTextField;
import javax.swing.JToggleButton; import javax.swing.JToggleButton;
import javax.swing.JToolBar; import javax.swing.JToolBar;
import javax.swing.KeyStroke; import javax.swing.KeyStroke;
@@ -30,6 +32,7 @@ import javax.swing.SwingUtilities;
import java.awt.BorderLayout; import java.awt.BorderLayout;
import java.awt.Component; import java.awt.Component;
import java.awt.Dimension; import java.awt.Dimension;
import java.awt.GridLayout;
import java.awt.KeyEventDispatcher; import java.awt.KeyEventDispatcher;
import java.awt.KeyboardFocusManager; import java.awt.KeyboardFocusManager;
import java.awt.event.KeyEvent; 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); ConnectDialog dlg = new ConnectDialog(this, settings, identities);
dlg.setVisible(true); dlg.setVisible(true);
if (!dlg.isConfirmed()) return; 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()) { if (conn.isConnected()) {
JOptionPane.showMessageDialog(this, "Already connected. Disconnect first.", JOptionPane.showMessageDialog(this, "Already connected. Disconnect first.",
"Connect", JOptionPane.INFORMATION_MESSAGE); "Connect", JOptionPane.INFORMATION_MESSAGE);
@@ -320,7 +327,8 @@ public final class MainFrame extends JFrame implements ConnectionListener, Serve
settings.nickname = nickname; settings.nickname = nickname;
settings.serverPassword = password; settings.serverPassword = password;
settings.save(); 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. // Resolving may have to generate a first identity, so keep it off the EDT.
onStatus("Loading identity…"); onStatus("Loading identity…");
@@ -336,13 +344,20 @@ public final class MainFrame extends JFrame implements ConnectionListener, Serve
currentIdentityId = entry.getId(); currentIdentityId = entry.getId();
chatPanel.appendSystem("Using identity \"" + entry.getName() + "\"."); 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(); }, "identity-resolve").start();
} }
private void connectToBookmark(Bookmark b) { private void connectToBookmark(Bookmark b) {
String nick = (b.nickname != null && !b.nickname.isBlank()) ? b.nickname : settings.nickname; 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() { private void addCurrentServerBookmark() {
@@ -356,10 +371,22 @@ public final class MainFrame extends JFrame implements ConnectionListener, Serve
} catch (NumberFormatException ignored) { } catch (NumberFormatException ignored) {
} }
} }
String label = JOptionPane.showInputDialog(this, "Bookmark label:", addr); String channelPath = currentChannelPath();
if (label == null) return;
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 bookmark = new Bookmark(label.trim(), addr, port, settings.nickname, settings.serverPassword);
bookmark.identityId = currentIdentityId; bookmark.identityId = currentIdentityId;
if (joinChannel.isSelected()) bookmark.channel = channelPath;
bookmarks.add(bookmark); bookmarks.add(bookmark);
bookmarks.save(); bookmarks.save();
rebuildBookmarksMenu(); rebuildBookmarksMenu();