From 7f30a27d73a6547ee5ea2336a07cecb14d6960c7 Mon Sep 17 00:00:00 2001 From: ericek111 Date: Thu, 1 Oct 2026 08:27:50 +0000 Subject: [PATCH] Open avatars and description pictures full size on Android; fold minor client facts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../com/ts3client/android/SavedPictures.kt | 39 ++++ .../com/ts3client/android/ServerController.kt | 17 ++ .../com/ts3client/android/ServerImages.kt | 4 + .../ts3client/android/SessionController.kt | 2 + .../java/com/ts3client/android/WebPictures.kt | 3 + .../main/java/com/ts3client/android/ui/App.kt | 6 +- .../java/com/ts3client/android/ui/ChatText.kt | 6 +- .../ts3client/android/ui/DetailsSection.kt | 25 +- .../com/ts3client/android/ui/PictureViewer.kt | 220 ++++++++++++++++++ .../java/com/ts3client/android/ui/Sheets.kt | 8 +- .../main/java/com/ts3client/net/Details.java | 43 ++-- 11 files changed, 349 insertions(+), 24 deletions(-) create mode 100644 android/app/src/main/java/com/ts3client/android/SavedPictures.kt create mode 100644 android/app/src/main/java/com/ts3client/android/ui/PictureViewer.kt diff --git a/android/app/src/main/java/com/ts3client/android/SavedPictures.kt b/android/app/src/main/java/com/ts3client/android/SavedPictures.kt new file mode 100644 index 0000000..0a250c8 --- /dev/null +++ b/android/app/src/main/java/com/ts3client/android/SavedPictures.kt @@ -0,0 +1,39 @@ +package com.ts3client.android + +import android.content.ContentValues +import android.content.Context +import android.graphics.BitmapFactory +import android.os.Environment +import android.provider.MediaStore +import android.webkit.MimeTypeMap +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** Puts pictures into the shared Pictures collection, in a TeamSpeak album, where galleries find them. */ +object SavedPictures { + + /** Saves [data] as [name], its extension following what the data is; false if it could not be. */ + suspend fun save(context: Context, name: String, data: ByteArray): Boolean = withContext(Dispatchers.IO) { + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeByteArray(data, 0, data.size, bounds) + val mime = bounds.outMimeType ?: return@withContext false + val extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mime) + val values = ContentValues().apply { + put(MediaStore.Images.Media.DISPLAY_NAME, if (extension == null) name else "$name.$extension") + put(MediaStore.Images.Media.MIME_TYPE, mime) + put(MediaStore.Images.Media.RELATIVE_PATH, "${Environment.DIRECTORY_PICTURES}/TeamSpeak") + put(MediaStore.Images.Media.IS_PENDING, 1) + } + val resolver = context.contentResolver + val uri = resolver.insert(MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY), values) + ?: return@withContext false + try { + resolver.openOutputStream(uri)!!.use { it.write(data) } + resolver.update(uri, ContentValues().apply { put(MediaStore.Images.Media.IS_PENDING, 0) }, null, null) + true + } catch (e: Exception) { + resolver.delete(uri, null, null) + false + } + } +} 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 11589f4..ebe16b1 100644 --- a/android/app/src/main/java/com/ts3client/android/ServerController.kt +++ b/android/app/src/main/java/com/ts3client/android/ServerController.kt @@ -16,8 +16,12 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicLong +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine /** * One server the app is on: its [ServerSession], and the snapshots the UI shows of it. Session @@ -162,6 +166,19 @@ class ServerController internal constructor( conn.model.getClient(clientId)?.let { conn.setClientVolumeModifier(it, decibels) } } + /** Uploads [data] as our avatar; null once it is set, else why not. */ + suspend fun setAvatar(data: ByteArray): String? { + if (!session.isConnected) return "Not connected" + val file = withContext(Dispatchers.IO) { + File.createTempFile("avatar", null, app.cacheDir).apply { writeBytes(data) } + } + return try { + suspendCoroutine { done -> conn.setAvatar(file) { done.resume(it) } } + } finally { + file.delete() + } + } + // ---- details ---- fun serverDetails(): Details? = diff --git a/android/app/src/main/java/com/ts3client/android/ServerImages.kt b/android/app/src/main/java/com/ts3client/android/ServerImages.kt index 1cd8f9e..a4a726e 100644 --- a/android/app/src/main/java/com/ts3client/android/ServerImages.kt +++ b/android/app/src/main/java/com/ts3client/android/ServerImages.kt @@ -39,6 +39,10 @@ class ServerImages internal constructor(private val conn: TeamspeakConnection) { 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 } diff --git a/android/app/src/main/java/com/ts3client/android/SessionController.kt b/android/app/src/main/java/com/ts3client/android/SessionController.kt index 6cf6ffe..dc1c8ad 100644 --- a/android/app/src/main/java/com/ts3client/android/SessionController.kt +++ b/android/app/src/main/java/com/ts3client/android/SessionController.kt @@ -32,6 +32,7 @@ import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.withContext +import java.io.File import java.util.concurrent.atomic.AtomicInteger /** What the app shows of a connection and the local client on it. */ @@ -67,6 +68,7 @@ class SessionController(private val context: Context) { val settings: Settings = Settings.load() val icons = IconTheme(context, settings) val pictures = WebPictures(context.cacheDir, settings) + internal val cacheDir: File get() = context.cacheDir private val bookmarkStore = Bookmarks.load() internal val identityStore = IdentityStore.load(settings) val identities = IdentityManager(context, identityStore, settings, bookmarkStore) { diff --git a/android/app/src/main/java/com/ts3client/android/WebPictures.kt b/android/app/src/main/java/com/ts3client/android/WebPictures.kt index d3c582e..4c68cd5 100644 --- a/android/app/src/main/java/com/ts3client/android/WebPictures.kt +++ b/android/app/src/main/java/com/ts3client/android/WebPictures.kt @@ -48,6 +48,9 @@ class WebPictures(cacheDir: File, private val settings: Settings) { return State.Shown(picture) } + /** The picture's file as downloaded; null while it is not there. */ + suspend fun data(url: String): ByteArray? = withContext(Dispatchers.IO) { downloads.get(url)?.readBytes() } + private companion object { const val CACHED = 32 const val MAX_PX = 1024 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 cb639a8..37771f7 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 @@ -82,8 +82,10 @@ fun App(vm: SessionController) { server != null && !connectOpen -> { serverStates.SaveableStateProvider(server.id) { CompositionLocalProvider(LocalServerImages provides server.images) { - ServerScreen(vm, server, snackbar, { settingsOpen = true }, openContacts, enableMicrophone, - onConnectOther = { connectOpen = true }) + PictureViewerHost(server) { + ServerScreen(vm, server, snackbar, { settingsOpen = true }, openContacts, enableMicrophone, + onConnectOther = { connectOpen = true }) + } } } } diff --git a/android/app/src/main/java/com/ts3client/android/ui/ChatText.kt b/android/app/src/main/java/com/ts3client/android/ui/ChatText.kt index 2f9a62c..0763b4b 100644 --- a/android/app/src/main/java/com/ts3client/android/ui/ChatText.kt +++ b/android/app/src/main/java/com/ts3client/android/ui/ChatText.kt @@ -157,13 +157,17 @@ private fun WebPicture(image: Piece.Image, style: TextStyle, onLink: (String) -> WebPictures.State.Broken -> PictureLink("BROKEN_IMAGE", image, style, onLink) is WebPictures.State.Shown -> { val picture = shown.picture + val viewer = LocalPictureViewer.current Image( rememberPicturePainter(picture), null, Modifier .widthIn(max = picture.width.dp) .aspectRatio(picture.width.toFloat() / picture.height.coerceAtLeast(1)) - .then(if (image.href != null) Modifier.clickable { onLink(image.href) } else Modifier), + .then(if (viewer != null) Modifier.clickable { + val openLink = image.href?.let { href -> { onLink(href) } } + viewer(ViewedPicture(pictureName(image.url), image.url, openLink) { pictures.data(image.url) }) + } else if (image.href != null) Modifier.clickable { onLink(image.href) } else Modifier), ) } } 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 4c6c5d3..aa8e1c1 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 @@ -37,13 +37,23 @@ import androidx.compose.ui.unit.dp import com.ts3client.net.Details import com.ts3client.text.BBCode -/** The facts of the server, a channel or a client, then its description, for the top of its sheet. */ +/** + * The facts of the server, a channel or a client, then its description, for the top of its sheet. + * The minor facts wait behind "more…". + */ @Composable fun DetailsSection(details: Details, onLink: (String) -> Unit, modifier: Modifier = Modifier) { + var all by rememberSaveable(details.title()) { mutableStateOf(false) } + val shown = if (all) details.fields() else details.fields().filterNot { it.minor() } + val more = !all && shown.size < details.fields().size 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) + if (shown.isNotEmpty() || more) { + Column(Modifier.animateContentSize(), verticalArrangement = Arrangement.spacedBy(2.dp)) { + for (field in shown) Fact(field) + if (more) { + Text("more…", Modifier.clickable { all = true }.padding(vertical = 2.dp), + style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary) + } } } when { @@ -65,11 +75,14 @@ private fun Fact(field: Details.Field) { } else { // Each group with its icon, as TeamSpeak's info frame lists them. FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + // A nameless group shows only its icon, and takes no comma. + val lastNamed = field.groups().indexOfLast { it.name.isNotBlank() } 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) + if (group.name.isNotBlank()) { + Text(group.name.trim() + if (i < lastNamed) "," else "", style = MaterialTheme.typography.bodySmall) + } } } } diff --git a/android/app/src/main/java/com/ts3client/android/ui/PictureViewer.kt b/android/app/src/main/java/com/ts3client/android/ui/PictureViewer.kt new file mode 100644 index 0000000..67c7930 --- /dev/null +++ b/android/app/src/main/java/com/ts3client/android/ui/PictureViewer.kt @@ -0,0 +1,220 @@ +package com.ts3client.android.ui + +import android.content.ClipData +import android.content.ClipboardManager +import android.widget.Toast +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.gestures.detectTransformGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.ts3client.android.Picture +import com.ts3client.android.SavedPictures +import com.ts3client.android.ServerController +import com.ts3client.android.decodePicture +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** A picture to open full size. */ +class ViewedPicture( + /** What to call it when saved, without an extension. */ + val name: String, + /** Where it is on the web; null for an avatar. */ + val url: String? = null, + /** Follows the link it sits in; null when it sits in none. */ + val openLink: (() -> Unit)? = null, + /** Our own avatar, which there is no taking. */ + val ownAvatar: Boolean = false, + /** Its file; null when it is not there. */ + val read: suspend () -> ByteArray?, +) + +/** Opens a picture full size; null where nothing can. */ +val LocalPictureViewer = staticCompositionLocalOf<((ViewedPicture) -> Unit)?> { null } + +/** Lets [content] open pictures full size, over everything, with [server] to take one as our avatar on. */ +@Composable +fun PictureViewerHost(server: ServerController, content: @Composable () -> Unit) { + var viewed by remember { mutableStateOf(null) } + CompositionLocalProvider(LocalPictureViewer provides { viewed = it }, content = content) + val context = LocalContext.current + viewed?.let { picture -> + PictureViewer( + picture, + onSteal = if (picture.ownAvatar) null else { data -> + val error = server.setAvatar(data) + Toast.makeText(context, error ?: "Avatar set.", Toast.LENGTH_SHORT).show() + }, + onDismiss = { viewed = null }, + ) + } +} + +/** A name to save the picture at [url] under: its file's, or "picture" when it has none. */ +fun pictureName(url: String): String = + url.substringBefore('?').substringBefore('#').trimEnd('/').substringAfterLast('/') + .substringBeforeLast('.').ifBlank { "picture" } + +/** The picture on black, filling the screen; pinch or double-tap to zoom, tap to hide the bar. */ +@Composable +private fun PictureViewer( + picture: ViewedPicture, + /** Takes the picture as our avatar; null when it cannot be. */ + onSteal: (suspend (ByteArray) -> Unit)?, + onDismiss: () -> Unit, +) { + val state by produceState(null, picture) { + val data = picture.read() + value = Shown(data, data?.let { withContext(Dispatchers.Default) { decodePicture(it, MAX_PX) } }) + } + val data = state?.data + var bar by remember { mutableStateOf(true) } + var scale by remember { mutableFloatStateOf(1f) } + var offset by remember { mutableStateOf(Offset.Zero) } + var size by remember { mutableStateOf(IntSize.Zero) } + fun panTo(target: Offset) { + val maxX = size.width * (scale - 1) / 2 + val maxY = size.height * (scale - 1) / 2 + offset = Offset(target.x.coerceIn(-maxX, maxX), target.y.coerceIn(-maxY, maxY)) + } + + Dialog(onDismiss, DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false)) { + Box( + Modifier.fillMaxSize().background(Color.Black).onSizeChanged { size = it } + .pointerInput(Unit) { + detectTapGestures( + onTap = { bar = !bar }, + onDoubleTap = { scale = if (scale > 1f) 1f else DOUBLE_TAP_SCALE; panTo(Offset.Zero) }, + ) + } + .pointerInput(Unit) { + detectTransformGestures { _, pan, zoom, _ -> + scale = (scale * zoom).coerceIn(1f, MAX_SCALE) + panTo(offset + pan) + } + }, + contentAlignment = Alignment.Center, + ) { + val shown = state?.picture + when { + state == null -> PackIcon("LOADING_IMAGE", "Loading picture") + shown == null -> PackIcon("BROKEN_IMAGE", "Broken picture") + else -> Image( + rememberPicturePainter(shown), null, + Modifier.fillMaxSize().graphicsLayer { + scaleX = scale + scaleY = scale + translationX = offset.x + translationY = offset.y + }, + contentScale = ContentScale.Fit, + ) + } + if (bar) { + CompositionLocalProvider(LocalContentColor provides Color.White) { + Bar(picture, data, onSteal, onDismiss, Modifier.align(Alignment.TopCenter)) + } + } + } + } +} + +@Composable +private fun Bar( + picture: ViewedPicture, + data: ByteArray?, + onSteal: (suspend (ByteArray) -> Unit)?, + onDismiss: () -> Unit, + modifier: Modifier, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + var menu by remember { mutableStateOf(false) } + Row( + modifier.fillMaxWidth() + .background(Brush.verticalGradient(listOf(Color.Black.copy(alpha = 0.6f), Color.Transparent))) + .statusBarsPadding(), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = onDismiss) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") } + Box(Modifier.weight(1f)) + Box { + IconButton(onClick = { menu = true }) { Icon(Icons.Default.MoreVert, "Picture options") } + DropdownMenu(menu, onDismissRequest = { menu = false }) { + DropdownMenuItem({ Text("Save") }, enabled = data != null, onClick = { + menu = false + val bytes = data ?: return@DropdownMenuItem + scope.launch { + val saved = SavedPictures.save(context, picture.name, bytes) + Toast.makeText(context, if (saved) "Saved." else "Could not save.", Toast.LENGTH_SHORT).show() + } + }) + if (onSteal != null) { + DropdownMenuItem({ Text("Steal for avatar") }, enabled = data != null, onClick = { + menu = false + val bytes = data ?: return@DropdownMenuItem + scope.launch { onSteal(bytes) } + }) + } + picture.url?.let { url -> + DropdownMenuItem({ Text("Copy URL") }, onClick = { + menu = false + context.getSystemService(ClipboardManager::class.java) + .setPrimaryClip(ClipData.newPlainText("URL", url)) + }) + } + picture.openLink?.let { open -> + DropdownMenuItem({ Text("Open link") }, onClick = { + menu = false + onDismiss() + open() + }) + } + } + } + } +} + +/** The picture's file, and the picture when the file is one; both null when it is not there. */ +private class Shown(val data: ByteArray?, val picture: Picture?) + +private const val MAX_PX = 4096 +private const val MAX_SCALE = 8f +private const val DOUBLE_TAP_SCALE = 2.5f 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 d2ff9b9..b952af4 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 @@ -74,7 +74,13 @@ fun ClientSheet( 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) + val viewer = LocalPictureViewer.current + val images = LocalServerImages.current + Avatar(client.uniqueId, client.avatarFlag, Modifier.clickable(enabled = viewer != null && images != null) { + viewer?.invoke(ViewedPicture("avatar_${client.nickname}", ownAvatar = client.self) { + images?.avatarData(client.uniqueId, client.avatarFlag) + }) + }) } details?.let { DetailsSection(it, onLink, Modifier.padding(bottom = 8.dp)) } if (client.self) { diff --git a/ts3-client/core/src/main/java/com/ts3client/net/Details.java b/ts3-client/core/src/main/java/com/ts3client/net/Details.java index 4fce731..818d3d5 100644 --- a/ts3-client/core/src/main/java/com/ts3client/net/Details.java +++ b/ts3-client/core/src/main/java/com/ts3client/net/Details.java @@ -23,10 +23,19 @@ public record Details(String title, List fields, String description, bool * * @param groups the groups {@code value} names, in order, for frontends that show their icons; * empty when it names none + * @param minor for frontends short of room to show only on asking */ - public record Field(String label, String value, List groups) { + public record Field(String label, String value, List groups, boolean minor) { Field(String label, String value) { - this(label, value, List.of()); + this(label, value, List.of(), false); + } + + Field(String label, String value, List groups) { + this(label, value, groups, false); + } + + static Field minor(String label, String value) { + return new Field(label, value, List.of(), true); } } @@ -66,7 +75,7 @@ public record Details(String title, List fields, String description, bool if (contact != null) { String type = contact.type.label(); if (!contact.phoneticNickname.isEmpty()) type += " (" + contact.phoneticNickname + ")"; - fields.add(new Field("Contact", type)); + fields.add(Field.minor("Contact", type)); } List serverGroups = model.serverGroupsOf(cl.serverGroupIds); @@ -74,27 +83,33 @@ public record Details(String title, List fields, String description, bool List names = model.serverGroupNames(cl.serverGroupIds); fields.add(new Field("Server groups", names.isEmpty() ? "—" : String.join(", ", names))); } else { + // A nameless group is there for its icon only; without one, there is nothing to show of it. + List shown = new ArrayList<>(); List names = new ArrayList<>(); - for (Group g : serverGroups) names.add(g.name); - fields.add(new Field("Server groups", String.join(", ", names), serverGroups)); + for (Group g : serverGroups) { + if (g.name.isBlank() && g.iconId == 0) continue; + shown.add(g); + if (!g.name.isBlank()) names.add(g.name.trim()); + } + fields.add(new Field("Server groups", names.isEmpty() ? "—" : String.join(", ", names), shown)); } Group channelGroup = model.channelGroup(cl.channelGroupId); fields.add(channelGroup != null ? new Field("Channel group", channelGroup.name, List.of(channelGroup)) : new Field("Channel group", "#" + cl.channelGroupId)); - if (cl.talkPower != 0) fields.add(new Field("Talk power", Integer.toString(cl.talkPower))); + if (cl.talkPower != 0) fields.add(Field.minor("Talk power", Integer.toString(cl.talkPower))); if (!cl.version.isEmpty()) { - fields.add(new Field("Version", cl.version + (cl.platform.isEmpty() ? "" : " on " + cl.platform))); + fields.add(Field.minor("Version", cl.version + (cl.platform.isEmpty() ? "" : " on " + cl.platform))); } else if (!cl.platform.isEmpty()) { - fields.add(new Field("Platform", cl.platform)); + fields.add(Field.minor("Platform", cl.platform)); } - if (cl.away) fields.add(new Field("Status", "Away")); - if (!cl.inputHardware) fields.add(new Field("Microphone", "disabled")); - else if (cl.inputMuted) fields.add(new Field("Microphone", "muted")); - if (!cl.outputHardware) fields.add(new Field("Speakers", "disabled")); - else if (cl.outputMuted) fields.add(new Field("Speakers", "muted")); - if (!cl.uniqueId.isEmpty()) fields.add(new Field("Unique ID", cl.uniqueId)); + if (cl.away) fields.add(Field.minor("Status", "Away")); + if (!cl.inputHardware) fields.add(Field.minor("Microphone", "disabled")); + else if (cl.inputMuted) fields.add(Field.minor("Microphone", "muted")); + if (!cl.outputHardware) fields.add(Field.minor("Speakers", "disabled")); + else if (cl.outputMuted) fields.add(Field.minor("Speakers", "muted")); + if (!cl.uniqueId.isEmpty()) fields.add(Field.minor("Unique ID", cl.uniqueId)); String name = contact == null ? cl.nickname : contact.displayName(cl.nickname); return new Details(name, fields, cl.description == null ? "" : cl.description, false);