Show clients' country flags beside their names, and optionally in the tree

The server places each client in a country (client_country, from its GeoIP
database); a client's details now show that country's flag left of their
name, as TeamSpeak's info frame does, on both clients. "Show country flags
in the channel tree" (off by default, as TeamSpeak's EnableCountryFlags)
also puts it last on client rows.

The flags are TeamSpeak's countries.zip, which now ships with the icon
packs; core's CountryFlags reads it, else a TeamSpeak 3 install's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 23:40:44 +00:00
parent 4ca5534ef4
commit e799218444
20 changed files with 256 additions and 19 deletions

View File

@@ -39,7 +39,7 @@
<resource>
<directory>src/main/resources</directory>
</resource>
<!-- TeamSpeak's own icon packs, proprietary and so taken from an install, not the repository. -->
<!-- TeamSpeak's own icon packs and country flags, proprietary and so taken from an install, not the repository. -->
<resource>
<directory>${ts3.client.dir}/gfx</directory>
<targetPath>gfx/teamspeak</targetPath>
@@ -47,6 +47,7 @@
<include>default.zip</include>
<include>default_colored_2014.zip</include>
<include>default_mono_2014.zip</include>
<include>countries.zip</include>
</includes>
</resource>
</resources>

View File

@@ -156,6 +156,11 @@ public final class Settings {
// ---- icons ----
/** File name of the active icon pack; empty uses the built-in icons. */
public String iconPack = "default_colored_2014.zip";
/**
* Whether the channel tree shows each client's country flag, as TeamSpeak's EnableCountryFlags;
* their details show it beside their name either way.
*/
public boolean countryFlags = false;
/** Extra folder to look for icon packs in, on top of the well-known locations. */
public String iconPackDir = "";
@@ -270,6 +275,7 @@ public final class Settings {
soundPackDir = props.getProperty("soundPackDir", soundPackDir);
desktopNotifications = parseB(props.getProperty("desktopNotifications"), desktopNotifications);
iconPack = props.getProperty("iconPack", iconPack);
countryFlags = parseB(props.getProperty("countryFlags"), countryFlags);
iconPackDir = props.getProperty("iconPackDir", iconPackDir);
appearance = parseEnum(Appearance.class, props.getProperty("appearance"), appearance);
uiScale = parseD(props.getProperty("uiScale"), uiScale);
@@ -325,6 +331,7 @@ public final class Settings {
props.setProperty("soundPackDir", soundPackDir);
props.setProperty("desktopNotifications", Boolean.toString(desktopNotifications));
props.setProperty("iconPack", iconPack);
props.setProperty("countryFlags", Boolean.toString(countryFlags));
props.setProperty("iconPackDir", iconPackDir);
props.setProperty("appearance", appearance.name());
props.setProperty("uiScale", Double.toString(uiScale));

View File

@@ -0,0 +1,86 @@
package com.ts3client.gfx;
import com.ts3client.config.InstallDirs;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.HashMap;
import java.util.IllformedLocaleException;
import java.util.Locale;
import java.util.Map;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
/**
* The flags TeamSpeak shows for the country the server places a client in: the
* {@code <code>.png} files of its {@code countries.zip}, the one the build shipped, else
* a TeamSpeak 3 install's.
*/
public final class CountryFlags {
private static final String RESOURCE = "/gfx/teamspeak/countries.zip";
private static final String FILE = "countries.zip";
private static volatile Map<String, byte[]> flags;
private CountryFlags() {
}
/** @return the flag's PNG bytes, or {@code null} for an unknown or empty code */
public static byte[] png(String code) {
if (code == null || code.isEmpty()) return null;
return flags().get(code.toLowerCase(Locale.ROOT));
}
/** @return the country's English name, or the code itself when it names none */
public static String name(String code) {
try {
String name = new Locale.Builder().setRegion(code).build().getDisplayCountry(Locale.ENGLISH);
return name.isEmpty() ? code : name;
} catch (IllformedLocaleException e) {
return code;
}
}
private static Map<String, byte[]> flags() {
Map<String, byte[]> loaded = flags;
if (loaded != null) return loaded;
synchronized (CountryFlags.class) {
if (flags == null) flags = load();
return flags;
}
}
private static Map<String, byte[]> load() {
try (InputStream in = CountryFlags.class.getResourceAsStream(RESOURCE)) {
if (in != null) return read(in);
} catch (IOException ignored) {
// try an install's
}
for (File root : InstallDirs.searchRoots("gfx", null, null)) {
File zip = new File(root, FILE);
if (!zip.isFile()) continue;
try (InputStream in = new FileInputStream(zip)) {
return read(in);
} catch (IOException ignored) {
// try the next
}
}
return Map.of();
}
private static Map<String, byte[]> read(InputStream in) throws IOException {
Map<String, byte[]> out = new HashMap<>();
try (ZipInputStream zip = new ZipInputStream(in)) {
for (ZipEntry e; (e = zip.getNextEntry()) != null; ) {
String name = e.getName().toLowerCase(Locale.ROOT);
if (!e.isDirectory() && name.endsWith(".png")) {
out.put(name.substring(name.lastIndexOf('/') + 1, name.length() - 4), zip.readAllBytes());
}
}
}
return out;
}
}

View File

@@ -23,6 +23,8 @@ public final class ClientEntry {
public volatile String description = "";
/** MD5 of the client's avatar ({@code client_flag_avatar}); empty when they have none. */
public volatile String avatarFlag = "";
/** Where the server places the client ({@code client_country}), as an ISO code; empty when it cannot tell. */
public volatile String country = "";
public volatile boolean talking;
public volatile boolean inputMuted; // microphone muted (client_input_muted)

View File

@@ -46,6 +46,7 @@ final class ConnectionEventHandler implements TS3Listener {
c.awayMessage = TeamspeakConnection.orEmpty(e.get("client_away_message"));
c.uniqueId = TeamspeakConnection.orEmpty(e.getUniqueClientIdentifier());
c.avatarFlag = TeamspeakConnection.orEmpty(e.get("client_flag_avatar"));
c.country = TeamspeakConnection.orEmpty(e.get("client_country"));
c.databaseId = e.getClientDatabaseId();
c.serverGroupIds = parseIntList(e.getClientServerGroups());
c.channelGroupId = e.getClientChannelGroupId();
@@ -338,6 +339,7 @@ final class ConnectionEventHandler implements TS3Listener {
if (has(e, "client_is_channel_commander"))
c.channelCommander = e.getBoolean("client_is_channel_commander");
if (reported(e, "client_flag_avatar")) c.avatarFlag = TeamspeakConnection.orEmpty(e.get("client_flag_avatar"));
if (reported(e, "client_country")) c.country = TeamspeakConnection.orEmpty(e.get("client_country"));
announceClientUpdate(e, c, renamed, oldName);
conn.ui.onModelChanged();
}

View File

@@ -0,0 +1,22 @@
package com.ts3client.gfx;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
class CountryFlagsTest {
@Test
void namesCountriesInEnglishAndLeavesOddCodesAsTheyAre() {
assertEquals("Slovakia", CountryFlags.name("SK"));
assertEquals("Germany", CountryFlags.name("de"));
assertEquals("not a code", CountryFlags.name("not a code"));
}
@Test
void hasNoFlagForNoCountry() {
assertNull(CountryFlags.png(""));
assertNull(CountryFlags.png(null));
}
}

View File

@@ -0,0 +1,40 @@
package com.ts3client.ui;
import com.formdev.flatlaf.util.UIScale;
import com.ts3client.gfx.CountryFlags;
import javax.swing.ImageIcon;
import java.awt.Image;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
/** TeamSpeak's country flags as Swing icons, at their own 16&times;11 scaled with the interface. */
final class CountryFlagIcons {
private static final int WIDTH = 16;
private static final int HEIGHT = 11;
private static final Map<String, Optional<ImageIcon>> ICONS = new ConcurrentHashMap<>();
private CountryFlagIcons() {
}
/** @return the flag, or {@code null} for an unknown or empty code */
static ImageIcon of(String code) {
if (code == null || code.isEmpty()) return null;
return ICONS.computeIfAbsent(code, CountryFlagIcons::load).orElse(null);
}
private static Optional<ImageIcon> load(String code) {
byte[] png = CountryFlags.png(code);
if (png == null) return Optional.empty();
ImageIcon icon = new ImageIcon(png);
if (icon.getIconWidth() <= 0) return Optional.empty();
int width = UIScale.scale(WIDTH);
int height = UIScale.scale(HEIGHT);
if (icon.getIconWidth() != width || icon.getIconHeight() != height) {
icon = new ImageIcon(icon.getImage().getScaledInstance(width, height, Image.SCALE_SMOOTH));
}
return Optional.of(icon);
}
}

View File

@@ -10,6 +10,7 @@ import javax.swing.DefaultListCellRenderer;
import javax.swing.DefaultListModel;
import javax.swing.ImageIcon;
import javax.swing.JButton;
import javax.swing.JCheckBox;
import javax.swing.JComboBox;
import javax.swing.JComponent;
import javax.swing.JFileChooser;
@@ -59,6 +60,7 @@ final class DesignPanel extends JPanel {
private final JComboBox<Object> packCombo = new JComboBox<>();
private final JLabel packInfo = new JLabel();
private final JTextField packDirField;
private final JCheckBox countryFlags = new JCheckBox("Show country flags in the channel tree");
private final DefaultListModel<String> previewModel = new DefaultListModel<>();
private final JList<String> preview = new JList<>(previewModel);
@@ -84,6 +86,7 @@ final class DesignPanel extends JPanel {
settings.iconPackDir = packDirField.getText().trim();
Object selected = packCombo.getSelectedItem();
settings.iconPack = selected instanceof IconPack pack ? pack.id() : "";
settings.countryFlags = countryFlags.isSelected();
}
/** Puts back the appearance and pack the dialog started with, for a cancelled edit. */
@@ -149,7 +152,12 @@ final class DesignPanel extends JPanel {
dirRow.add(packDirField, BorderLayout.CENTER);
dirRow.add(browse, BorderLayout.EAST);
packDirField.setToolTipText("Extra folder to look for icon packs in");
addRow(p, c, row, new JLabel("Extra pack folder:"), dirRow);
addRow(p, c, row++, new JLabel("Extra pack folder:"), dirRow);
countryFlags.setSelected(settings.countryFlags);
c.gridx = 1;
c.gridy = row;
p.add(countryFlags, c);
return p;
}

View File

@@ -1,6 +1,7 @@
package com.ts3client.ui;
import com.formdev.flatlaf.util.UIScale;
import com.ts3client.config.Settings;
import com.ts3client.net.ChannelNode;
import com.ts3client.net.Spacers;
import com.ts3client.net.ClientEntry;
@@ -40,6 +41,7 @@ final class DropIndicatorTree extends JTree {
private final ServerModel model;
private final GroupIcons groupIcons;
private final Settings settings;
/** Set while a drop would move a client into this channel row. */
private TreePath highlight;
/** The row the pointer is over, or -1 when it is over none. */
@@ -48,10 +50,11 @@ final class DropIndicatorTree extends JTree {
private Set<Object> searchHits = Collections.emptySet();
private Predicate<TreePath> pathEditable = path -> false;
DropIndicatorTree(TreeModel treeModel, ServerModel model, GroupIcons groupIcons) {
DropIndicatorTree(TreeModel treeModel, ServerModel model, GroupIcons groupIcons, Settings settings) {
super(treeModel);
this.model = model;
this.groupIcons = groupIcons;
this.settings = settings;
MouseAdapter hover = new MouseAdapter() {
@Override
public void mouseMoved(MouseEvent e) {
@@ -299,8 +302,12 @@ final class DropIndicatorTree extends JTree {
private List<Icon> badgesOf(Object node) {
if (node instanceof ClientEntry) {
ClientEntry cl = (ClientEntry) node;
return groupIcons.iconsOf(
List<Icon> icons = groupIcons.iconsOf(
model.serverGroupsOf(cl.serverGroupIds), model.channelGroup(cl.channelGroupId));
// The country's flag comes last, at the row's edge, as in TeamSpeak.
Icon flag = settings.countryFlags ? CountryFlagIcons.of(cl.country) : null;
if (flag != null) icons.add(flag);
return icons;
}
if (node instanceof ChannelNode) {
ChannelNode ch = (ChannelNode) node;

View File

@@ -11,8 +11,8 @@ import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* Icons of the active pack as PNG files, for HTML views, which only show pictures by URL.
* A pack change gives the theme new icons, and so new files.
* Icons of the active pack, and country flags, as PNG files, for HTML views, which only
* show pictures by URL. A pack change gives the theme new icons, and so new files.
*/
final class IconFiles {
@@ -31,6 +31,14 @@ final class IconFiles {
return file.isFile() ? file : null;
}
/** @return the country's flag as drawn in the tree, or {@code null} for none */
static File flag(String code) {
ImageIcon icon = CountryFlagIcons.of(code);
if (icon == null) return null;
File file = FILES.computeIfAbsent("flag_" + code + '_' + icon.getIconWidth() + ".png", n -> write(icon, n));
return file.isFile() ? file : null;
}
private static File write(ImageIcon icon, String name) {
try {
File file = new File(dir(), name);

View File

@@ -241,8 +241,8 @@ public final class InfoPanel extends JPanel {
private String body() {
showsPictures = false;
if (shownServer != null) return html(Details.server(shownModel, shownServer), "", null, null);
if (shownChannel != null) return html(Details.channel(shownChannel), "", null, "No description.");
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);
@@ -278,17 +278,19 @@ public final class InfoPanel extends JPanel {
private 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);
// The country's flag goes before the name, as in TeamSpeak's info frame.
return html(Details.client(cl, model, contact), flagTag(cl.country), you, icons, null);
}
/**
* @param titlePrefix HTML before the title
* @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 String html(Details details, String titleSuffix, IconRepository icons, String none) {
private String html(Details details, String titlePrefix, String titleSuffix, IconRepository icons, String none) {
StringBuilder sb = new StringBuilder();
sb.append(heading(esc(details.title()) + titleSuffix));
sb.append(heading(titlePrefix + 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(description(details.description())).append("</div>");
@@ -327,6 +329,13 @@ public final class InfoPanel extends JPanel {
return "<img src='" + file.toURI() + "' width='" + UIScale.scale(14) + "' height='" + UIScale.scale(14) + "'> ";
}
/** An inline {@code <img>} for a country's flag, or nothing for no country. */
private static String flagTag(String country) {
File file = IconFiles.flag(country);
if (file == null) return "";
return "<img src='" + file.toURI() + "' width='" + UIScale.scale(16) + "' height='" + UIScale.scale(11) + "'>&nbsp;";
}
private static String heading(String text) {
return "<div style='font-weight:bold;font-size:13px;margin-bottom:" + HtmlStyles.px(4) + "'>" + text + "</div>";
}

View File

@@ -184,7 +184,7 @@ public final class ServerTreePanel extends JPanel {
this.groupIcons = groupIcons;
this.actions = actions;
root.setUserObject("Not connected");
this.tree = new DropIndicatorTree(treeModel, model, groupIcons);
this.tree = new DropIndicatorTree(treeModel, model, groupIcons, settings);
tree.setRootVisible(true);
// The server node is the only top-level row and always stays open, so it gets
// no expand control. Nesting is tightened too: horizontal space in this view