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); return new File(identityFile);
} }
public File configDir() { /** The directory holding all persistent client state (settings, identities, caches). */
public static File configDir() {
return DIR; return DIR;
} }

View File

@@ -21,6 +21,9 @@ public interface ConnectionListener {
/** On-demand channel/client details finished loading; refresh any info view. */ /** On-demand channel/client details finished loading; refresh any info view. */
void onInfoUpdated(); void onInfoUpdated();
/** A group icon finished downloading; repaint anything showing icons. */
void onIconsUpdated();
void onChat(ChatScope scope, int fromClientId, String fromName, String message); void onChat(ChatScope scope, int fromClientId, String fromName, String message);
/** A client (possibly the local one) started or stopped talking. */ /** 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, ChannelNode> channels = new LinkedHashMap<>();
private final Map<Integer, ClientEntry> clients = new LinkedHashMap<>(); private final Map<Integer, ClientEntry> clients = new LinkedHashMap<>();
private final Map<Integer, String> serverGroups = new LinkedHashMap<>(); private final Map<Integer, Group> serverGroups = new LinkedHashMap<>();
private final Map<Integer, String> channelGroups = new LinkedHashMap<>(); private final Map<Integer, Group> channelGroups = new LinkedHashMap<>();
private String serverName = "TeamSpeak Server"; private String serverName = "TeamSpeak Server";
public synchronized void clear() { public synchronized void clear() {
@@ -29,25 +29,43 @@ public final class ServerModel {
// ---- groups ---- // ---- groups ----
public synchronized void putServerGroup(int id, String name) { public synchronized void putServerGroup(Group group) {
if (name != null) serverGroups.put(id, name); if (group != null && group.name != null) serverGroups.put(group.id, group);
} }
public synchronized void putChannelGroup(int id, String name) { public synchronized void putChannelGroup(Group group) {
if (name != null) channelGroups.put(id, name); 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); return channelGroups.get(id);
} }
/** Resolves server-group ids to their names, keeping unknown ids as "#id". */ public synchronized String channelGroupName(int id) {
public synchronized java.util.List<String> serverGroupNames(int[] ids) { Group g = channelGroups.get(id);
java.util.List<String> names = new java.util.ArrayList<>(); 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) { if (ids != null) {
for (int id : ids) { for (int id : ids) {
String name = serverGroups.get(id); Group g = serverGroups.get(id);
names.add(name != null ? name : "#" + 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; return names;
@@ -56,7 +74,8 @@ public final class ServerModel {
/** Primary (first) server-group name for compact display, or {@code null}. */ /** Primary (first) server-group name for compact display, or {@code null}. */
public synchronized String primaryServerGroupName(int[] ids) { public synchronized String primaryServerGroupName(int[] ids) {
if (ids == null || ids.length == 0) return null; 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() { public synchronized String getServerName() {

View File

@@ -33,10 +33,14 @@ import java.util.function.Consumer;
*/ */
public final class TeamspeakConnection implements TS3Listener { 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 Settings settings;
private final AudioBackend audio; private final AudioBackend audio;
private final ServerModel model = new ServerModel(); private final ServerModel model = new ServerModel();
private final ConnectionListener ui; private final ConnectionListener ui;
private final IconRepository icons;
private LocalTeamspeakClientSocket client; private LocalTeamspeakClientSocket client;
private VoiceInput microphone; private VoiceInput microphone;
@@ -64,6 +68,7 @@ public final class TeamspeakConnection implements TS3Listener {
this.settings = settings; this.settings = settings;
this.audio = audio; this.audio = audio;
this.ui = ui; this.ui = ui;
this.icons = new IconRepository(this::fetchIcon, ui::onIconsUpdated);
} }
public ServerModel getModel() { public ServerModel getModel() {
@@ -201,6 +206,7 @@ public final class TeamspeakConnection implements TS3Listener {
selfClientId = client.getClientId(); selfClientId = client.getClientId();
fileTransfers = new FileTransferManager(client, () -> serverHost); fileTransfers = new FileTransferManager(client, () -> serverHost);
client.setMicrophone(microphone); client.setMicrophone(microphone);
icons.retryFailed();
connected = true; connected = true;
connectedAtMs = System.currentTimeMillis(); connectedAtMs = System.currentTimeMillis();
ui.onConnected(); ui.onConnected();
@@ -647,13 +653,17 @@ public final class TeamspeakConnection implements TS3Listener {
@Override @Override
public void onServerGroupList(ServerGroupListEvent e) { public void onServerGroupList(ServerGroupListEvent e) {
int id = safeInt(e, "sgid"); int id = safeInt(e, "sgid");
if (id > 0) model.putServerGroup(id, e.get("name")); if (id > 0) model.putServerGroup(toGroup(e, id));
} }
@Override @Override
public void onChannelGroupList(ChannelGroupListEvent e) { public void onChannelGroupList(ChannelGroupListEvent e) {
int id = safeInt(e, "cgid"); 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. */ /** 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(); }, "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 ---- // ---- file transfers ----
/** Whether a live connection capable of file transfers is available. */ /** Whether a live connection capable of file transfers is available. */
@@ -1022,6 +1046,15 @@ public final class TeamspeakConnection implements TS3Listener {
// ---- helpers ---- // ---- 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) { private static int safeInt(BaseEvent e, String key) {
try { try {
String v = e.get(key); String v = e.get(key);

View File

@@ -176,6 +176,41 @@ public final class FileTransferManager {
return transfer; 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, private void runDownload(int channelId, String channelPassword, String remoteFullPath,
FileTransfer transfer) { FileTransfer transfer) {
try { 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

View File

@@ -0,0 +1,101 @@
package com.ts3client.ui;
import com.ts3client.net.Group;
import com.ts3client.net.IconRepository;
import javax.swing.Icon;
import javax.swing.ImageIcon;
import java.awt.Component;
import java.awt.Graphics;
import java.awt.Image;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* Turns the {@link IconRepository}'s icon bytes into Swing icons for one server's
* groups, caching the decoded images. Icons that still have to be downloaded simply
* do not render yet; the repository repaints the view once they arrive.
*/
public final class GroupIcons {
/** Tree rows are 16&times;16 like TeamSpeak's own icon set. */
private static final int SIZE = 16;
private final IconRepository repository;
private final Map<Long, ImageIcon> decoded = new ConcurrentHashMap<>();
public GroupIcons(IconRepository repository) {
this.repository = repository;
}
/** @return the group's icon, or {@code null} if it has none or it is not loaded yet */
public ImageIcon iconOf(Group group) {
if (group == null || group.iconId == 0) return null;
return icon(group.iconId);
}
public ImageIcon icon(long iconId) {
ImageIcon cached = decoded.get(iconId);
if (cached != null) return cached;
byte[] data = repository.get(iconId);
if (data == null) return null;
ImageIcon icon = new ImageIcon(data);
if (icon.getIconWidth() <= 0) return null;
if (icon.getIconWidth() != SIZE || icon.getIconHeight() != SIZE) {
icon = new ImageIcon(icon.getImage().getScaledInstance(SIZE, SIZE, Image.SCALE_SMOOTH));
}
decoded.put(iconId, icon);
return icon;
}
/** Collects the icons of a client's server groups plus its channel group, in display order. */
public List<Icon> iconsOf(List<Group> serverGroups, Group channelGroup) {
List<Icon> icons = new ArrayList<>();
for (Group g : serverGroups) {
ImageIcon icon = iconOf(g);
if (icon != null) icons.add(icon);
}
ImageIcon channelIcon = iconOf(channelGroup);
if (channelIcon != null) icons.add(channelIcon);
return icons;
}
/** Lays several icons out in a row, so a single tree cell can show a badge strip. */
public static final class Row implements Icon {
private static final int GAP = 2;
private final List<Icon> icons;
public Row(List<Icon> icons) {
this.icons = icons;
}
@Override
public void paintIcon(Component c, Graphics g, int x, int y) {
int offset = x;
for (Icon icon : icons) {
icon.paintIcon(c, g, offset, y + (getIconHeight() - icon.getIconHeight()) / 2);
offset += icon.getIconWidth() + GAP;
}
}
@Override
public int getIconWidth() {
int width = 0;
for (Icon icon : icons) width += icon.getIconWidth() + GAP;
return Math.max(0, width - GAP);
}
@Override
public int getIconHeight() {
int height = 0;
for (Icon icon : icons) height = Math.max(height, icon.getIconHeight());
return height;
}
}
}

View File

@@ -2,12 +2,15 @@ package com.ts3client.ui;
import com.ts3client.net.ChannelNode; import com.ts3client.net.ChannelNode;
import com.ts3client.net.ClientEntry; import com.ts3client.net.ClientEntry;
import com.ts3client.net.Group;
import com.ts3client.net.IconRepository;
import com.ts3client.net.ServerModel; import com.ts3client.net.ServerModel;
import com.ts3client.text.BBCode; import com.ts3client.text.BBCode;
import javax.swing.BorderFactory; import javax.swing.BorderFactory;
import javax.swing.JEditorPane; import javax.swing.JEditorPane;
import javax.swing.JScrollPane; import javax.swing.JScrollPane;
import java.io.File;
import java.util.List; import java.util.List;
/** /**
@@ -67,15 +70,27 @@ public final class InfoPanel extends JScrollPane {
setHtml(sb.toString()); setHtml(sb.toString());
} }
public void showClient(ClientEntry cl, ServerModel model) { public void showClient(ClientEntry cl, ServerModel model, IconRepository icons) {
StringBuilder sb = new StringBuilder(); StringBuilder sb = new StringBuilder();
sb.append(heading(esc(cl.nickname) + (cl.self ? " <span style='color:#8a8a8a'>(you)</span>" : ""))); sb.append(heading(esc(cl.nickname) + (cl.self ? " <span style='color:#8a8a8a'>(you)</span>" : "")));
List<String> serverGroups = model.serverGroupNames(cl.serverGroupIds); List<Group> serverGroups = model.serverGroupsOf(cl.serverGroupIds);
row(sb, "Server groups", serverGroups.isEmpty() ? "" : esc(String.join(", ", serverGroups))); 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());
}
String channelGroup = model.channelGroupName(cl.channelGroupId); Group channelGroup = model.channelGroup(cl.channelGroupId);
row(sb, "Channel group", channelGroup != null ? esc(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.talkPower != 0) row(sb, "Talk power", Integer.toString(cl.talkPower));
if (!cl.platform.isEmpty()) row(sb, "Platform", esc(cl.platform)); if (!cl.platform.isEmpty()) row(sb, "Platform", esc(cl.platform));
@@ -97,6 +112,18 @@ public final class InfoPanel extends JScrollPane {
pane.setCaretPosition(0); pane.setCaretPosition(0);
} }
/**
* An inline {@code <img>} for a group's icon, or nothing while the icon is missing
* or still downloading. Swing's HTML renderer only loads images by URL, so the icon
* is served from the repository's on-disk cache.
*/
private static String iconTag(Group group, IconRepository icons) {
if (group.iconId == 0) return "";
File file = icons.file(group.iconId);
if (file == null) return "";
return "<img src='" + file.toURI() + "' width='14' height='14'> ";
}
private static String heading(String text) { private static String heading(String text) {
return "<div style='font-weight:bold;font-size:13px;margin-bottom:4px'>" + text + "</div>"; return "<div style='font-weight:bold;font-size:13px;margin-bottom:4px'>" + text + "</div>";
} }

View File

@@ -314,6 +314,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
if (tabs.size() == 1) return; // always keep one view around if (tabs.size() == 1) return; // always keep one view around
tabs.remove(tab); tabs.remove(tab);
tab.dispose();
tabPane.removeTab(tab); tabPane.removeTab(tab);
if (micTab == tab) micTab = null; if (micTab == tab) micTab = null;
if (selected == tab) { if (selected == tab) {

View File

@@ -31,6 +31,7 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
private final IdentityStore identities; private final IdentityStore identities;
private final TeamspeakConnection conn; private final TeamspeakConnection conn;
private final GroupIcons groupIcons;
private final ServerTreePanel treePanel; private final ServerTreePanel treePanel;
private final ChatPanel chatPanel; private final ChatPanel chatPanel;
private final InfoPanel infoPanel = new InfoPanel(); private final InfoPanel infoPanel = new InfoPanel();
@@ -56,7 +57,8 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
this.settings = settings; this.settings = settings;
this.identities = identities; this.identities = identities;
this.conn = new TeamspeakConnection(settings, audio, this); this.conn = new TeamspeakConnection(settings, audio, this);
this.treePanel = new ServerTreePanel(conn.getModel(), this); this.groupIcons = new GroupIcons(conn.getIcons());
this.treePanel = new ServerTreePanel(conn.getModel(), groupIcons, this);
this.chatPanel = new ChatPanel(); this.chatPanel = new ChatPanel();
chatPanel.setSendHandler(this::onSendChat); chatPanel.setSendHandler(this::onSendChat);
@@ -189,6 +191,11 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
if (conn.isConnected()) conn.disconnect(); if (conn.isConnected()) conn.disconnect();
} }
/** Releases the background resources of a tab that is being thrown away. */
void dispose() {
conn.getIcons().shutdown();
}
/** Synchronous teardown for shutdown paths, so the server sees us leave. */ /** Synchronous teardown for shutdown paths, so the server sees us leave. */
void shutdown() { void shutdown() {
if (conn.isConnected()) conn.disconnectBlocking("Leaving"); if (conn.isConnected()) conn.disconnectBlocking("Leaving");
@@ -361,7 +368,7 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
if (sel instanceof ChannelNode) { if (sel instanceof ChannelNode) {
infoPanel.showChannel((ChannelNode) sel); infoPanel.showChannel((ChannelNode) sel);
} else if (sel instanceof ClientEntry) { } else if (sel instanceof ClientEntry) {
infoPanel.showClient((ClientEntry) sel, conn.getModel()); infoPanel.showClient((ClientEntry) sel, conn.getModel(), conn.getIcons());
} else { } else {
infoPanel.clear(); infoPanel.clear();
} }
@@ -425,6 +432,14 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
SwingUtilities.invokeLater(this::renderInfo); SwingUtilities.invokeLater(this::renderInfo);
} }
@Override
public void onIconsUpdated() {
SwingUtilities.invokeLater(() -> {
treePanel.refreshRowSizes();
renderInfo();
});
}
@Override @Override
public void onChat(ChatScope scope, int fromClientId, String fromName, String message) { public void onChat(ChatScope scope, int fromClientId, String fromName, String message) {
switch (scope) { switch (scope) {

View File

@@ -6,6 +6,7 @@ import com.ts3client.net.ServerModel;
import com.ts3client.text.TsLink; import com.ts3client.text.TsLink;
import javax.swing.DropMode; import javax.swing.DropMode;
import javax.swing.Icon;
import javax.swing.ImageIcon; import javax.swing.ImageIcon;
import javax.swing.JComponent; import javax.swing.JComponent;
import javax.swing.JScrollPane; import javax.swing.JScrollPane;
@@ -77,11 +78,13 @@ public final class ServerTreePanel extends JScrollPane {
private final DefaultMutableTreeNode root = new DefaultMutableTreeNode(); private final DefaultMutableTreeNode root = new DefaultMutableTreeNode();
private final DefaultTreeModel treeModel = new DefaultTreeModel(root); private final DefaultTreeModel treeModel = new DefaultTreeModel(root);
private final ServerModel model; private final ServerModel model;
private final GroupIcons groupIcons;
private final Actions actions; private final Actions actions;
private int selfClientId = -1; private int selfClientId = -1;
public ServerTreePanel(ServerModel model, Actions actions) { public ServerTreePanel(ServerModel model, GroupIcons groupIcons, Actions actions) {
this.model = model; this.model = model;
this.groupIcons = groupIcons;
this.actions = actions; this.actions = actions;
root.setUserObject("Not connected"); root.setUserObject("Not connected");
this.tree = new DropIndicatorTree(treeModel); this.tree = new DropIndicatorTree(treeModel);
@@ -364,11 +367,47 @@ public final class ServerTreePanel extends JScrollPane {
} }
} }
// ---- group icon strip ----
/** Gap kept between a row's label and the right-aligned icon strip. */
private static final int BADGE_GAP = 8;
/** Inset of the strip from the visible right edge. */
private static final int BADGE_MARGIN = 4;
/**
* Paints every visible client's group icons flush with the right edge of the
* viewport, the way TeamSpeak lines them up. Drawing them here rather than in the
* cell renderer keeps the rows' measured widths (and thus the selection highlight)
* tied to the label alone.
*/
private void paintBadges(Graphics g, JTree tree) {
Rectangle visible = tree.getVisibleRect();
int right = visible.x + visible.width - BADGE_MARGIN;
for (int row = 0; row < tree.getRowCount(); row++) {
Rectangle bounds = tree.getRowBounds(row);
if (bounds == null || bounds.y + bounds.height < visible.y) continue;
if (bounds.y > visible.y + visible.height) break;
TreePath path = tree.getPathForRow(row);
Object obj = ((DefaultMutableTreeNode) path.getLastPathComponent()).getUserObject();
if (!(obj instanceof ClientEntry)) continue;
ClientEntry cl = (ClientEntry) obj;
List<Icon> icons = groupIcons.iconsOf(
model.serverGroupsOf(cl.serverGroupIds), model.channelGroup(cl.channelGroupId));
if (icons.isEmpty()) continue;
GroupIcons.Row strip = new GroupIcons.Row(icons);
int x = Math.max(bounds.x + bounds.width + BADGE_GAP, right - strip.getIconWidth());
strip.paintIcon(tree, g, x, bounds.y + (bounds.height - strip.getIconHeight()) / 2);
}
}
/** /**
* Draws where the drop will land: an insertion line between rows, or an outline * Draws where the drop will land: an insertion line between rows, or an outline
* around the row that will receive the dragged node. * around the row that will receive the dragged node.
*/ */
private static final class DropIndicatorTree extends JTree { private final class DropIndicatorTree extends JTree {
/** Set while a drop would move a client into this channel row. */ /** Set while a drop would move a client into this channel row. */
private TreePath highlight; private TreePath highlight;
@@ -385,6 +424,7 @@ public final class ServerTreePanel extends JScrollPane {
@Override @Override
protected void paintComponent(Graphics g) { protected void paintComponent(Graphics g) {
super.paintComponent(g); super.paintComponent(g);
paintBadges(g, this);
JTree.DropLocation loc = getDropLocation(); JTree.DropLocation loc = getDropLocation();
if (loc == null || loc.getPath() == null) return; if (loc == null || loc.getPath() == null) return;
@@ -477,7 +517,26 @@ public final class ServerTreePanel extends JScrollPane {
tree.repaint(); tree.repaint();
} }
/**
* Re-measures every visible row, for changes that alter a row's width (a group
* icon that finished downloading). A plain repaint would keep the cached widths
* and clip the new icons.
*/
public void refreshRowSizes() {
for (int i = tree.getRowCount() - 1; i >= 0; i--) {
TreePath path = tree.getPathForRow(i);
if (path != null) {
treeModel.nodeChanged((DefaultMutableTreeNode) path.getLastPathComponent());
}
}
}
/**
* Draws a tree row: the status icon and the label. The group icon strip is painted
* separately, right-aligned, by {@link #paintBadges}.
*/
private final class Renderer extends DefaultTreeCellRenderer { private final class Renderer extends DefaultTreeCellRenderer {
@Override @Override
public Component getTreeCellRendererComponent(JTree tree, Object value, boolean sel, public Component getTreeCellRendererComponent(JTree tree, Object value, boolean sel,
boolean expanded, boolean leaf, int row, boolean expanded, boolean leaf, int row,
@@ -504,9 +563,15 @@ public final class ServerTreePanel extends JScrollPane {
} }
} else if (obj instanceof ClientEntry) { } else if (obj instanceof ClientEntry) {
ClientEntry cl = (ClientEntry) obj; ClientEntry cl = (ClientEntry) obj;
String primaryGroup = model.primaryServerGroupName(cl.serverGroupIds);
String label = cl.nickname; String label = cl.nickname;
if (primaryGroup != null) label += " [" + primaryGroup + "]"; boolean hasIcons = !groupIcons.iconsOf(
model.serverGroupsOf(cl.serverGroupIds),
model.channelGroup(cl.channelGroupId)).isEmpty();
if (!hasIcons) {
// No icons (yet): fall back to naming the primary group inline.
String primaryGroup = model.primaryServerGroupName(cl.serverGroupIds);
if (primaryGroup != null) label += " [" + primaryGroup + "]";
}
setText(label); setText(label);
setIcon(iconFor(cl)); setIcon(iconFor(cl));
setForeground(cl.talking ? Theme.TALKING : Theme.TREE_TEXT); setForeground(cl.talking ? Theme.TALKING : Theme.TREE_TEXT);

View File

@@ -13,12 +13,14 @@ import javax.swing.ButtonGroup;
import javax.swing.JButton; import javax.swing.JButton;
import javax.swing.JCheckBox; import javax.swing.JCheckBox;
import javax.swing.JComboBox; import javax.swing.JComboBox;
import javax.swing.JComponent;
import javax.swing.JDialog; import javax.swing.JDialog;
import javax.swing.JLabel; import javax.swing.JLabel;
import javax.swing.JPanel; import javax.swing.JPanel;
import javax.swing.JRadioButton; import javax.swing.JRadioButton;
import javax.swing.JSlider; import javax.swing.JSlider;
import javax.swing.JTabbedPane; import javax.swing.JTabbedPane;
import javax.swing.Scrollable;
import javax.swing.SwingUtilities; import javax.swing.SwingUtilities;
import java.awt.BorderLayout; import java.awt.BorderLayout;
import java.awt.Dimension; import java.awt.Dimension;
@@ -26,6 +28,7 @@ import java.awt.Frame;
import java.awt.GridBagConstraints; import java.awt.GridBagConstraints;
import java.awt.GridBagLayout; import java.awt.GridBagLayout;
import java.awt.Insets; import java.awt.Insets;
import java.awt.Rectangle;
import java.awt.event.KeyAdapter; import java.awt.event.KeyAdapter;
import java.awt.event.KeyEvent; import java.awt.event.KeyEvent;
import java.util.List; import java.util.List;
@@ -37,6 +40,14 @@ import java.util.List;
*/ */
public final class SettingsDialog extends JDialog { public final class SettingsDialog extends JDialog {
/** Preferred widths of the form's field column; rows shrink with the dialog from there. */
private static final int FIELD_WIDTH = 240;
private static final int SLIDER_WIDTH = 200;
private static final int MIN_FIELD_WIDTH = 60;
private static final int MIN_BITRATE_KBITS = 8;
private static final int MAX_BITRATE_KBITS = 160;
private final Settings settings; private final Settings settings;
private final VoiceInput liveMic; private final VoiceInput liveMic;
private final VoiceOutput livePlayback; private final VoiceOutput livePlayback;
@@ -114,13 +125,13 @@ public final class SettingsDialog extends JDialog {
pack(); pack();
setSize(new Dimension(480, 540)); setSize(new Dimension(480, 540));
setMinimumSize(new Dimension(420, 360));
setLocationRelativeTo(owner); setLocationRelativeTo(owner);
startMeter(); startMeter();
} }
private JPanel buildDevicesTab() { private JPanel buildDevicesTab() {
JPanel p = new JPanel(new GridBagLayout()); JPanel p = formPanel();
p.setBorder(BorderFactory.createEmptyBorder(12, 12, 12, 12));
GridBagConstraints c = gbc(); GridBagConstraints c = gbc();
List<AudioDevices.Device> ins = AudioDevices.inputDevices(); List<AudioDevices.Device> ins = AudioDevices.inputDevices();
@@ -136,6 +147,8 @@ public final class SettingsDialog extends JDialog {
+ "<b>ALSA:</b> entries talk to the sound card directly, taking it exclusively.</html>"; + "<b>ALSA:</b> entries talk to the sound card directly, taking it exclusively.</html>";
inputCombo.setToolTipText(deviceHint); inputCombo.setToolTipText(deviceHint);
outputCombo.setToolTipText(deviceHint); outputCombo.setToolTipText(deviceHint);
limitWidth(inputCombo, FIELD_WIDTH);
limitWidth(outputCombo, FIELD_WIDTH);
int row = 0; int row = 0;
addRow(p, c, row++, new JLabel("Capture device (microphone):"), inputCombo); addRow(p, c, row++, new JLabel("Capture device (microphone):"), inputCombo);
@@ -143,6 +156,8 @@ public final class SettingsDialog extends JDialog {
inputGain = new JSlider(0, 200, (int) Math.round(settings.inputVolume * 100)); inputGain = new JSlider(0, 200, (int) Math.round(settings.inputVolume * 100));
outputVol = new JSlider(0, 200, (int) Math.round(settings.outputVolume * 100)); outputVol = new JSlider(0, 200, (int) Math.round(settings.outputVolume * 100));
limitWidth(inputGain, SLIDER_WIDTH);
limitWidth(outputVol, SLIDER_WIDTH);
addRow(p, c, row++, new JLabel("Microphone gain:"), inputGain); addRow(p, c, row++, new JLabel("Microphone gain:"), inputGain);
addRow(p, c, row++, new JLabel("Playback volume:"), outputVol); addRow(p, c, row++, new JLabel("Playback volume:"), outputVol);
@@ -165,6 +180,7 @@ public final class SettingsDialog extends JDialog {
denoiseCheck = new JCheckBox("Remove background noise", settings.denoise); denoiseCheck = new JCheckBox("Remove background noise", settings.denoise);
denoiseCheck.setToolTipText("Attempt to filter out background noises."); denoiseCheck.setToolTipText("Attempt to filter out background noises.");
denoiseLevel = new JSlider(0, 100, (int) Math.round(settings.denoiserLevel * 100)); denoiseLevel = new JSlider(0, 100, (int) Math.round(settings.denoiserLevel * 100));
limitWidth(denoiseLevel, SLIDER_WIDTH);
denoiseLevel.setToolTipText("Higher = more aggressive noise removal."); denoiseLevel.setToolTipText("Higher = more aggressive noise removal.");
typingCheck = new JCheckBox("Typing attenuation", settings.typingAttenuation); typingCheck = new JCheckBox("Typing attenuation", settings.typingAttenuation);
typingCheck.setToolTipText("<html><b>Typing attenuation</b> tries to detect and " typingCheck.setToolTipText("<html><b>Typing attenuation</b> tries to detect and "
@@ -214,8 +230,7 @@ public final class SettingsDialog extends JDialog {
} }
private JPanel buildVoiceTab() { private JPanel buildVoiceTab() {
JPanel p = new JPanel(new GridBagLayout()); JPanel p = formPanel();
p.setBorder(BorderFactory.createEmptyBorder(12, 12, 12, 12));
GridBagConstraints c = gbc(); GridBagConstraints c = gbc();
vadRadio = new JRadioButton("Voice Activation Detection"); vadRadio = new JRadioButton("Voice Activation Detection");
@@ -295,18 +310,18 @@ public final class SettingsDialog extends JDialog {
p.add(vadOverPttCheck, c); p.add(vadOverPttCheck, c);
c.gridwidth = 1; c.gridwidth = 1;
bitrateSlider = new JSlider(8, 128, settings.bitrate / 1000); int kbits = Math.max(MIN_BITRATE_KBITS, Math.min(MAX_BITRATE_KBITS, settings.bitrate / 1000));
bitrateLabel = new JLabel(settings.bitrate / 1000 + " kbit/s"); bitrateSlider = new JSlider(MIN_BITRATE_KBITS, MAX_BITRATE_KBITS, kbits);
bitrateLabel = new JLabel(kbits + " kbit/s");
bitrateSlider.addChangeListener(e -> { bitrateSlider.addChangeListener(e -> {
bitrateLabel.setText(bitrateSlider.getValue() + " kbit/s"); bitrateLabel.setText(bitrateSlider.getValue() + " kbit/s");
pushOpusLive(); pushOpusLive();
}); });
JPanel brPanel = new JPanel(new BorderLayout(6, 0)); addRow(p, c, row++, new JLabel("Opus bitrate:"),
brPanel.add(bitrateSlider, BorderLayout.CENTER); sliderWithLabel(bitrateSlider, bitrateLabel, 70));
brPanel.add(bitrateLabel, BorderLayout.EAST);
addRow(p, c, row++, new JLabel("Opus bitrate:"), brPanel);
complexitySlider = new JSlider(0, 10, settings.complexity); complexitySlider = new JSlider(0, 10, settings.complexity);
limitWidth(complexitySlider, SLIDER_WIDTH);
complexitySlider.addChangeListener(e -> pushOpusLive()); complexitySlider.addChangeListener(e -> pushOpusLive());
addRow(p, c, row++, new JLabel("Opus complexity:"), complexitySlider); addRow(p, c, row++, new JLabel("Opus complexity:"), complexitySlider);
@@ -389,6 +404,47 @@ public final class SettingsDialog extends JDialog {
} }
} }
/**
* The form panel used by both tabs. It follows the scroll pane's width instead of
* demanding its own preferred one, so rows stay inside the dialog.
*/
private static JPanel formPanel() {
JPanel p = new FormPanel();
p.setBorder(BorderFactory.createEmptyBorder(12, 12, 12, 12));
return p;
}
private static final class FormPanel extends JPanel implements Scrollable {
FormPanel() {
super(new GridBagLayout());
}
@Override
public Dimension getPreferredScrollableViewportSize() {
return getPreferredSize();
}
@Override
public int getScrollableUnitIncrement(Rectangle visible, int orientation, int direction) {
return 16;
}
@Override
public int getScrollableBlockIncrement(Rectangle visible, int orientation, int direction) {
return visible.height;
}
@Override
public boolean getScrollableTracksViewportWidth() {
return true;
}
@Override
public boolean getScrollableTracksViewportHeight() {
return false;
}
}
private static javax.swing.JScrollPane scrollable(JPanel content) { private static javax.swing.JScrollPane scrollable(JPanel content) {
javax.swing.JScrollPane sp = new javax.swing.JScrollPane(content, javax.swing.JScrollPane sp = new javax.swing.JScrollPane(content,
javax.swing.ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, javax.swing.ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED,
@@ -399,13 +455,33 @@ public final class SettingsDialog extends JDialog {
} }
private static JPanel sliderWithLabel(JSlider slider, JLabel valueLabel) { private static JPanel sliderWithLabel(JSlider slider, JLabel valueLabel) {
return sliderWithLabel(slider, valueLabel, 48);
}
/**
* Pairs a slider with its value readout. The readout gets a fixed width wide enough for
* the longest value so the slider does not jump around as it is dragged.
*/
private static JPanel sliderWithLabel(JSlider slider, JLabel valueLabel, int labelWidth) {
JPanel panel = new JPanel(new BorderLayout(6, 0)); JPanel panel = new JPanel(new BorderLayout(6, 0));
limitWidth(slider, SLIDER_WIDTH);
panel.add(slider, BorderLayout.CENTER); panel.add(slider, BorderLayout.CENTER);
valueLabel.setPreferredSize(new Dimension(48, valueLabel.getPreferredSize().height)); valueLabel.setPreferredSize(new Dimension(labelWidth, valueLabel.getPreferredSize().height));
panel.add(valueLabel, BorderLayout.EAST); panel.add(valueLabel, BorderLayout.EAST);
return panel; return panel;
} }
/**
* Caps a field's preferred width and lets it shrink: long device names and wide sliders
* would otherwise force the form past the dialog's edge, where the scroll pane (which
* never scrolls horizontally) simply clips them.
*/
private static void limitWidth(JComponent comp, int preferredWidth) {
int height = comp.getPreferredSize().height;
comp.setPreferredSize(new Dimension(preferredWidth, height));
comp.setMinimumSize(new Dimension(MIN_FIELD_WIDTH, height));
}
private OpusParameters currentOpusParameters() { private OpusParameters currentOpusParameters() {
return new OpusParameters( return new OpusParameters(
bitrateSlider.getValue() * 1000, bitrateSlider.getValue() * 1000,