From e79921844464462d3af3297a24ec1398c39d02e9 Mon Sep 17 00:00:00 2001 From: ericek111 Date: Wed, 30 Sep 2026 23:40:44 +0000 Subject: [PATCH] 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 --- .../com/ts3client/android/ServerController.kt | 3 +- .../java/com/ts3client/android/ServerTree.kt | 9 +- .../ts3client/android/SessionController.kt | 6 ++ .../com/ts3client/android/ui/ChannelTree.kt | 6 +- .../com/ts3client/android/ui/ServerImage.kt | 20 +++++ .../ts3client/android/ui/SettingsScreen.kt | 5 ++ .../java/com/ts3client/android/ui/Sheets.kt | 6 +- android/core/build.gradle.kts | 4 +- ts3-client/core/pom.xml | 3 +- .../java/com/ts3client/config/Settings.java | 7 ++ .../java/com/ts3client/gfx/CountryFlags.java | 86 +++++++++++++++++++ .../java/com/ts3client/net/ClientEntry.java | 2 + .../ts3client/net/ConnectionEventHandler.java | 2 + .../com/ts3client/gfx/CountryFlagsTest.java | 22 +++++ .../com/ts3client/ui/CountryFlagIcons.java | 40 +++++++++ .../java/com/ts3client/ui/DesignPanel.java | 10 ++- .../com/ts3client/ui/DropIndicatorTree.java | 11 ++- .../main/java/com/ts3client/ui/IconFiles.java | 12 ++- .../main/java/com/ts3client/ui/InfoPanel.java | 19 ++-- .../com/ts3client/ui/ServerTreePanel.java | 2 +- 20 files changed, 256 insertions(+), 19 deletions(-) create mode 100644 ts3-client/core/src/main/java/com/ts3client/gfx/CountryFlags.java create mode 100644 ts3-client/core/src/test/java/com/ts3client/gfx/CountryFlagsTest.java create mode 100644 ts3-client/swing/src/main/java/com/ts3client/ui/CountryFlagIcons.java diff --git a/android/app/src/main/java/com/ts3client/android/ServerController.kt b/android/app/src/main/java/com/ts3client/android/ServerController.kt index 5c3b31b..11589f4 100644 --- a/android/app/src/main/java/com/ts3client/android/ServerController.kt +++ b/android/app/src/main/java/com/ts3client/android/ServerController.kt @@ -283,7 +283,8 @@ class ServerController internal constructor( delay(TREE_FRAME_MS) treePending.set(false) if (structureChanged.getAndSet(false)) roots = conn.model.buildTree() - _tree.value = ServerTree.of(roots, conn.model, conn.contacts, app.settings.colorContacts) + _tree.value = ServerTree.of(roots, conn.model, conn.contacts, app.settings.colorContacts, + app.settings.countryFlags) val serverName = if (session.isConnected) conn.model.serverName.orEmpty() else "" val channelName = session.currentChannel()?.name.orEmpty() _chats.update { diff --git a/android/app/src/main/java/com/ts3client/android/ServerTree.kt b/android/app/src/main/java/com/ts3client/android/ServerTree.kt index 551bb3f..abd67e7 100644 --- a/android/app/src/main/java/com/ts3client/android/ServerTree.kt +++ b/android/app/src/main/java/com/ts3client/android/ServerTree.kt @@ -18,6 +18,8 @@ data class ServerTree( val channels: List = emptyList(), /** The server's own icon; 0 for none. */ val serverIconId: Long = 0, + /** Whether client rows show their country's flag. */ + val countryFlags: Boolean = false, ) { fun channel(id: Int): ChannelItem? = find(channels) { it.id == id } @@ -37,10 +39,12 @@ data class ServerTree( } companion object { - fun of(roots: List, model: ServerModel, contacts: ContactStore, colorContacts: Boolean): ServerTree = + fun of(roots: List, model: ServerModel, contacts: ContactStore, colorContacts: Boolean, + countryFlags: Boolean): ServerTree = ServerTree( roots.map { channel(it, model, contacts, colorContacts) }, model.serverProperty("virtualserver_icon_id").toLongOrNull() ?: 0, + countryFlags, ) private fun channel(c: ChannelNode, model: ServerModel, contacts: ContactStore, colorContacts: Boolean): ChannelItem { @@ -75,6 +79,7 @@ data class ServerTree( groupIconIds = (model.serverGroupsOf(c.serverGroupIds) + listOfNotNull(model.channelGroup(c.channelGroupId))) .map { it.iconId }.filter { it != 0L }, avatarFlag = c.avatarFlag.orEmpty(), + country = c.country.orEmpty(), ) } } @@ -113,4 +118,6 @@ data class ClientItem( val groupIconIds: List, /** The MD5 of the client's avatar; empty for none. */ val avatarFlag: String, + /** The code of the country the server places the client in; empty when it cannot tell. */ + val country: String, ) diff --git a/android/app/src/main/java/com/ts3client/android/SessionController.kt b/android/app/src/main/java/com/ts3client/android/SessionController.kt index 37eb057..6cf6ffe 100644 --- a/android/app/src/main/java/com/ts3client/android/SessionController.kt +++ b/android/app/src/main/java/com/ts3client/android/SessionController.kt @@ -319,6 +319,12 @@ class SessionController(private val context: Context) { val soundPack: SoundPack? get() = sounds.pack() + fun setCountryFlags(shown: Boolean) { + settings.countryFlags = shown + settings.save() + servers.value.forEach { it.scheduleTree(structure = false) } + } + fun setDescriptionPictures(shown: Boolean) { settings.descriptionPictures = shown settings.save() diff --git a/android/app/src/main/java/com/ts3client/android/ui/ChannelTree.kt b/android/app/src/main/java/com/ts3client/android/ui/ChannelTree.kt index 001a2ef..fad4a51 100644 --- a/android/app/src/main/java/com/ts3client/android/ui/ChannelTree.kt +++ b/android/app/src/main/java/com/ts3client/android/ui/ChannelTree.kt @@ -93,7 +93,7 @@ fun ChannelTree( items(rows, key = { it.key }) { row -> when (row) { is TreeRow.Channel -> ChannelRow(row, onToggle, onChannel) - is TreeRow.Client -> ClientRow(row, onClient) + is TreeRow.Client -> ClientRow(row, tree.countryFlags, onClient) } } } @@ -170,7 +170,7 @@ private fun SpacerLabel(align: Char, caption: String, modifier: Modifier) { } @Composable -private fun ClientRow(row: TreeRow.Client, onClient: (ClientItem) -> Unit) { +private fun ClientRow(row: TreeRow.Client, countryFlags: Boolean, onClient: (ClientItem) -> Unit) { val client = row.client RowLayout(row.depth, onClick = { onClient(client) }) { Spacer(Modifier.width(ICON + 4.dp)) @@ -193,6 +193,8 @@ private fun ClientRow(row: TreeRow.Client, onClient: (ClientItem) -> Unit) { } } for (id in client.groupIconIds) ServerIcon(id, size = ICON) + // The country's flag comes last, at the row's edge. + if (countryFlags) CountryFlag(client.country) } } diff --git a/android/app/src/main/java/com/ts3client/android/ui/ServerImage.kt b/android/app/src/main/java/com/ts3client/android/ui/ServerImage.kt index 8563a9c..83dd208 100644 --- a/android/app/src/main/java/com/ts3client/android/ui/ServerImage.kt +++ b/android/app/src/main/java/com/ts3client/android/ui/ServerImage.kt @@ -1,5 +1,6 @@ package com.ts3client.android.ui +import android.graphics.BitmapFactory import android.graphics.drawable.AnimatedImageDrawable import android.graphics.drawable.Drawable import android.os.Handler @@ -18,6 +19,7 @@ import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.drawIntoCanvas @@ -31,7 +33,10 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.ts3client.android.Picture import com.ts3client.android.ServerImages +import com.ts3client.gfx.CountryFlags import com.ts3client.net.IconRepository +import java.util.Optional +import java.util.concurrent.ConcurrentHashMap import kotlin.math.roundToInt /** The images of the server on screen. */ @@ -118,3 +123,18 @@ private class MovingPainter(private val drawable: AnimatedImageDrawable) : Paint main.removeCallbacks(what) } } + +private val flags = ConcurrentHashMap>() + +/** The flag of a country, at TeamSpeak's 16×11; nothing for no country or one without a flag. */ +@Composable +fun CountryFlag(code: String, modifier: Modifier = Modifier) { + if (code.isEmpty()) return + val bitmap = remember(code) { + flags.getOrPut(code) { + Optional.ofNullable(CountryFlags.png(code)?.let { BitmapFactory.decodeByteArray(it, 0, it.size)?.asImageBitmap() }) + }.orElse(null) + } ?: return + Image(bitmap, CountryFlags.name(code), modifier.size(16.dp, 11.dp)) +} + diff --git a/android/app/src/main/java/com/ts3client/android/ui/SettingsScreen.kt b/android/app/src/main/java/com/ts3client/android/ui/SettingsScreen.kt index 667e206..6bac6fa 100644 --- a/android/app/src/main/java/com/ts3client/android/ui/SettingsScreen.kt +++ b/android/app/src/main/java/com/ts3client/android/ui/SettingsScreen.kt @@ -50,6 +50,11 @@ fun SettingsScreen(vm: SessionController, onBack: () -> Unit) { "MYTS_ACCOUNT") { page = SettingsPage.MY_TEAMSPEAK } Section("Appearance") IconPackPicker(vm.icons) + var flags by remember { mutableStateOf(vm.settings.countryFlags) } + Toggle("Show country flags in the channel tree", flags) { + flags = it + vm.setCountryFlags(it) + } var pictures by remember { mutableStateOf(vm.settings.descriptionPictures) } Toggle("Show pictures in descriptions", pictures) { pictures = it diff --git a/android/app/src/main/java/com/ts3client/android/ui/Sheets.kt b/android/app/src/main/java/com/ts3client/android/ui/Sheets.kt index 0d2c0c9..d2ff9b9 100644 --- a/android/app/src/main/java/com/ts3client/android/ui/Sheets.kt +++ b/android/app/src/main/java/com/ts3client/android/ui/Sheets.kt @@ -66,7 +66,11 @@ fun ClientSheet( horizontalArrangement = Arrangement.spacedBy(12.dp)) { StatusIcon(client.status) Column(Modifier.weight(1f)) { - Text(client.name, style = MaterialTheme.typography.titleLarge) + // The country's flag goes before the name, as in TeamSpeak's info frame. + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + CountryFlag(client.country) + Text(client.name, style = MaterialTheme.typography.titleLarge) + } if (client.name != client.nickname) Text(client.nickname, style = MaterialTheme.typography.bodyMedium) if (client.awayMessage.isNotEmpty()) Text(client.awayMessage, style = MaterialTheme.typography.bodyMedium) } diff --git a/android/core/build.gradle.kts b/android/core/build.gradle.kts index 9831dc5..42a3ceb 100644 --- a/android/core/build.gradle.kts +++ b/android/core/build.gradle.kts @@ -6,11 +6,11 @@ val ts3ClientDir: String = providers.gradleProperty("ts3.client.dir") .orElse(providers.environmentVariable("TS3_CLIENT_DIR")) .getOrElse(rootDir.resolve("../TeamSpeak3-Client-linux_amd64").path) -/** TeamSpeak's own icon packs, proprietary and so taken from an install, as the desktop's build does. */ +/** TeamSpeak's own icon packs and country flags, proprietary and so taken from an install, as the desktop's build does. */ val teamspeakIconPacks = tasks.register("teamspeakIconPacks") { into(layout.buildDirectory.dir("generated/iconPacks")) from(File(ts3ClientDir, "gfx")) { - include("default.zip", "default_colored_2014.zip", "default_mono_2014.zip") + include("default.zip", "default_colored_2014.zip", "default_mono_2014.zip", "countries.zip") into("gfx/teamspeak") } } diff --git a/ts3-client/core/pom.xml b/ts3-client/core/pom.xml index 1988905..b25aecc 100644 --- a/ts3-client/core/pom.xml +++ b/ts3-client/core/pom.xml @@ -39,7 +39,7 @@ src/main/resources - + ${ts3.client.dir}/gfx gfx/teamspeak @@ -47,6 +47,7 @@ default.zip default_colored_2014.zip default_mono_2014.zip + countries.zip 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 3d11d3a..886deea 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 @@ -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)); diff --git a/ts3-client/core/src/main/java/com/ts3client/gfx/CountryFlags.java b/ts3-client/core/src/main/java/com/ts3client/gfx/CountryFlags.java new file mode 100644 index 0000000..5fc897b --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/gfx/CountryFlags.java @@ -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 .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 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 flags() { + Map loaded = flags; + if (loaded != null) return loaded; + synchronized (CountryFlags.class) { + if (flags == null) flags = load(); + return flags; + } + } + + private static Map 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 read(InputStream in) throws IOException { + Map 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; + } +} diff --git a/ts3-client/core/src/main/java/com/ts3client/net/ClientEntry.java b/ts3-client/core/src/main/java/com/ts3client/net/ClientEntry.java index 20502ac..d97cd2e 100644 --- a/ts3-client/core/src/main/java/com/ts3client/net/ClientEntry.java +++ b/ts3-client/core/src/main/java/com/ts3client/net/ClientEntry.java @@ -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) diff --git a/ts3-client/core/src/main/java/com/ts3client/net/ConnectionEventHandler.java b/ts3-client/core/src/main/java/com/ts3client/net/ConnectionEventHandler.java index 3a2ef59..85cd3d0 100644 --- a/ts3-client/core/src/main/java/com/ts3client/net/ConnectionEventHandler.java +++ b/ts3-client/core/src/main/java/com/ts3client/net/ConnectionEventHandler.java @@ -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(); } diff --git a/ts3-client/core/src/test/java/com/ts3client/gfx/CountryFlagsTest.java b/ts3-client/core/src/test/java/com/ts3client/gfx/CountryFlagsTest.java new file mode 100644 index 0000000..d00ddc4 --- /dev/null +++ b/ts3-client/core/src/test/java/com/ts3client/gfx/CountryFlagsTest.java @@ -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)); + } +} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/CountryFlagIcons.java b/ts3-client/swing/src/main/java/com/ts3client/ui/CountryFlagIcons.java new file mode 100644 index 0000000..c111a94 --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/CountryFlagIcons.java @@ -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×11 scaled with the interface. */ +final class CountryFlagIcons { + + private static final int WIDTH = 16; + private static final int HEIGHT = 11; + private static final Map> 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 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); + } +} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/DesignPanel.java b/ts3-client/swing/src/main/java/com/ts3client/ui/DesignPanel.java index 338be66..28094b7 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/DesignPanel.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/DesignPanel.java @@ -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 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 previewModel = new DefaultListModel<>(); private final JList 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; } diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/DropIndicatorTree.java b/ts3-client/swing/src/main/java/com/ts3client/ui/DropIndicatorTree.java index 8b717d9..e2bd6bd 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/DropIndicatorTree.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/DropIndicatorTree.java @@ -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 searchHits = Collections.emptySet(); private Predicate 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 badgesOf(Object node) { if (node instanceof ClientEntry) { ClientEntry cl = (ClientEntry) node; - return groupIcons.iconsOf( + List 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; diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/IconFiles.java b/ts3-client/swing/src/main/java/com/ts3client/ui/IconFiles.java index a7caf2b..7c24bec 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/IconFiles.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/IconFiles.java @@ -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); 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 fa5ec35..c8fee1e 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 @@ -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 ? " (you)" : ""; - 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("
").append(description(details.description())).append("
"); @@ -327,6 +329,13 @@ public final class InfoPanel extends JPanel { return " "; } + /** An inline {@code } 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 " "; + } + private static String heading(String text) { return "
" + text + "
"; } 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 b68c2e3..e681c29 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 @@ -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