Show server and channel group icons in the tree

Download the icons a server advertises for its groups over the file
transfer channel, cache them under the config directory, and draw them as
a badge strip on each client's row, right-aligned against the tree's
visible edge like the TeamSpeak client does. Groups without a custom icon
fall back to the bundled default set.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 07:06:23 +00:00
parent 0333b92ce5
commit 7c86a1f166
18 changed files with 597 additions and 38 deletions

View File

@@ -123,7 +123,8 @@ public final class Settings {
return new File(identityFile);
}
public File configDir() {
/** The directory holding all persistent client state (settings, identities, caches). */
public static File configDir() {
return DIR;
}

View File

@@ -21,6 +21,9 @@ public interface ConnectionListener {
/** On-demand channel/client details finished loading; refresh any info view. */
void onInfoUpdated();
/** A group icon finished downloading; repaint anything showing icons. */
void onIconsUpdated();
void onChat(ChatScope scope, int fromClientId, String fromName, String message);
/** A client (possibly the local one) started or stopped talking. */

View File

@@ -0,0 +1,19 @@
package com.ts3client.net;
/** A server or channel group as announced by the server. */
public final class Group {
public final int id;
public final String name;
/** Id of the group's icon (0 = none); resolved through {@link IconRepository}. */
public final long iconId;
/** Display order among groups; lower comes first. */
public final int sortId;
public Group(int id, String name, long iconId, int sortId) {
this.id = id;
this.name = name;
this.iconId = iconId;
this.sortId = sortId;
}
}

View File

@@ -0,0 +1,164 @@
package com.ts3client.net;
import com.ts3client.config.Settings;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* Supplies the raw PNG bytes of a TeamSpeak icon id, as used by server and channel
* groups.
*
* <p>Ids up to {@link #MAX_BUNDLED_ID} identify the icons shipped with the client
* (TeamSpeak's default group icons); anything above that is a server-specific icon
* stored in the virtual server's file repository as {@code /icon_<id>} in channel 0,
* which is downloaded once and then cached in memory and on disk.
*
* <p>Lookups never block: {@link #get(long)} returns {@code null} while an icon is
* still being fetched and the listener is notified once it becomes available.
*/
public final class IconRepository {
/** Icons at or below this id are bundled defaults rather than server uploads. */
public static final int MAX_BUNDLED_ID = 1000;
/** Downloads {@code /icon_<id>} from the connected server's file repository. */
public interface Fetcher {
byte[] fetchIcon(long iconId) throws Exception;
}
private final Fetcher fetcher;
private final Runnable onIconLoaded;
private final File cacheDir = new File(Settings.configDir(), "icons");
private final Map<Long, byte[]> icons = new ConcurrentHashMap<>();
private final Set<Long> pending = ConcurrentHashMap.newKeySet();
private final Set<Long> failed = ConcurrentHashMap.newKeySet();
private final ExecutorService downloads = Executors.newSingleThreadExecutor(r -> {
Thread t = new Thread(r, "ts3j-icons");
t.setDaemon(true);
return t;
});
/**
* @param onIconLoaded run (off the UI thread) whenever a previously missing icon
* has arrived, so the view can repaint
*/
public IconRepository(Fetcher fetcher, Runnable onIconLoaded) {
this.fetcher = fetcher;
this.onIconLoaded = onIconLoaded;
}
/**
* @return the icon's PNG bytes, or {@code null} if it is unknown, still loading or
* unavailable. A server icon that is not cached yet starts downloading.
*/
public byte[] get(long iconId) {
long id = normalize(iconId);
if (id == 0) return null;
byte[] cached = icons.get(id);
if (cached != null) return cached;
byte[] bundled = readBundled(id);
if (bundled != null) {
icons.put(id, bundled);
return bundled;
}
if (id <= MAX_BUNDLED_ID) return null; // a default icon we do not ship
byte[] fromDisk = readDiskCache(id);
if (fromDisk != null) {
icons.put(id, fromDisk);
return fromDisk;
}
if (!failed.contains(id) && pending.add(id)) {
downloads.execute(() -> download(id));
}
return null;
}
/**
* The icon as a file on disk, materialising bundled icons into the cache directory
* on first use, for callers that can only reference images by URL (HTML views).
*
* @return the file, or {@code null} while the icon is unavailable
*/
public File file(long iconId) {
long id = normalize(iconId);
byte[] data = get(id);
if (data == null) return null;
File file = new File(cacheDir, "icon_" + id + ".png");
if (!file.isFile()) writeDiskCache(id, data);
return file.isFile() ? file : null;
}
/** Forgets download failures so icons can be retried on the next reconnect. */
public void retryFailed() {
failed.clear();
}
public void shutdown() {
downloads.shutdownNow();
}
private void download(long id) {
try {
byte[] data = fetcher.fetchIcon(id);
if (data == null || data.length == 0) {
failed.add(id);
return;
}
icons.put(id, data);
writeDiskCache(id, data);
if (onIconLoaded != null) onIconLoaded.run();
} catch (Exception e) {
// Missing or permission-denied icons are common; don't ask again.
failed.add(id);
} finally {
pending.remove(id);
}
}
/**
* TeamSpeak transmits icon ids as unsigned 32-bit CRCs, which arrive here through
* signed parsing; fold them back into their unsigned value.
*/
private static long normalize(long iconId) {
return iconId < 0 ? iconId & 0xFFFFFFFFL : iconId;
}
private static byte[] readBundled(long id) {
try (InputStream in = IconRepository.class.getResourceAsStream("/icons/group_" + id + ".png")) {
return in == null ? null : in.readAllBytes();
} catch (IOException e) {
return null;
}
}
private byte[] readDiskCache(long id) {
File file = new File(cacheDir, "icon_" + id + ".png");
if (!file.isFile()) return null;
try {
return Files.readAllBytes(file.toPath());
} catch (IOException e) {
return null;
}
}
private void writeDiskCache(long id, byte[] data) {
try {
//noinspection ResultOfMethodCallIgnored
cacheDir.mkdirs();
Files.write(new File(cacheDir, "icon_" + id + ".png").toPath(), data);
} catch (IOException ignored) {
// the in-memory cache still serves this session
}
}
}

View File

@@ -16,8 +16,8 @@ public final class ServerModel {
private final Map<Integer, ChannelNode> channels = new LinkedHashMap<>();
private final Map<Integer, ClientEntry> clients = new LinkedHashMap<>();
private final Map<Integer, String> serverGroups = new LinkedHashMap<>();
private final Map<Integer, String> channelGroups = new LinkedHashMap<>();
private final Map<Integer, Group> serverGroups = new LinkedHashMap<>();
private final Map<Integer, Group> channelGroups = new LinkedHashMap<>();
private String serverName = "TeamSpeak Server";
public synchronized void clear() {
@@ -29,25 +29,43 @@ public final class ServerModel {
// ---- groups ----
public synchronized void putServerGroup(int id, String name) {
if (name != null) serverGroups.put(id, name);
public synchronized void putServerGroup(Group group) {
if (group != null && group.name != null) serverGroups.put(group.id, group);
}
public synchronized void putChannelGroup(int id, String name) {
if (name != null) channelGroups.put(id, name);
public synchronized void putChannelGroup(Group group) {
if (group != null && group.name != null) channelGroups.put(group.id, group);
}
public synchronized String channelGroupName(int id) {
public synchronized Group channelGroup(int id) {
return channelGroups.get(id);
}
/** Resolves server-group ids to their names, keeping unknown ids as "#id". */
public synchronized java.util.List<String> serverGroupNames(int[] ids) {
java.util.List<String> names = new java.util.ArrayList<>();
public synchronized String channelGroupName(int id) {
Group g = channelGroups.get(id);
return g == null ? null : g.name;
}
/** Resolves a client's server-group ids to groups, ordered for display. */
public synchronized List<Group> serverGroupsOf(int[] ids) {
List<Group> groups = new ArrayList<>();
if (ids != null) {
for (int id : ids) {
String name = serverGroups.get(id);
names.add(name != null ? name : "#" + id);
Group g = serverGroups.get(id);
if (g != null) groups.add(g);
}
}
groups.sort(Comparator.comparingInt((Group g) -> g.sortId).thenComparingInt(g -> g.id));
return groups;
}
/** Resolves server-group ids to their names, keeping unknown ids as "#id". */
public synchronized List<String> serverGroupNames(int[] ids) {
List<String> names = new ArrayList<>();
if (ids != null) {
for (int id : ids) {
Group g = serverGroups.get(id);
names.add(g != null ? g.name : "#" + id);
}
}
return names;
@@ -56,7 +74,8 @@ public final class ServerModel {
/** Primary (first) server-group name for compact display, or {@code null}. */
public synchronized String primaryServerGroupName(int[] ids) {
if (ids == null || ids.length == 0) return null;
return serverGroups.get(ids[0]);
Group g = serverGroups.get(ids[0]);
return g == null ? null : g.name;
}
public synchronized String getServerName() {

View File

@@ -33,10 +33,14 @@ import java.util.function.Consumer;
*/
public final class TeamspeakConnection implements TS3Listener {
/** Upper bound for a downloaded group icon; anything larger is not an icon. */
private static final int MAX_ICON_BYTES = 1024 * 1024;
private final Settings settings;
private final AudioBackend audio;
private final ServerModel model = new ServerModel();
private final ConnectionListener ui;
private final IconRepository icons;
private LocalTeamspeakClientSocket client;
private VoiceInput microphone;
@@ -64,6 +68,7 @@ public final class TeamspeakConnection implements TS3Listener {
this.settings = settings;
this.audio = audio;
this.ui = ui;
this.icons = new IconRepository(this::fetchIcon, ui::onIconsUpdated);
}
public ServerModel getModel() {
@@ -201,6 +206,7 @@ public final class TeamspeakConnection implements TS3Listener {
selfClientId = client.getClientId();
fileTransfers = new FileTransferManager(client, () -> serverHost);
client.setMicrophone(microphone);
icons.retryFailed();
connected = true;
connectedAtMs = System.currentTimeMillis();
ui.onConnected();
@@ -647,13 +653,17 @@ public final class TeamspeakConnection implements TS3Listener {
@Override
public void onServerGroupList(ServerGroupListEvent e) {
int id = safeInt(e, "sgid");
if (id > 0) model.putServerGroup(id, e.get("name"));
if (id > 0) model.putServerGroup(toGroup(e, id));
}
@Override
public void onChannelGroupList(ChannelGroupListEvent e) {
int id = safeInt(e, "cgid");
if (id > 0) model.putChannelGroup(id, e.get("name"));
if (id > 0) model.putChannelGroup(toGroup(e, id));
}
private static Group toGroup(BaseEvent e, int id) {
return new Group(id, e.get("name"), safeLong(e, "iconid"), safeInt(e, "sortid"));
}
/** Fetches a channel's description (and topic) on demand, then notifies the UI. */
@@ -697,6 +707,20 @@ public final class TeamspeakConnection implements TS3Listener {
}, "ts3j-clientinfo").start();
}
// ---- icons ----
/** Group icons for this server: bundled defaults plus the server's own uploads. */
public IconRepository getIcons() {
return icons;
}
/** Reads an icon out of the virtual server's file repository (channel 0). */
private byte[] fetchIcon(long iconId) throws Exception {
FileTransferManager ft = fileTransfers;
if (ft == null) throw new IllegalStateException("Not connected");
return ft.downloadToMemory(0, "", "/icon_" + iconId, MAX_ICON_BYTES);
}
// ---- file transfers ----
/** Whether a live connection capable of file transfers is available. */
@@ -1022,6 +1046,15 @@ public final class TeamspeakConnection implements TS3Listener {
// ---- helpers ----
private static long safeLong(BaseEvent e, String key) {
try {
String v = e.get(key);
return v == null ? 0 : Long.parseLong(v.trim());
} catch (Exception ex) {
return 0;
}
}
private static int safeInt(BaseEvent e, String key) {
try {
String v = e.get(key);

View File

@@ -176,6 +176,41 @@ public final class FileTransferManager {
return transfer;
}
/**
* Downloads a small file straight into memory, blocking until it is complete.
* Intended for icons and other assets that never reach the disk.
*/
public byte[] downloadToMemory(int channelId, String channelPassword, String remoteFullPath,
int maxBytes) throws Exception {
int ftfid = clientTransferId.getAndIncrement();
SingleCommand cmd = new SingleCommand("ftinitdownload", ProtocolRole.CLIENT,
new CommandSingleParameter("clientftfid", Integer.toString(ftfid)),
new CommandSingleParameter("name", remoteFullPath),
new CommandSingleParameter("cid", Integer.toString(channelId)),
new CommandSingleParameter("cpw", channelPassword == null ? "" : channelPassword),
new CommandSingleParameter("seekpos", "0"),
new CommandSingleParameter("proto", "0"));
FileTransferParameters params = negotiate(ftfid, cmd);
long size = params.getFileSize();
if (size <= 0 || size > maxBytes) {
throw new IOException("Unexpected file size " + size + " for " + remoteFullPath);
}
byte[] data = new byte[(int) size];
try (Socket fileSocket = openFileSocket(params);
InputStream in = new BufferedInputStream(fileSocket.getInputStream())) {
sendKey(fileSocket, params);
int offset = 0;
while (offset < data.length) {
int read = in.read(data, offset, data.length - offset);
if (read < 0) throw new IOException("File server closed the connection early");
offset += read;
}
}
return data;
}
private void runDownload(int channelId, String channelPassword, String remoteFullPath,
FileTransfer transfer) {
try {

Binary file not shown.

After

Width:  |  Height:  |  Size: 809 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 781 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 820 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 803 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 757 B