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:
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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<Sync>("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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user