Show servers' icons on bookmarks, and their host button in the toolbar's corner

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 22:28:35 +00:00
parent cf1db61a4a
commit 2c1b37ee30
20 changed files with 419 additions and 30 deletions

View File

@@ -354,6 +354,7 @@ class ServerController internal constructor(
selfClientId = if (session.isConnected) conn.selfClientId else 0,
channelId = if (session.isConnected) self?.channelId ?: 0 else 0,
inputMode = app.settings.inputMode,
hostButton = if (session.isConnected) conn.model.hostButton() else null,
)
}
@@ -412,7 +413,15 @@ class ServerController internal constructor(
refresh()
}
override fun onModelChanged() = scheduleTree(structure = true)
override fun onModelChanged() {
scheduleTree(structure = true)
// A server edit may have changed it.
if (session.isConnected && conn.model.hostButton() != _state.value.hostButton) refresh()
}
override fun onServerIconChanged(iconId: Long) =
app.serverIconChanged(session.address(), session.port(), iconId)
override fun onInfoUpdated() {
_infoUpdates.update { it + 1 }
// Avatars announce their arrival as info updates.

View File

@@ -94,7 +94,7 @@ data class ServerTree(
countryFlags: Boolean): ServerTree =
ServerTree(
roots.map { channel(it, model, contacts, colorContacts) },
model.serverProperty("virtualserver_icon_id").toLongOrNull() ?: 0,
model.serverIconId(),
countryFlags,
)

View File

@@ -18,6 +18,7 @@ import com.ts3client.config.Settings
import com.ts3client.contacts.Contact
import com.ts3client.contacts.ContactStore
import com.ts3client.myts.MyTeamSpeak
import com.ts3client.net.HostButton
import com.ts3client.myts.MyTeamSpeakLogin
import com.ts3client.session.Sessions
import com.ts3client.sound.SoundNotifier
@@ -51,6 +52,7 @@ data class SessionState(
val selfClientId: Int = 0,
val channelId: Int = 0,
val inputMode: Settings.InputMode = Settings.InputMode.VOICE_ACTIVATION,
val hostButton: HostButton? = null,
)
/** Something that happened once, for the UI to tell the user about. */
@@ -270,6 +272,22 @@ class SessionController(private val context: Context) {
saveBookmarks()
}
/** Bookmarks leading to [address]:[port] take on the icon the server shows now. */
internal fun serverIconChanged(address: String, port: Int, iconId: Long) {
main.post {
val all = bookmarkStore.all()
var changed = false
for (i in all.indices) {
val b = all[i]
if (!b.pointsAt(address, port) || b.iconId == iconId) continue
// A new entry, so the list the UI holds changes along with it.
all[i] = b.copy().also { it.iconId = iconId }
changed = true
}
if (changed) saveBookmarks()
}
}
private fun saveBookmarks() {
bookmarkStore.save()
_bookmarks.value = bookmarkStore.all().toList()

View File

@@ -1,6 +1,8 @@
package com.ts3client.android.ui
import android.graphics.BitmapFactory
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -9,6 +11,7 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardOptions
@@ -44,6 +47,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
@@ -51,6 +55,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.ts3client.android.IdentityInfo
import com.ts3client.android.SessionController
import com.ts3client.config.Bookmark
import com.ts3client.net.IconRepository
/** Where to connect to: an address typed in, or a bookmark. */
@OptIn(ExperimentalMaterial3Api::class)
@@ -205,6 +210,16 @@ private fun IdentityDialog(identities: List<IdentityInfo>, selected: String, onD
)
}
/** The server's icon as last seen there, else the pack's bookmark icon. */
@Composable
private fun BookmarkIcon(iconId: Long) {
val cached = remember(iconId) {
IconRepository.cached(iconId)?.let { BitmapFactory.decodeByteArray(it, 0, it.size)?.asImageBitmap() }
}
if (cached != null) Image(cached, null, Modifier.size(24.dp))
else PackIcon("BOOKMARK_MANAGER", null)
}
@Composable
private fun BookmarkRow(
bookmark: Bookmark,
@@ -220,7 +235,7 @@ private fun BookmarkRow(
supportingContent = {
Text("${bookmark.address}:${bookmark.port}" + (identity?.let { " · ${it.name}" } ?: ""))
},
leadingContent = { PackIcon("BOOKMARK_MANAGER", null) },
leadingContent = { BookmarkIcon(bookmark.iconId) },
trailingContent = {
Box {
IconButton(onClick = { menu = true }) { Icon(Icons.Default.MoreVert, "Bookmark options") }

View File

@@ -0,0 +1,64 @@
package com.ts3client.android.ui
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.ts3client.android.Picture
import com.ts3client.android.WebPictures
import com.ts3client.net.HostButton
/**
* The server's host button in the top bar: its own picture fitted to the bar's icons, or
* the pack's host button icon while there is none (yet).
*/
@Composable
fun HostButtonAction(button: HostButton, onOpen: (String) -> Unit) {
val label = button.tooltip.ifEmpty { button.url }.ifEmpty { null }
val picture = hostPicture(button.gfxUrl)
Box(
Modifier
.fillMaxHeight()
.clickable(enabled = button.url.isNotEmpty()) { onOpen(button.url) }
.padding(horizontal = 12.dp),
contentAlignment = Alignment.Center,
) {
if (picture == null) {
PackIcon("HOSTER_BUTTON", label)
} else {
Image(
rememberPicturePainter(picture),
label,
Modifier
.widthIn(max = 24.dp * MAX_ASPECT)
.height(24.dp)
.aspectRatio(picture.width.toFloat() / picture.height.coerceAtLeast(1)),
)
}
}
}
@Composable
private fun hostPicture(url: String): Picture? {
val pictures = LocalWebPictures.current
if (pictures == null || url.isEmpty()) return null
val version by pictures.version.collectAsStateWithLifecycle()
val state by produceState<WebPictures.State>(WebPictures.State.Loading, url, version) {
value = pictures.state(url)
}
return (state as? WebPictures.State.Shown)?.picture
}
/** Keeps a banner-shaped picture from crowding out the server's name. */
private const val MAX_ASPECT = 6

View File

@@ -231,6 +231,7 @@ fun ServerScreen(
title = { ServerSwitcher(vm, server, state, onConnectOther) },
expandedHeight = 48.dp,
actions = {
state.hostButton?.let { HostButtonAction(it) { url -> runCatching { uriHandler.openUri(url) } } }
IconButton(onClick = find::show) { PackIcon("FIND", "Find") }
IconButton(onClick = { onContacts(null) }) { PackIcon("CONTACT", "Contacts") }
IconButton(onClick = onSettings) { PackIcon("SETTINGS", "Settings") }