diff --git a/ts3-client/core/src/main/java/com/ts3client/config/Settings.java b/ts3-client/core/src/main/java/com/ts3client/config/Settings.java
index 0076bf7..3eed341 100644
--- a/ts3-client/core/src/main/java/com/ts3client/config/Settings.java
+++ b/ts3-client/core/src/main/java/com/ts3client/config/Settings.java
@@ -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;
}
diff --git a/ts3-client/core/src/main/java/com/ts3client/net/ConnectionListener.java b/ts3-client/core/src/main/java/com/ts3client/net/ConnectionListener.java
index 1b5d615..94bf3bb 100644
--- a/ts3-client/core/src/main/java/com/ts3client/net/ConnectionListener.java
+++ b/ts3-client/core/src/main/java/com/ts3client/net/ConnectionListener.java
@@ -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. */
diff --git a/ts3-client/core/src/main/java/com/ts3client/net/Group.java b/ts3-client/core/src/main/java/com/ts3client/net/Group.java
new file mode 100644
index 0000000..4865653
--- /dev/null
+++ b/ts3-client/core/src/main/java/com/ts3client/net/Group.java
@@ -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;
+ }
+}
diff --git a/ts3-client/core/src/main/java/com/ts3client/net/IconRepository.java b/ts3-client/core/src/main/java/com/ts3client/net/IconRepository.java
new file mode 100644
index 0000000..ad31867
--- /dev/null
+++ b/ts3-client/core/src/main/java/com/ts3client/net/IconRepository.java
@@ -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.
+ *
+ *
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_} in channel 0,
+ * which is downloaded once and then cached in memory and on disk.
+ *
+ * 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_} 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 icons = new ConcurrentHashMap<>();
+ private final Set pending = ConcurrentHashMap.newKeySet();
+ private final Set 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
+ }
+ }
+}
diff --git a/ts3-client/core/src/main/java/com/ts3client/net/ServerModel.java b/ts3-client/core/src/main/java/com/ts3client/net/ServerModel.java
index db1d4ab..ed62de2 100644
--- a/ts3-client/core/src/main/java/com/ts3client/net/ServerModel.java
+++ b/ts3-client/core/src/main/java/com/ts3client/net/ServerModel.java
@@ -16,8 +16,8 @@ public final class ServerModel {
private final Map channels = new LinkedHashMap<>();
private final Map clients = new LinkedHashMap<>();
- private final Map serverGroups = new LinkedHashMap<>();
- private final Map channelGroups = new LinkedHashMap<>();
+ private final Map serverGroups = new LinkedHashMap<>();
+ private final Map 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 serverGroupNames(int[] ids) {
- java.util.List 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 serverGroupsOf(int[] ids) {
+ List 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 serverGroupNames(int[] ids) {
+ List 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() {
diff --git a/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java b/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java
index d0a2b15..9dcefd1 100644
--- a/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java
+++ b/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java
@@ -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);
diff --git a/ts3-client/core/src/main/java/com/ts3client/net/filetransfer/FileTransferManager.java b/ts3-client/core/src/main/java/com/ts3client/net/filetransfer/FileTransferManager.java
index c8d1d43..f2802e8 100644
--- a/ts3-client/core/src/main/java/com/ts3client/net/filetransfer/FileTransferManager.java
+++ b/ts3-client/core/src/main/java/com/ts3client/net/filetransfer/FileTransferManager.java
@@ -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 {
diff --git a/ts3-client/core/src/main/resources/icons/group_100.png b/ts3-client/core/src/main/resources/icons/group_100.png
new file mode 100644
index 0000000..7c948d4
Binary files /dev/null and b/ts3-client/core/src/main/resources/icons/group_100.png differ
diff --git a/ts3-client/core/src/main/resources/icons/group_200.png b/ts3-client/core/src/main/resources/icons/group_200.png
new file mode 100644
index 0000000..4acadb5
Binary files /dev/null and b/ts3-client/core/src/main/resources/icons/group_200.png differ
diff --git a/ts3-client/core/src/main/resources/icons/group_300.png b/ts3-client/core/src/main/resources/icons/group_300.png
new file mode 100644
index 0000000..d048e3e
Binary files /dev/null and b/ts3-client/core/src/main/resources/icons/group_300.png differ
diff --git a/ts3-client/core/src/main/resources/icons/group_500.png b/ts3-client/core/src/main/resources/icons/group_500.png
new file mode 100644
index 0000000..f303433
Binary files /dev/null and b/ts3-client/core/src/main/resources/icons/group_500.png differ
diff --git a/ts3-client/core/src/main/resources/icons/group_600.png b/ts3-client/core/src/main/resources/icons/group_600.png
new file mode 100644
index 0000000..d625f2a
Binary files /dev/null and b/ts3-client/core/src/main/resources/icons/group_600.png differ
diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/GroupIcons.java b/ts3-client/swing/src/main/java/com/ts3client/ui/GroupIcons.java
new file mode 100644
index 0000000..e241b3f
--- /dev/null
+++ b/ts3-client/swing/src/main/java/com/ts3client/ui/GroupIcons.java
@@ -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×16 like TeamSpeak's own icon set. */
+ private static final int SIZE = 16;
+
+ private final IconRepository repository;
+ private final Map 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 iconsOf(List serverGroups, Group channelGroup) {
+ List 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 icons;
+
+ public Row(List 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;
+ }
+ }
+}
diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/InfoPanel.java b/ts3-client/swing/src/main/java/com/ts3client/ui/InfoPanel.java
index bb573fb..0aca914 100644
--- a/ts3-client/swing/src/main/java/com/ts3client/ui/InfoPanel.java
+++ b/ts3-client/swing/src/main/java/com/ts3client/ui/InfoPanel.java
@@ -2,12 +2,15 @@ package com.ts3client.ui;
import com.ts3client.net.ChannelNode;
import com.ts3client.net.ClientEntry;
+import com.ts3client.net.Group;
+import com.ts3client.net.IconRepository;
import com.ts3client.net.ServerModel;
import com.ts3client.text.BBCode;
import javax.swing.BorderFactory;
import javax.swing.JEditorPane;
import javax.swing.JScrollPane;
+import java.io.File;
import java.util.List;
/**
@@ -67,15 +70,27 @@ public final class InfoPanel extends JScrollPane {
setHtml(sb.toString());
}
- public void showClient(ClientEntry cl, ServerModel model) {
+ public void showClient(ClientEntry cl, ServerModel model, IconRepository icons) {
StringBuilder sb = new StringBuilder();
sb.append(heading(esc(cl.nickname) + (cl.self ? " (you)" : "")));
- List serverGroups = model.serverGroupNames(cl.serverGroupIds);
- row(sb, "Server groups", serverGroups.isEmpty() ? "—" : esc(String.join(", ", serverGroups)));
+ List serverGroups = model.serverGroupsOf(cl.serverGroupIds);
+ if (serverGroups.isEmpty()) {
+ List 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);
- row(sb, "Channel group", channelGroup != null ? esc(channelGroup) : "#" + cl.channelGroupId);
+ 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.platform.isEmpty()) row(sb, "Platform", esc(cl.platform));
@@ -97,6 +112,18 @@ public final class InfoPanel extends JScrollPane {
pane.setCaretPosition(0);
}
+ /**
+ * An inline {@code
} 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 "
";
+ }
+
private static String heading(String text) {
return "" + text + "
";
}
diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java b/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java
index 57291d0..827c32d 100644
--- a/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java
+++ b/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java
@@ -314,6 +314,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
if (tabs.size() == 1) return; // always keep one view around
tabs.remove(tab);
+ tab.dispose();
tabPane.removeTab(tab);
if (micTab == tab) micTab = null;
if (selected == tab) {
diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTab.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTab.java
index a46ecaa..9d52bbf 100644
--- a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTab.java
+++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTab.java
@@ -31,6 +31,7 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
private final IdentityStore identities;
private final TeamspeakConnection conn;
+ private final GroupIcons groupIcons;
private final ServerTreePanel treePanel;
private final ChatPanel chatPanel;
private final InfoPanel infoPanel = new InfoPanel();
@@ -56,7 +57,8 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
this.settings = settings;
this.identities = identities;
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();
chatPanel.setSendHandler(this::onSendChat);
@@ -189,6 +191,11 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
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. */
void shutdown() {
if (conn.isConnected()) conn.disconnectBlocking("Leaving");
@@ -361,7 +368,7 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
if (sel instanceof ChannelNode) {
infoPanel.showChannel((ChannelNode) sel);
} else if (sel instanceof ClientEntry) {
- infoPanel.showClient((ClientEntry) sel, conn.getModel());
+ infoPanel.showClient((ClientEntry) sel, conn.getModel(), conn.getIcons());
} else {
infoPanel.clear();
}
@@ -425,6 +432,14 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
SwingUtilities.invokeLater(this::renderInfo);
}
+ @Override
+ public void onIconsUpdated() {
+ SwingUtilities.invokeLater(() -> {
+ treePanel.refreshRowSizes();
+ renderInfo();
+ });
+ }
+
@Override
public void onChat(ChatScope scope, int fromClientId, String fromName, String message) {
switch (scope) {
diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTreePanel.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTreePanel.java
index d1942f7..a54d6b1 100644
--- a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTreePanel.java
+++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTreePanel.java
@@ -6,6 +6,7 @@ import com.ts3client.net.ServerModel;
import com.ts3client.text.TsLink;
import javax.swing.DropMode;
+import javax.swing.Icon;
import javax.swing.ImageIcon;
import javax.swing.JComponent;
import javax.swing.JScrollPane;
@@ -77,11 +78,13 @@ public final class ServerTreePanel extends JScrollPane {
private final DefaultMutableTreeNode root = new DefaultMutableTreeNode();
private final DefaultTreeModel treeModel = new DefaultTreeModel(root);
private final ServerModel model;
+ private final GroupIcons groupIcons;
private final Actions actions;
private int selfClientId = -1;
- public ServerTreePanel(ServerModel model, Actions actions) {
+ public ServerTreePanel(ServerModel model, GroupIcons groupIcons, Actions actions) {
this.model = model;
+ this.groupIcons = groupIcons;
this.actions = actions;
root.setUserObject("Not connected");
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 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
* 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. */
private TreePath highlight;
@@ -385,6 +424,7 @@ public final class ServerTreePanel extends JScrollPane {
@Override
protected void paintComponent(Graphics g) {
super.paintComponent(g);
+ paintBadges(g, this);
JTree.DropLocation loc = getDropLocation();
if (loc == null || loc.getPath() == null) return;
@@ -477,7 +517,26 @@ public final class ServerTreePanel extends JScrollPane {
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 {
+
@Override
public Component getTreeCellRendererComponent(JTree tree, Object value, boolean sel,
boolean expanded, boolean leaf, int row,
@@ -504,9 +563,15 @@ public final class ServerTreePanel extends JScrollPane {
}
} else if (obj instanceof ClientEntry) {
ClientEntry cl = (ClientEntry) obj;
- String primaryGroup = model.primaryServerGroupName(cl.serverGroupIds);
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);
setIcon(iconFor(cl));
setForeground(cl.talking ? Theme.TALKING : Theme.TREE_TEXT);
diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/SettingsDialog.java b/ts3-client/swing/src/main/java/com/ts3client/ui/SettingsDialog.java
index 20b3605..2d66230 100644
--- a/ts3-client/swing/src/main/java/com/ts3client/ui/SettingsDialog.java
+++ b/ts3-client/swing/src/main/java/com/ts3client/ui/SettingsDialog.java
@@ -13,12 +13,14 @@ import javax.swing.ButtonGroup;
import javax.swing.JButton;
import javax.swing.JCheckBox;
import javax.swing.JComboBox;
+import javax.swing.JComponent;
import javax.swing.JDialog;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JRadioButton;
import javax.swing.JSlider;
import javax.swing.JTabbedPane;
+import javax.swing.Scrollable;
import javax.swing.SwingUtilities;
import java.awt.BorderLayout;
import java.awt.Dimension;
@@ -26,6 +28,7 @@ import java.awt.Frame;
import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import java.awt.Insets;
+import java.awt.Rectangle;
import java.awt.event.KeyAdapter;
import java.awt.event.KeyEvent;
import java.util.List;
@@ -37,6 +40,14 @@ import java.util.List;
*/
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 VoiceInput liveMic;
private final VoiceOutput livePlayback;
@@ -114,13 +125,13 @@ public final class SettingsDialog extends JDialog {
pack();
setSize(new Dimension(480, 540));
+ setMinimumSize(new Dimension(420, 360));
setLocationRelativeTo(owner);
startMeter();
}
private JPanel buildDevicesTab() {
- JPanel p = new JPanel(new GridBagLayout());
- p.setBorder(BorderFactory.createEmptyBorder(12, 12, 12, 12));
+ JPanel p = formPanel();
GridBagConstraints c = gbc();
List ins = AudioDevices.inputDevices();
@@ -136,6 +147,8 @@ public final class SettingsDialog extends JDialog {
+ "ALSA: entries talk to the sound card directly, taking it exclusively.