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>
62 lines
2.4 KiB
Kotlin
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
|
|
}
|
|
}
|