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

@@ -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));
}
}