Show server, channel and group icons and client avatars on Android

The tree lines icons up at the right of each row as TeamSpeak does: the
server's own icon, a channel's icon, and a client's server group icons then
their channel group's. The details list each group with its icon, and the
client sheet shows the avatar; an animated GIF plays while it is on screen.

TeamSpeak's default group icons come from the icon pack, as on the desktop;
a server's own uploads are downloaded by core's repositories and decoded
once per server, redrawn as they arrive.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 22:34:52 +00:00
parent b678805c37
commit fd86d9e638
8 changed files with 288 additions and 33 deletions

View File

@@ -72,6 +72,8 @@ class ServerController internal constructor(
private val _infoUpdates = MutableStateFlow(0)
val infoUpdates: StateFlow<Int> = _infoUpdates
val images = ServerImages(conn)
/** A private chat a notification asked to show, until the UI took it up. */
private val _chatRequest = MutableStateFlow<ChatKey.Private?>(null)
val chatRequest: StateFlow<ChatKey.Private?> = _chatRequest
@@ -281,7 +283,7 @@ 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.contacts, app.settings.colorContacts)
_tree.value = ServerTree.of(roots, conn.model, conn.contacts, app.settings.colorContacts)
val serverName = if (session.isConnected) conn.model.serverName.orEmpty() else ""
val channelName = session.currentChannel()?.name.orEmpty()
_chats.update {
@@ -323,8 +325,13 @@ class ServerController internal constructor(
}
override fun onModelChanged() = scheduleTree(structure = true)
override fun onInfoUpdated() = _infoUpdates.update { it + 1 }
override fun onIconsUpdated() {}
override fun onInfoUpdated() {
_infoUpdates.update { it + 1 }
// Avatars announce their arrival as info updates.
images.loaded()
}
override fun onIconsUpdated() = images.loaded()
override fun onChat(scope: ChatScope, fromClientId: Int, fromUniqueId: String?, fromName: String, message: String) {
append(key(scope, fromClientId), fromName, ChatHtml.message(fromClientId, fromUniqueId, fromName, message))

View File

@@ -0,0 +1,86 @@
package com.ts3client.android
import android.graphics.BitmapFactory
import android.graphics.ImageDecoder
import android.graphics.drawable.AnimatedImageDrawable
import android.graphics.drawable.BitmapDrawable
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import com.ts3client.net.TeamspeakConnection
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import java.nio.ByteBuffer
import java.util.concurrent.ConcurrentHashMap
/**
* One server's icons and its clients' avatars, decoded once. What is still
* downloading reads as null until [version] moves on.
*/
class ServerImages internal constructor(private val conn: TeamspeakConnection) {
private val icons = ConcurrentHashMap<Long, ImageBitmap>()
private val avatars = ConcurrentHashMap<String, AvatarImage>()
private val _version = MutableStateFlow(0)
val version: StateFlow<Int> = _version
internal fun loaded() = _version.update { it + 1 }
fun icon(id: Long): ImageBitmap? {
if (id == 0L) return null
icons[id]?.let { return it }
val bitmap = conn.icons.get(id)?.let { decode(it, ICON_PX) } ?: return null
icons[id] = bitmap
return bitmap
}
fun avatar(uniqueId: String, flag: String): AvatarImage? {
if (flag.isEmpty()) return null
avatars[flag]?.let { return it }
val avatar = conn.avatars.get(uniqueId, flag) ?: return null
val image = if (avatar.isAnimated) decodeAnimated(avatar.data()) else decode(avatar.data(), AVATAR_PX)?.let(AvatarImage::Still)
if (image != null) avatars[flag] = image
return image
}
/** A GIF as it moves; one of a single frame stays still. */
private fun decodeAnimated(data: ByteArray): AvatarImage? = try {
val drawable = ImageDecoder.decodeDrawable(ImageDecoder.createSource(ByteBuffer.wrap(data))) { decoder, info, _ ->
val largest = maxOf(info.size.width, info.size.height)
if (largest > AVATAR_PX) {
decoder.setTargetSize(info.size.width * AVATAR_PX / largest, info.size.height * AVATAR_PX / largest)
}
}
when (drawable) {
is AnimatedImageDrawable -> AvatarImage.Moving(drawable)
is BitmapDrawable -> AvatarImage.Still(drawable.bitmap.asImageBitmap())
else -> null
}
} catch (e: Exception) {
decode(data, AVATAR_PX)?.let(AvatarImage::Still)
}
/** Decoded no larger than needed: avatars may be far bigger than they are ever shown. */
private fun decode(data: ByteArray, maxPx: Int): ImageBitmap? {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeByteArray(data, 0, data.size, bounds)
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
var sample = 1
while (maxOf(bounds.outWidth, bounds.outHeight) / (sample * 2) >= maxPx) sample *= 2
val options = BitmapFactory.Options().apply { inSampleSize = sample }
return BitmapFactory.decodeByteArray(data, 0, data.size, options)?.asImageBitmap()
}
private companion object {
const val ICON_PX = 64
const val AVATAR_PX = 512
}
}
sealed interface AvatarImage {
data class Still(val bitmap: ImageBitmap) : AvatarImage
/** Shared by whatever shows it: one animation, drawn wherever it is on screen. */
data class Moving(val drawable: AnimatedImageDrawable) : AvatarImage
}

View File

@@ -6,6 +6,7 @@ import com.ts3client.contacts.ContactType
import com.ts3client.net.ChannelNode
import com.ts3client.net.ClientEntry
import com.ts3client.net.ClientStatus
import com.ts3client.net.ServerModel
import com.ts3client.net.Spacers
/**
@@ -13,7 +14,11 @@ import com.ts3client.net.Spacers
* on connection threads, which Compose cannot observe; these never change.
*/
@Immutable
data class ServerTree(val channels: List<ChannelItem> = emptyList()) {
data class ServerTree(
val channels: List<ChannelItem> = emptyList(),
/** The server's own icon; 0 for none. */
val serverIconId: Long = 0,
) {
fun channel(id: Int): ChannelItem? = find(channels) { it.id == id }
@@ -32,24 +37,29 @@ data class ServerTree(val channels: List<ChannelItem> = emptyList()) {
}
companion object {
fun of(roots: List<ChannelNode>, contacts: ContactStore, colorContacts: Boolean): ServerTree =
ServerTree(roots.map { channel(it, contacts, colorContacts) })
fun of(roots: List<ChannelNode>, model: ServerModel, contacts: ContactStore, colorContacts: Boolean): ServerTree =
ServerTree(
roots.map { channel(it, model, contacts, colorContacts) },
model.serverProperty("virtualserver_icon_id").toLongOrNull() ?: 0,
)
private fun channel(c: ChannelNode, contacts: ContactStore, colorContacts: Boolean): ChannelItem {
private fun channel(c: ChannelNode, model: ServerModel, contacts: ContactStore, colorContacts: Boolean): ChannelItem {
val clients = c.clients
val spacer = Spacers.parse(c.name)
return ChannelItem(
id = c.id,
name = c.name ?: "",
spacer = Spacers.parse(c.name),
spacer = spacer,
iconId = if (spacer == null) c.iconId else 0,
locked = c.hasPassword,
full = c.maxClients >= 0 && clients.size >= c.maxClients,
subscribed = c.subscribed,
clients = clients.map { client(it, contacts, colorContacts) },
children = c.children.map { channel(it, contacts, colorContacts) },
clients = clients.map { client(it, model, contacts, colorContacts) },
children = c.children.map { channel(it, model, contacts, colorContacts) },
)
}
private fun client(c: ClientEntry, contacts: ContactStore, colorContacts: Boolean): ClientItem {
private fun client(c: ClientEntry, model: ServerModel, contacts: ContactStore, colorContacts: Boolean): ClientItem {
val contact = contacts.find(c.uniqueId)
val away = c.away && c.awayMessage.isNotEmpty() && contact?.ignoreAwayMessage != true
return ClientItem(
@@ -62,6 +72,9 @@ data class ServerTree(val channels: List<ChannelItem> = emptyList()) {
self = c.self,
contactType = contact?.type,
highlight = if (colorContacts) contact?.type ?: ContactType.NEUTRAL else ContactType.NEUTRAL,
groupIconIds = (model.serverGroupsOf(c.serverGroupIds) + listOfNotNull(model.channelGroup(c.channelGroupId)))
.map { it.iconId }.filter { it != 0L },
avatarFlag = c.avatarFlag.orEmpty(),
)
}
}
@@ -76,6 +89,8 @@ data class ChannelItem(
val locked: Boolean,
val full: Boolean,
val subscribed: Boolean,
/** 0 for none. */
val iconId: Long,
val clients: List<ClientItem>,
val children: List<ChannelItem>,
)
@@ -94,4 +109,8 @@ data class ClientItem(
val contactType: ContactType?,
/** The contact colour the name is drawn in, NEUTRAL when contacts are not coloured. */
val highlight: ContactType,
/** The icons of the client's server groups, then of its channel group, as TeamSpeak lines them up. */
val groupIconIds: List<Long>,
/** The MD5 of the client's avatar; empty for none. */
val avatarFlag: String,
)

View File

@@ -81,10 +81,12 @@ fun App(vm: SessionController) {
}, onBack = { contactsOpen = false })
server != null && !connectOpen -> {
serverStates.SaveableStateProvider(server.id) {
CompositionLocalProvider(LocalServerImages provides server.images) {
ServerScreen(vm, server, snackbar, { settingsOpen = true }, openContacts, enableMicrophone,
onConnectOther = { connectOpen = true })
}
}
}
else -> ConnectScreen(vm, snackbar, { settingsOpen = true }, { openContacts(null) }, connectAsking,
onBack = if (current != null) ({ connectOpen = false }) else null)
}

View File

@@ -85,7 +85,9 @@ fun ChannelTree(
item(key = "server") {
RowLayout(depth = 0, onClick = onServer) {
PackIcon("SERVER_GREEN", null, size = ICON)
Text(serverName, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(serverName, Modifier.weight(1f), fontWeight = FontWeight.Bold, maxLines = 1,
overflow = TextOverflow.Ellipsis)
ServerIcon(tree.serverIconId, size = ICON)
}
}
items(rows, key = { it.key }) { row ->
@@ -142,6 +144,7 @@ private fun ChannelRow(row: TreeRow.Channel, onToggle: (ChannelItem) -> Unit, on
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
ServerIcon(channel.iconId, size = ICON)
}
}
}
@@ -172,6 +175,7 @@ private fun ClientRow(row: TreeRow.Client, onClient: (ClientItem) -> Unit) {
RowLayout(row.depth, onClick = { onClient(client) }) {
Spacer(Modifier.width(ICON + 4.dp))
StatusIcon(client.status)
Row(Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
client.name,
fontWeight = if (client.self) FontWeight.Bold else FontWeight.Normal,
@@ -188,6 +192,8 @@ private fun ClientRow(row: TreeRow.Client, onClient: (ClientItem) -> Unit) {
color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
for (id in client.groupIconIds) ServerIcon(id, size = ICON)
}
}
@Composable

View File

@@ -6,6 +6,8 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -41,7 +43,7 @@ fun DetailsSection(details: Details, onLink: (String) -> Unit, modifier: Modifie
Column(modifier.padding(horizontal = 24.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
if (details.fields().isNotEmpty()) {
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
for (field in details.fields()) Fact(field.label(), field.value())
for (field in details.fields()) Fact(field)
}
}
when {
@@ -52,12 +54,26 @@ fun DetailsSection(details: Details, onLink: (String) -> Unit, modifier: Modifie
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun Fact(label: String, value: String) {
private fun Fact(field: Details.Field) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(label, Modifier.width(96.dp), style = MaterialTheme.typography.bodySmall,
Text(field.label(), Modifier.width(96.dp), style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(value, style = MaterialTheme.typography.bodySmall)
if (field.groups().isEmpty()) {
Text(field.value(), style = MaterialTheme.typography.bodySmall)
} else {
// Each group with its icon, as TeamSpeak's info frame lists them.
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
field.groups().forEachIndexed { i, group ->
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
ServerIcon(group.iconId, size = 14.dp)
Text(group.name + if (i < field.groups().size - 1) "," else "",
style = MaterialTheme.typography.bodySmall)
}
}
}
}
}
}

View File

@@ -0,0 +1,118 @@
package com.ts3client.android.ui
import android.graphics.drawable.AnimatedImageDrawable
import android.graphics.drawable.Drawable
import android.os.Handler
import android.os.Looper
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.LocalContentColor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
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.asImageBitmap
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.ts3client.android.AvatarImage
import com.ts3client.android.ServerImages
import com.ts3client.net.IconRepository
import kotlin.math.roundToInt
/** The images of the server on screen. */
val LocalServerImages = staticCompositionLocalOf<ServerImages?> { null }
/**
* A group, channel or server icon; nothing while it has none or it is still downloading.
* TeamSpeak's default group icons are the pack's, drawn in its style, as on the desktop.
*/
@Composable
fun ServerIcon(id: Long, modifier: Modifier = Modifier, size: Dp = 16.dp) {
if (id == 0L) return
if (id in 1..IconRepository.MAX_BUNDLED_ID) {
val theme = LocalIconTheme.current
val active by theme.active.collectAsStateWithLifecycle()
val pixels = with(LocalDensity.current) { size.roundToPx() }
val rgb = LocalContentColor.current.copy(alpha = 1f).toArgb()
val themed = remember(id, pixels, rgb, active) { theme.render(listOf("GROUP_$id"), pixels, rgb)?.asImageBitmap() }
if (themed != null) {
Image(themed, null, modifier.size(size))
return
}
}
val images = LocalServerImages.current ?: return
val version by images.version.collectAsStateWithLifecycle()
val bitmap = remember(id, version) { images.icon(id) } ?: return
Image(bitmap, null, modifier.size(size))
}
/** A client's avatar, moving if it is an animated GIF; nothing while they have none or it is still downloading. */
@Composable
fun Avatar(uniqueId: String, flag: String, modifier: Modifier = Modifier, size: Dp = 64.dp) {
val images = LocalServerImages.current ?: return
val version by images.version.collectAsStateWithLifecycle()
val image = remember(flag, version) { images.avatar(uniqueId, flag) } ?: return
val painter = when (image) {
is AvatarImage.Still -> remember(image) { BitmapPainter(image.bitmap) }
is AvatarImage.Moving -> rememberMovingPainter(image.drawable)
}
Image(painter, "Avatar", modifier.size(size).clip(RoundedCornerShape(8.dp)))
}
/** Plays [drawable] while on screen, redrawing at each of its frames. */
@Composable
private fun rememberMovingPainter(drawable: AnimatedImageDrawable): Painter {
val painter = remember(drawable) { MovingPainter(drawable) }
DisposableEffect(drawable) {
drawable.callback = painter
drawable.start()
onDispose {
drawable.stop()
if (drawable.callback === painter) drawable.callback = null
}
}
return painter
}
private class MovingPainter(private val drawable: AnimatedImageDrawable) : Painter(), Drawable.Callback {
/** Read while drawing, so that each new frame draws again. */
private var frame by mutableIntStateOf(0)
private val main = Handler(Looper.getMainLooper())
override val intrinsicSize = Size(drawable.intrinsicWidth.toFloat(), drawable.intrinsicHeight.toFloat())
override fun DrawScope.onDraw() {
frame
drawIntoCanvas { canvas ->
drawable.setBounds(0, 0, size.width.roundToInt(), size.height.roundToInt())
drawable.draw(canvas.nativeCanvas)
}
}
override fun invalidateDrawable(who: Drawable) {
frame++
}
override fun scheduleDrawable(who: Drawable, what: Runnable, `when`: Long) {
main.postAtTime(what, `when`)
}
override fun unscheduleDrawable(who: Drawable, what: Runnable) {
main.removeCallbacks(what)
}
}

View File

@@ -65,11 +65,12 @@ fun ClientSheet(
Row(Modifier.padding(horizontal = 24.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)) {
StatusIcon(client.status)
Column {
Column(Modifier.weight(1f)) {
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)
}
Avatar(client.uniqueId, client.avatarFlag)
}
details?.let { DetailsSection(it, onLink, Modifier.padding(bottom = 8.dp)) }
if (client.self) {