From fd86d9e638e163beebbec883c065fa2a66e398e8 Mon Sep 17 00:00:00 2001 From: ericek111 Date: Wed, 30 Sep 2026 22:34:52 +0000 Subject: [PATCH] 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 --- .../com/ts3client/android/ServerController.kt | 13 +- .../com/ts3client/android/ServerImages.kt | 86 +++++++++++++ .../java/com/ts3client/android/ServerTree.kt | 35 ++++-- .../main/java/com/ts3client/android/ui/App.kt | 6 +- .../com/ts3client/android/ui/ChannelTree.kt | 36 +++--- .../ts3client/android/ui/DetailsSection.kt | 24 +++- .../com/ts3client/android/ui/ServerImage.kt | 118 ++++++++++++++++++ .../java/com/ts3client/android/ui/Sheets.kt | 3 +- 8 files changed, 288 insertions(+), 33 deletions(-) create mode 100644 android/app/src/main/java/com/ts3client/android/ServerImages.kt create mode 100644 android/app/src/main/java/com/ts3client/android/ui/ServerImage.kt 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 f573dc7..5c3b31b 100644 --- a/android/app/src/main/java/com/ts3client/android/ServerController.kt +++ b/android/app/src/main/java/com/ts3client/android/ServerController.kt @@ -72,6 +72,8 @@ class ServerController internal constructor( private val _infoUpdates = MutableStateFlow(0) val infoUpdates: StateFlow = _infoUpdates + val images = ServerImages(conn) + /** A private chat a notification asked to show, until the UI took it up. */ private val _chatRequest = MutableStateFlow(null) val chatRequest: StateFlow = _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)) diff --git a/android/app/src/main/java/com/ts3client/android/ServerImages.kt b/android/app/src/main/java/com/ts3client/android/ServerImages.kt new file mode 100644 index 0000000..786f749 --- /dev/null +++ b/android/app/src/main/java/com/ts3client/android/ServerImages.kt @@ -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() + private val avatars = ConcurrentHashMap() + + private val _version = MutableStateFlow(0) + val version: StateFlow = _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 +} 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 bb03747..551bb3f 100644 --- a/android/app/src/main/java/com/ts3client/android/ServerTree.kt +++ b/android/app/src/main/java/com/ts3client/android/ServerTree.kt @@ -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 = emptyList()) { +data class ServerTree( + val channels: List = 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 = emptyList()) { } companion object { - fun of(roots: List, contacts: ContactStore, colorContacts: Boolean): ServerTree = - ServerTree(roots.map { channel(it, contacts, colorContacts) }) + fun of(roots: List, 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 = 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, val children: List, ) @@ -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, + /** The MD5 of the client's avatar; empty for none. */ + val avatarFlag: String, ) diff --git a/android/app/src/main/java/com/ts3client/android/ui/App.kt b/android/app/src/main/java/com/ts3client/android/ui/App.kt index 085f4d9..3c544f7 100644 --- a/android/app/src/main/java/com/ts3client/android/ui/App.kt +++ b/android/app/src/main/java/com/ts3client/android/ui/App.kt @@ -81,8 +81,10 @@ fun App(vm: SessionController) { }, onBack = { contactsOpen = false }) server != null && !connectOpen -> { serverStates.SaveableStateProvider(server.id) { - ServerScreen(vm, server, snackbar, { settingsOpen = true }, openContacts, enableMicrophone, - onConnectOther = { connectOpen = true }) + 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, 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 b2c5ee9..001a2ef 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 @@ -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,21 +175,24 @@ private fun ClientRow(row: TreeRow.Client, onClient: (ClientItem) -> Unit) { RowLayout(row.depth, onClick = { onClient(client) }) { Spacer(Modifier.width(ICON + 4.dp)) StatusIcon(client.status) - Text( - client.name, - fontWeight = if (client.self) FontWeight.Bold else FontWeight.Normal, - color = when (client.highlight) { - ContactType.FRIEND -> StatusColors.friend - ContactType.BLOCKED -> StatusColors.blocked - else -> Color.Unspecified - }, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - if (client.awayMessage.isNotEmpty()) { - Text("[${client.awayMessage}]", Modifier.weight(1f, fill = false), - color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) + Row(Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + client.name, + fontWeight = if (client.self) FontWeight.Bold else FontWeight.Normal, + color = when (client.highlight) { + ContactType.FRIEND -> StatusColors.friend + ContactType.BLOCKED -> StatusColors.blocked + else -> Color.Unspecified + }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (client.awayMessage.isNotEmpty()) { + Text("[${client.awayMessage}]", Modifier.weight(1f, fill = false), + color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) + } } + for (id in client.groupIconIds) ServerIcon(id, size = ICON) } } diff --git a/android/app/src/main/java/com/ts3client/android/ui/DetailsSection.kt b/android/app/src/main/java/com/ts3client/android/ui/DetailsSection.kt index 26f5104..4b92949 100644 --- a/android/app/src/main/java/com/ts3client/android/ui/DetailsSection.kt +++ b/android/app/src/main/java/com/ts3client/android/ui/DetailsSection.kt @@ -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) + } + } + } + } } } 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 new file mode 100644 index 0000000..2c4f208 --- /dev/null +++ b/android/app/src/main/java/com/ts3client/android/ui/ServerImage.kt @@ -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 { 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) + } +} 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 b1bb8cb..0d2c0c9 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 @@ -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) {