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:
@@ -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() {
|
||||
}
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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 "/<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 ----
|
||||
|
||||
public synchronized ClientEntry putClient(int id, String nickname, int channelId) {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user