Show server, channel and client details on Android, and the server's on the desktop

Core's Details says what the info box tells, as TeamSpeak's info frame does:
labelled facts, then a BBCode description. The desktop info panel now lays it
out instead of building its own, and selecting the server row shows the
server: address, version, clients, channels, creation date and its welcome
and host messages, from the virtualserver_* properties kept since initserver.

On Android the channel and client sheets show the same details, asking the
server for a missing channel description or a client's variables; tapping
the server row opens a server sheet. Descriptions render as TeamSpeak's do:
BBCode now knows [hr], [left]/[right]/[justify], numbered and lettered lists
and tables, which Android's HTML parser approximates — rules as dividers,
table rows as lines, list items numbered, font sizes as big/small. Links are
blue whatever the wallpaper.

Push-to-talk in the bottom bar follows whichever server holds the microphone,
and a server without it keeps its button to take it in a fixed slot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 21:52:28 +00:00
parent 9b1aa45611
commit cd02e4b2d8
17 changed files with 599 additions and 84 deletions

View File

@@ -493,6 +493,7 @@ final class ConnectionEventHandler implements TS3Listener {
@Override
public void onServerEdit(ServerEditedEvent e) {
if (has(e, "virtualserver_name")) conn.getModel().setServerName(e.get("virtualserver_name"));
conn.getModel().putServerProperties(e.getMap());
if (conn.isConnected()) {
conn.sound(byInvoker(e, SoundEvent.SERVER_EDITED_BY_YOU, SoundEvent.SERVER_EDITED_BY_OTHER,
SoundEvent.SERVER_EDITED_BY_OTHER), conn.serverVars());

View File

@@ -0,0 +1,110 @@
package com.ts3client.net;
import com.ts3client.contacts.Contact;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
/**
* What the info box tells about the server, a channel or a client, as the TeamSpeak 3 client's
* info frame does: labelled facts, then a description in BBCode. The same on every frontend,
* which only lays it out.
*
* @param description BBCode; empty when there is none
* @param pending the description is still being asked for
*/
public record Details(String title, List<Field> fields, String description, boolean pending) {
/**
* One labelled fact.
*
* @param groups the groups {@code value} names, in order, for frontends that show their icons;
* empty when it names none
*/
public record Field(String label, String value, List<Group> groups) {
Field(String label, String value) {
this(label, value, List.of());
}
}
private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("yyyy-MM-dd");
public static Details server(ServerModel model, String address) {
List<Field> fields = new ArrayList<>();
fields.add(new Field("Address", address));
String version = model.serverProperty("virtualserver_version");
String platform = model.serverProperty("virtualserver_platform");
if (!version.isEmpty()) fields.add(new Field("Version", version + (platform.isEmpty() ? "" : " on " + platform)));
String max = model.serverProperty("virtualserver_maxclients");
fields.add(new Field("Clients", model.clientCount() + (max.isEmpty() ? "" : " / " + max)));
fields.add(new Field("Channels", Integer.toString(model.channelCount())));
long created = parseLong(model.serverProperty("virtualserver_created"));
if (created > 0) {
fields.add(new Field("Created", DATE.format(Instant.ofEpochSecond(created).atZone(ZoneId.systemDefault()))));
}
String welcome = model.serverProperty("virtualserver_welcomemessage").trim();
String host = model.serverProperty("virtualserver_hostmessage").trim();
String description = welcome.isEmpty() || host.isEmpty() ? welcome + host : welcome + "\n\n" + host;
return new Details(model.getServerName(), fields, description, false);
}
public static Details channel(ChannelNode ch) {
List<Field> fields = new ArrayList<>();
if (ch.maxClients >= 0) fields.add(new Field("Max clients", Integer.toString(ch.maxClients)));
if (ch.hasPassword) fields.add(new Field("Password", "protected"));
if (!ch.topic.isEmpty()) fields.add(new Field("Topic", ch.topic));
String description = ch.description == null ? "" : ch.description;
return new Details(ch.name, fields, description, description.isEmpty() && !ch.descriptionLoaded);
}
/** @param contact the client's contact entry, or null */
public static Details client(ClientEntry cl, ServerModel model, Contact contact) {
List<Field> fields = new ArrayList<>();
if (contact != null) {
String type = contact.type.label();
if (!contact.phoneticNickname.isEmpty()) type += " (" + contact.phoneticNickname + ")";
fields.add(new Field("Contact", type));
}
List<Group> serverGroups = model.serverGroupsOf(cl.serverGroupIds);
if (serverGroups.isEmpty()) {
List<String> names = model.serverGroupNames(cl.serverGroupIds);
fields.add(new Field("Server groups", names.isEmpty() ? "—" : String.join(", ", names)));
} else {
List<String> names = new ArrayList<>();
for (Group g : serverGroups) names.add(g.name);
fields.add(new Field("Server groups", String.join(", ", names), serverGroups));
}
Group channelGroup = model.channelGroup(cl.channelGroupId);
fields.add(channelGroup != null
? new Field("Channel group", channelGroup.name, List.of(channelGroup))
: new Field("Channel group", "#" + cl.channelGroupId));
if (cl.talkPower != 0) fields.add(new Field("Talk power", Integer.toString(cl.talkPower)));
if (!cl.version.isEmpty()) {
fields.add(new Field("Version", cl.version + (cl.platform.isEmpty() ? "" : " on " + cl.platform)));
} else if (!cl.platform.isEmpty()) {
fields.add(new Field("Platform", cl.platform));
}
if (cl.away) fields.add(new Field("Status", "Away"));
if (!cl.inputHardware) fields.add(new Field("Microphone", "disabled"));
else if (cl.inputMuted) fields.add(new Field("Microphone", "muted"));
if (!cl.outputHardware) fields.add(new Field("Speakers", "disabled"));
else if (cl.outputMuted) fields.add(new Field("Speakers", "muted"));
if (!cl.uniqueId.isEmpty()) fields.add(new Field("Unique ID", cl.uniqueId));
String name = contact == null ? cl.nickname : contact.displayName(cl.nickname);
return new Details(name, fields, cl.description == null ? "" : cl.description, false);
}
private static long parseLong(String s) {
try {
return s.isEmpty() ? 0 : Long.parseLong(s.trim());
} catch (NumberFormatException e) {
return 0;
}
}
}

View File

@@ -33,6 +33,8 @@ public final class ServerModel {
/** Permission id -> the local client's resolved value, from {@code notifyclientneededpermissions}. */
private final Map<Integer, Integer> selfPermissionValues = new LinkedHashMap<>();
private String serverName = "TeamSpeak Server";
/** The {@code virtualserver_*} properties from {@code initserver}, kept up to date by server edits. */
private final Map<String, String> serverProperties = new HashMap<>();
/** The channel group everyone starts in, e.g. "Guest" — not worth offering to (re)assign. */
private int defaultChannelGroupId;
@@ -45,6 +47,7 @@ public final class ServerModel {
permissionIds.clear();
nextPermissionId = FIRST_PERMISSION_ID;
selfPermissionValues.clear();
serverProperties.clear();
defaultChannelGroupId = 0;
}
@@ -207,6 +210,19 @@ public final class ServerModel {
if (name != null && !name.isEmpty()) this.serverName = name;
}
/** Takes on the server properties among {@code properties}, keeping the others. */
public synchronized void putServerProperties(Map<String, String> properties) {
properties.forEach((key, value) -> {
// An empty property comes as a bare key, without a value.
if (key.startsWith("virtualserver_")) serverProperties.put(key, value == null ? "" : value);
});
}
/** @return the server property, empty when unknown */
public synchronized String serverProperty(String key) {
return serverProperties.getOrDefault(key, "");
}
public synchronized int defaultChannelGroupId() {
return defaultChannelGroupId;
}
@@ -450,6 +466,10 @@ public final class ServerModel {
siblings.addAll(ordered);
}
public synchronized int channelCount() {
return channels.size();
}
public synchronized int clientCount() {
int n = 0;
for (ClientEntry c : clients.values()) if (!c.isQuery()) n++;

View File

@@ -500,6 +500,7 @@ public final class TeamspeakConnection implements TS3Listener {
}
// The name comes with initserver; servergetvariables never reports it.
model.setServerName(client.getServerName());
model.putServerProperties(client.getServerProperties());
model.setDefaultChannelGroupId(
(int) safeLong(client.getServerProperties().get("virtualserver_default_channel_group")));
}

View File

@@ -136,10 +136,28 @@ public final class BBCode {
return push(out, open, name, "<u>", "</u>");
case "s":
return push(out, open, name, "<strike>", "</strike>");
case "hr":
// Stands alone; a closing tag some write after it is dropped, see closeTag.
out.append("<hr>");
return true;
case "left":
case "center":
return push(out, open, name, "<div align=\"center\">", "</div>");
case "right":
case "justify":
return push(out, open, name, "<div align=\"" + name + "\">", "</div>");
case "list":
if (value.equals("1")) return push(out, open, name, "<ol>", "</ol>");
if (value.equalsIgnoreCase("a")) return push(out, open, name, "<ol type=\"a\">", "</ol>");
return push(out, open, name, "<ul>", "</ul>");
case "table":
return push(out, open, name, "<table>", "</table>");
case "tr":
if (!contains(open, "table")) return false;
return push(out, open, name, "<tr>", "</tr>");
case "td":
case "th":
if (!contains(open, "tr")) return false;
return push(out, open, name, "<" + name + ">", "</" + name + ">");
case "color": {
if (!COLOR.matcher(value).matches()) return false;
return push(out, open, name, "<font color=\"" + value + "\">", "</font>");
@@ -159,6 +177,7 @@ public final class BBCode {
}
private static boolean closeTag(StringBuilder out, String name, Deque<Open> open) {
if (name.equals("hr")) return true;
if (!contains(open, name)) return false;
// Close anything left dangling inside, then the tag itself.
while (!open.isEmpty()) {

View File

@@ -0,0 +1,47 @@
package com.ts3client.net;
import org.junit.jupiter.api.Test;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class DetailsTest {
@Test
void serverTakesInitserverAndEditsAndBareKeys() {
ServerModel model = new ServerModel();
model.setServerName("Test");
Map<String, String> init = new HashMap<>();
init.put("virtualserver_version", "3.13.7");
init.put("virtualserver_platform", "Linux");
init.put("virtualserver_maxclients", "32");
init.put("virtualserver_welcomemessage", "Hello");
// An empty property comes as a bare key.
init.put("virtualserver_hostmessage", null);
model.putServerProperties(init);
model.putServerProperties(Map.of("virtualserver_hostmessage", "Be nice", "invokerid", "5"));
Details d = Details.server(model, "host:9987");
assertEquals("Test", d.title());
assertEquals(new Details.Field("Version", "3.13.7 on Linux"), d.fields().get(1));
assertEquals(new Details.Field("Clients", "0 / 32"), d.fields().get(2));
assertEquals("Hello\n\nBe nice", d.description());
assertEquals("", model.serverProperty("invokerid"));
}
@Test
void channelDescriptionIsPendingUntilAnswered() {
ChannelNode ch = new ChannelNode(1, "Lobby");
ch.topic = "Entry";
assertTrue(Details.channel(ch).pending());
ch.descriptionLoaded = true;
Details d = Details.channel(ch);
assertFalse(d.pending());
assertEquals(List.of(new Details.Field("Topic", "Entry")), d.fields());
}
}

View File

@@ -0,0 +1,34 @@
package com.ts3client.text;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
/** The block tags TeamSpeak's own client renders in descriptions. */
class BBCodeTagsTest {
@Test
void rulesStandAlone() {
assertEquals("a<hr>b", BBCode.toHtml("a[hr]b"));
assertEquals("a<hr>b", BBCode.toHtml("a[hr][/hr]b"));
}
@Test
void alignsBlocks() {
assertEquals("<div align=\"right\">x</div><div align=\"justify\">y</div>",
BBCode.toHtml("[right]x[/right][justify]y[/justify]"));
}
@Test
void numbersLists() {
assertEquals("<ol><li>one</li><li>two</li></ol>", BBCode.toHtml("[list=1][*]one[*]two[/list]"));
assertEquals("<ol type=\"a\"><li>x</li></ol>", BBCode.toHtml("[list=a][*]x[/list]"));
}
@Test
void buildsTablesOnlyWhereTheyNest() {
assertEquals("<table><tr><th>h</th><td>d</td></tr></table>",
BBCode.toHtml("[table][tr][th]h[/th][td]d[/td][/tr][/table]"));
assertEquals("[td]loose[/td]", BBCode.toHtml("[td]loose[/td]"));
}
}

View File

@@ -5,6 +5,7 @@ import com.formdev.flatlaf.util.UIScale;
import com.ts3client.contacts.Contact;
import com.ts3client.net.ChannelNode;
import com.ts3client.net.ClientEntry;
import com.ts3client.net.Details;
import com.ts3client.net.Group;
import com.ts3client.net.IconRepository;
import com.ts3client.net.ServerModel;
@@ -29,9 +30,8 @@ import java.io.File;
import java.util.List;
/**
* Read-only detail view for the currently selected channel or client, mirroring
* the TeamSpeak 3 info box: channel topic/description, or a client's server and
* channel groups, platform and version.
* Read-only detail view for the server, channel or client selected, mirroring the
* TeamSpeak 3 info box; what it says comes from {@link Details}.
*/
public final class InfoPanel extends JPanel {
@@ -57,6 +57,8 @@ public final class InfoPanel extends JPanel {
private DescriptionHandler descriptionHandler;
private ChannelNode shownChannel;
private ClientEntry shownClient;
/** The server's address while the server itself is shown. */
private String shownServer;
private ServerModel shownModel;
private IconRepository shownIcons;
private Contact shownContact;
@@ -143,6 +145,7 @@ public final class InfoPanel extends JPanel {
public void clear() {
shownChannel = null;
shownClient = null;
shownServer = null;
inChatTab = false;
render();
}
@@ -157,9 +160,18 @@ public final class InfoPanel extends JPanel {
render();
}
public void showServer(ServerModel model, String address) {
shownServer = address;
shownModel = model;
shownChannel = null;
shownClient = null;
render();
}
public void showChannel(ChannelNode ch) {
shownChannel = ch;
shownClient = null;
shownServer = null;
render();
}
@@ -168,6 +180,7 @@ public final class InfoPanel extends JPanel {
Avatar avatar) {
shownClient = cl;
shownChannel = null;
shownServer = null;
shownModel = model;
shownIcons = icons;
shownContact = contact;
@@ -176,7 +189,7 @@ public final class InfoPanel extends JPanel {
}
private void render() {
boolean selected = shownChannel != null || shownClient != null;
boolean selected = shownChannel != null || shownClient != null || shownServer != null;
boolean hide = inChatTab && selected;
if (descriptionHandler != null) descriptionHandler.setPanelHidden(hide);
toggle.setVisible(selected && !hide);
@@ -188,12 +201,13 @@ public final class InfoPanel extends JPanel {
}
private String body() {
if (shownChannel != null) return channelBody(shownChannel);
if (shownServer != null) return html(Details.server(shownModel, shownServer), "", null, null);
if (shownChannel != null) return html(Details.channel(shownChannel), "", null, "No description.");
if (shownClient != null) {
renderedAvatarBox = inChatTab ? UIScale.scale(AvatarHtml.MAX_SIZE) : avatarBox();
return clientBody(shownClient, shownModel, shownIcons, shownContact, shownAvatar, renderedAvatarBox);
}
return "<i class='muted'>Select a channel or client to see details.</i>";
return "<i class='muted'>Select the server, a channel or a client to see details.</i>";
}
/**
@@ -210,23 +224,6 @@ public final class InfoPanel extends JPanel {
return Math.max(UIScale.scale(AVATAR_MIN_SIZE), Math.min(max, box));
}
private static String channelBody(ChannelNode ch) {
StringBuilder sb = new StringBuilder();
sb.append(heading(esc(ch.name)));
if (ch.maxClients >= 0) row(sb, "Max clients", Integer.toString(ch.maxClients));
if (ch.hasPassword) row(sb, "Password", "protected");
if (!ch.topic.isEmpty()) row(sb, "Topic", esc(ch.topic));
sb.append("<hr>");
if (ch.description != null && !ch.description.isEmpty()) {
sb.append("<div>").append(multiline(ch.description)).append("</div>");
} else if (ch.descriptionLoaded) {
sb.append("<i class='muted'>No description.</i>");
} else {
sb.append("<i class='muted'>Loading description…</i>");
}
return sb.toString();
}
private static String clientBody(ClientEntry cl, ServerModel model, IconRepository icons, Contact contact,
Avatar avatar, int avatarBox) {
String details = clientDetails(cl, model, icons, contact);
@@ -240,49 +237,35 @@ public final class InfoPanel extends JPanel {
}
private static String clientDetails(ClientEntry cl, ServerModel model, IconRepository icons, Contact contact) {
String you = cl.self ? " <span class='muted'>(you)</span>" : "";
return html(Details.client(cl, model, contact), you, icons, null);
}
/**
* @param titleSuffix HTML after the title
* @param icons for the icons of the groups a field names; null for none
* @param none what to say when there is no description; null to say nothing
*/
private static String html(Details details, String titleSuffix, IconRepository icons, String none) {
StringBuilder sb = new StringBuilder();
String name = contact == null ? cl.nickname : contact.displayName(cl.nickname);
sb.append(heading(esc(name) + (cl.self ? " <span class='muted'>(you)</span>" : "")));
if (contact != null) {
String type = contact.type.label();
if (!contact.phoneticNickname.isEmpty()) type += " (" + esc(contact.phoneticNickname) + ")";
row(sb, "Contact", type);
sb.append(heading(esc(details.title()) + titleSuffix));
for (Details.Field f : details.fields()) row(sb, esc(f.label()), value(f, icons));
if (!details.description().isEmpty()) {
sb.append("<hr><div>").append(multiline(details.description())).append("</div>");
} else if (details.pending()) {
sb.append("<hr><i class='muted'>Loading description…</i>");
} else if (none != null) {
sb.append("<hr><i class='muted'>").append(none).append("</i>");
}
return sb.toString();
}
List<Group> serverGroups = model.serverGroupsOf(cl.serverGroupIds);
if (serverGroups.isEmpty()) {
List<String> names = model.serverGroupNames(cl.serverGroupIds);
row(sb, "Server groups", names.isEmpty() ? "—" : esc(String.join(", ", names)));
} else {
StringBuilder groups = new StringBuilder();
for (Group g : serverGroups) {
if (groups.length() > 0) groups.append(", ");
groups.append(iconTag(g, icons)).append(esc(g.name));
}
row(sb, "Server groups", groups.toString());
}
Group channelGroup = model.channelGroup(cl.channelGroupId);
row(sb, "Channel group", channelGroup != null
? iconTag(channelGroup, icons) + esc(channelGroup.name)
: "#" + cl.channelGroupId);
if (cl.talkPower != 0) row(sb, "Talk power", Integer.toString(cl.talkPower));
if (!cl.version.isEmpty()) {
row(sb, "Version", esc(cl.version)
+ (cl.platform.isEmpty() ? "" : " on " + esc(cl.platform)));
} else if (!cl.platform.isEmpty()) {
row(sb, "Platform", esc(cl.platform));
}
if (cl.away) row(sb, "Status", "Away");
if (!cl.inputHardware) row(sb, "Microphone", "disabled");
else if (cl.inputMuted) row(sb, "Microphone", "muted");
if (!cl.outputHardware) row(sb, "Speakers", "disabled");
else if (cl.outputMuted) row(sb, "Speakers", "muted");
if (!cl.uniqueId.isEmpty()) row(sb, "Unique ID", esc(cl.uniqueId));
if (cl.description != null && !cl.description.isEmpty()) {
sb.append("<hr><div>").append(multiline(cl.description)).append("</div>");
private static String value(Details.Field field, IconRepository icons) {
if (field.groups().isEmpty() || icons == null) return esc(field.value());
StringBuilder sb = new StringBuilder();
for (Group g : field.groups()) {
if (sb.length() > 0) sb.append(", ");
sb.append(iconTag(g, icons)).append(esc(g.name));
}
return sb.toString();
}

View File

@@ -64,6 +64,9 @@ final class ServerTabTreeActions implements ServerTreePanel.Actions {
ClientEntry client = (ClientEntry) sel;
infoPanel.showClient(client, conn.getModel(), conn.getIcons(),
conn.getContacts().find(client.uniqueId), conn.avatarOf(client));
} else if (sel instanceof String && conn.isConnected()) {
// The root row is the server itself, whose user object is just its name.
infoPanel.showServer(conn.getModel(), conn.getServerHost() + ":" + conn.getServerPort());
} else {
infoPanel.clear();
}