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

@@ -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 {

View File

@@ -18,6 +18,8 @@ data class ServerTree(
val channels: List<ChannelItem> = 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<ChannelNode>, model: ServerModel, contacts: ContactStore, colorContacts: Boolean): ServerTree =
fun of(roots: List<ChannelNode>, 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<Long>,
/** 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,
)

View File

@@ -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()

View File

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

View File

@@ -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<String, Optional<ImageBitmap>>()
/** 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))
}

View File

@@ -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

View File

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