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