Files
ts3java/android/app/src/main/java/com/ts3client/android/ServerImages.kt
ericek111 7f30a27d73 Open avatars and description pictures full size on Android; fold minor client facts
A client's sheet now shows only their server and channel groups, with
the rest (contact, talk power, version, microphone, unique ID, ...)
behind "more…". Core marks those facts minor, for frontends short of
room; groups without a name or an icon are left out on both clients.

Tapping an avatar or a picture in a description opens it on black, to
pinch or double-tap zoom. Its menu saves it to Pictures/TeamSpeak, takes
it as our avatar (not on our own), copies its address, and opens the
link it sat in, which a tap used to do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 08:27:50 +00:00

62 lines
2.4 KiB
Kotlin

package com.ts3client.android
import android.graphics.BitmapFactory
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.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, Picture>()
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): Picture? {
if (flag.isEmpty()) return null
avatars[flag]?.let { return it }
val picture = conn.avatars.get(uniqueId, flag)?.let { decodePicture(it.data(), AVATAR_PX) } ?: return null
avatars[flag] = picture
return picture
}
/** The avatar's file as the server keeps it; null while it has none or it is still downloading. */
fun avatarData(uniqueId: String, flag: String): ByteArray? =
if (flag.isEmpty()) null else conn.avatars.get(uniqueId, flag)?.data()
/** 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
}
}