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:
@@ -72,6 +72,8 @@ class ServerController internal constructor(
|
|||||||
private val _infoUpdates = MutableStateFlow(0)
|
private val _infoUpdates = MutableStateFlow(0)
|
||||||
val infoUpdates: StateFlow<Int> = _infoUpdates
|
val infoUpdates: StateFlow<Int> = _infoUpdates
|
||||||
|
|
||||||
|
val images = ServerImages(conn)
|
||||||
|
|
||||||
/** A private chat a notification asked to show, until the UI took it up. */
|
/** A private chat a notification asked to show, until the UI took it up. */
|
||||||
private val _chatRequest = MutableStateFlow<ChatKey.Private?>(null)
|
private val _chatRequest = MutableStateFlow<ChatKey.Private?>(null)
|
||||||
val chatRequest: StateFlow<ChatKey.Private?> = _chatRequest
|
val chatRequest: StateFlow<ChatKey.Private?> = _chatRequest
|
||||||
@@ -281,7 +283,7 @@ class ServerController internal constructor(
|
|||||||
delay(TREE_FRAME_MS)
|
delay(TREE_FRAME_MS)
|
||||||
treePending.set(false)
|
treePending.set(false)
|
||||||
if (structureChanged.getAndSet(false)) roots = conn.model.buildTree()
|
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 serverName = if (session.isConnected) conn.model.serverName.orEmpty() else ""
|
||||||
val channelName = session.currentChannel()?.name.orEmpty()
|
val channelName = session.currentChannel()?.name.orEmpty()
|
||||||
_chats.update {
|
_chats.update {
|
||||||
@@ -323,8 +325,13 @@ class ServerController internal constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onModelChanged() = scheduleTree(structure = true)
|
override fun onModelChanged() = scheduleTree(structure = true)
|
||||||
override fun onInfoUpdated() = _infoUpdates.update { it + 1 }
|
override fun onInfoUpdated() {
|
||||||
override fun onIconsUpdated() {}
|
_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) {
|
override fun onChat(scope: ChatScope, fromClientId: Int, fromUniqueId: String?, fromName: String, message: String) {
|
||||||
append(key(scope, fromClientId), fromName, ChatHtml.message(fromClientId, fromUniqueId, fromName, message))
|
append(key(scope, fromClientId), fromName, ChatHtml.message(fromClientId, fromUniqueId, fromName, message))
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import com.ts3client.contacts.ContactType
|
|||||||
import com.ts3client.net.ChannelNode
|
import com.ts3client.net.ChannelNode
|
||||||
import com.ts3client.net.ClientEntry
|
import com.ts3client.net.ClientEntry
|
||||||
import com.ts3client.net.ClientStatus
|
import com.ts3client.net.ClientStatus
|
||||||
|
import com.ts3client.net.ServerModel
|
||||||
import com.ts3client.net.Spacers
|
import com.ts3client.net.Spacers
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -13,7 +14,11 @@ import com.ts3client.net.Spacers
|
|||||||
* on connection threads, which Compose cannot observe; these never change.
|
* on connection threads, which Compose cannot observe; these never change.
|
||||||
*/
|
*/
|
||||||
@Immutable
|
@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 }
|
fun channel(id: Int): ChannelItem? = find(channels) { it.id == id }
|
||||||
|
|
||||||
@@ -32,24 +37,29 @@ data class ServerTree(val channels: List<ChannelItem> = emptyList()) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun of(roots: List<ChannelNode>, contacts: ContactStore, colorContacts: Boolean): ServerTree =
|
fun of(roots: List<ChannelNode>, model: ServerModel, contacts: ContactStore, colorContacts: Boolean): ServerTree =
|
||||||
ServerTree(roots.map { channel(it, contacts, colorContacts) })
|
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 clients = c.clients
|
||||||
|
val spacer = Spacers.parse(c.name)
|
||||||
return ChannelItem(
|
return ChannelItem(
|
||||||
id = c.id,
|
id = c.id,
|
||||||
name = c.name ?: "",
|
name = c.name ?: "",
|
||||||
spacer = Spacers.parse(c.name),
|
spacer = spacer,
|
||||||
|
iconId = if (spacer == null) c.iconId else 0,
|
||||||
locked = c.hasPassword,
|
locked = c.hasPassword,
|
||||||
full = c.maxClients >= 0 && clients.size >= c.maxClients,
|
full = c.maxClients >= 0 && clients.size >= c.maxClients,
|
||||||
subscribed = c.subscribed,
|
subscribed = c.subscribed,
|
||||||
clients = clients.map { client(it, contacts, colorContacts) },
|
clients = clients.map { client(it, model, contacts, colorContacts) },
|
||||||
children = c.children.map { channel(it, 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 contact = contacts.find(c.uniqueId)
|
||||||
val away = c.away && c.awayMessage.isNotEmpty() && contact?.ignoreAwayMessage != true
|
val away = c.away && c.awayMessage.isNotEmpty() && contact?.ignoreAwayMessage != true
|
||||||
return ClientItem(
|
return ClientItem(
|
||||||
@@ -62,6 +72,9 @@ data class ServerTree(val channels: List<ChannelItem> = emptyList()) {
|
|||||||
self = c.self,
|
self = c.self,
|
||||||
contactType = contact?.type,
|
contactType = contact?.type,
|
||||||
highlight = if (colorContacts) contact?.type ?: ContactType.NEUTRAL else ContactType.NEUTRAL,
|
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 locked: Boolean,
|
||||||
val full: Boolean,
|
val full: Boolean,
|
||||||
val subscribed: Boolean,
|
val subscribed: Boolean,
|
||||||
|
/** 0 for none. */
|
||||||
|
val iconId: Long,
|
||||||
val clients: List<ClientItem>,
|
val clients: List<ClientItem>,
|
||||||
val children: List<ChannelItem>,
|
val children: List<ChannelItem>,
|
||||||
)
|
)
|
||||||
@@ -94,4 +109,8 @@ data class ClientItem(
|
|||||||
val contactType: ContactType?,
|
val contactType: ContactType?,
|
||||||
/** The contact colour the name is drawn in, NEUTRAL when contacts are not coloured. */
|
/** The contact colour the name is drawn in, NEUTRAL when contacts are not coloured. */
|
||||||
val highlight: ContactType,
|
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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -81,10 +81,12 @@ fun App(vm: SessionController) {
|
|||||||
}, onBack = { contactsOpen = false })
|
}, onBack = { contactsOpen = false })
|
||||||
server != null && !connectOpen -> {
|
server != null && !connectOpen -> {
|
||||||
serverStates.SaveableStateProvider(server.id) {
|
serverStates.SaveableStateProvider(server.id) {
|
||||||
|
CompositionLocalProvider(LocalServerImages provides server.images) {
|
||||||
ServerScreen(vm, server, snackbar, { settingsOpen = true }, openContacts, enableMicrophone,
|
ServerScreen(vm, server, snackbar, { settingsOpen = true }, openContacts, enableMicrophone,
|
||||||
onConnectOther = { connectOpen = true })
|
onConnectOther = { connectOpen = true })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
else -> ConnectScreen(vm, snackbar, { settingsOpen = true }, { openContacts(null) }, connectAsking,
|
else -> ConnectScreen(vm, snackbar, { settingsOpen = true }, { openContacts(null) }, connectAsking,
|
||||||
onBack = if (current != null) ({ connectOpen = false }) else null)
|
onBack = if (current != null) ({ connectOpen = false }) else null)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,7 +85,9 @@ fun ChannelTree(
|
|||||||
item(key = "server") {
|
item(key = "server") {
|
||||||
RowLayout(depth = 0, onClick = onServer) {
|
RowLayout(depth = 0, onClick = onServer) {
|
||||||
PackIcon("SERVER_GREEN", null, size = ICON)
|
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 ->
|
items(rows, key = { it.key }) { row ->
|
||||||
@@ -142,6 +144,7 @@ private fun ChannelRow(row: TreeRow.Channel, onToggle: (ChannelItem) -> Unit, on
|
|||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
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) }) {
|
RowLayout(row.depth, onClick = { onClient(client) }) {
|
||||||
Spacer(Modifier.width(ICON + 4.dp))
|
Spacer(Modifier.width(ICON + 4.dp))
|
||||||
StatusIcon(client.status)
|
StatusIcon(client.status)
|
||||||
|
Row(Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
Text(
|
Text(
|
||||||
client.name,
|
client.name,
|
||||||
fontWeight = if (client.self) FontWeight.Bold else FontWeight.Normal,
|
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)
|
color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for (id in client.groupIconIds) ServerIcon(id, size = ICON)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import androidx.compose.foundation.clickable
|
|||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
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.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
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)) {
|
Column(modifier.padding(horizontal = 24.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
if (details.fields().isNotEmpty()) {
|
if (details.fields().isNotEmpty()) {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||||
for (field in details.fields()) Fact(field.label(), field.value())
|
for (field in details.fields()) Fact(field)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
when {
|
when {
|
||||||
@@ -52,12 +54,26 @@ fun DetailsSection(details: Details, onLink: (String) -> Unit, modifier: Modifie
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun Fact(label: String, value: String) {
|
private fun Fact(field: Details.Field) {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
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)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -65,11 +65,12 @@ fun ClientSheet(
|
|||||||
Row(Modifier.padding(horizontal = 24.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically,
|
Row(Modifier.padding(horizontal = 24.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
StatusIcon(client.status)
|
StatusIcon(client.status)
|
||||||
Column {
|
Column(Modifier.weight(1f)) {
|
||||||
Text(client.name, style = MaterialTheme.typography.titleLarge)
|
Text(client.name, style = MaterialTheme.typography.titleLarge)
|
||||||
if (client.name != client.nickname) Text(client.nickname, style = MaterialTheme.typography.bodyMedium)
|
if (client.name != client.nickname) Text(client.nickname, style = MaterialTheme.typography.bodyMedium)
|
||||||
if (client.awayMessage.isNotEmpty()) Text(client.awayMessage, 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)) }
|
details?.let { DetailsSection(it, onLink, Modifier.padding(bottom = 8.dp)) }
|
||||||
if (client.self) {
|
if (client.self) {
|
||||||
|
|||||||
Reference in New Issue
Block a user