From cd02e4b2d8e9ddc5844d47c8c761389d7ab9650d Mon Sep 17 00:00:00 2001 From: ericek111 Date: Wed, 30 Sep 2026 21:52:28 +0000 Subject: [PATCH] Show server, channel and client details on Android, and the server's on the desktop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Core's Details says what the info box tells, as TeamSpeak's info frame does: labelled facts, then a BBCode description. The desktop info panel now lays it out instead of building its own, and selecting the server row shows the server: address, version, clients, channels, creation date and its welcome and host messages, from the virtualserver_* properties kept since initserver. On Android the channel and client sheets show the same details, asking the server for a missing channel description or a client's variables; tapping the server row opens a server sheet. Descriptions render as TeamSpeak's do: BBCode now knows [hr], [left]/[right]/[justify], numbered and lettered lists and tables, which Android's HTML parser approximates — rules as dividers, table rows as lines, list items numbered, font sizes as big/small. Links are blue whatever the wallpaper. Push-to-talk in the bottom bar follows whichever server holds the microphone, and a server without it keeps its button to take it in a fixed slot. Co-Authored-By: Claude Opus 5.5 --- .../com/ts3client/android/ServerController.kt | 28 ++++- .../com/ts3client/android/ui/ChannelTree.kt | 3 +- .../java/com/ts3client/android/ui/ChatPane.kt | 4 +- .../java/com/ts3client/android/ui/ChatText.kt | 98 +++++++++++++++- .../ts3client/android/ui/DetailsSection.kt | 108 +++++++++++++++++ .../com/ts3client/android/ui/ServerScreen.kt | 63 ++++++++-- .../java/com/ts3client/android/ui/Sheets.kt | 29 ++++- .../java/com/ts3client/android/ui/Theme.kt | 4 + .../ts3client/net/ConnectionEventHandler.java | 1 + .../main/java/com/ts3client/net/Details.java | 110 ++++++++++++++++++ .../java/com/ts3client/net/ServerModel.java | 20 ++++ .../ts3client/net/TeamspeakConnection.java | 1 + .../main/java/com/ts3client/text/BBCode.java | 21 +++- .../java/com/ts3client/net/DetailsTest.java | 47 ++++++++ .../com/ts3client/text/BBCodeTagsTest.java | 34 ++++++ .../main/java/com/ts3client/ui/InfoPanel.java | 109 ++++++++--------- .../ts3client/ui/ServerTabTreeActions.java | 3 + 17 files changed, 599 insertions(+), 84 deletions(-) create mode 100644 android/app/src/main/java/com/ts3client/android/ui/DetailsSection.kt create mode 100644 ts3-client/core/src/main/java/com/ts3client/net/Details.java create mode 100644 ts3-client/core/src/test/java/com/ts3client/net/DetailsTest.java create mode 100644 ts3-client/core/src/test/java/com/ts3client/text/BBCodeTagsTest.java 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 e82fc1e..f2172be 100644 --- a/android/app/src/main/java/com/ts3client/android/ServerController.kt +++ b/android/app/src/main/java/com/ts3client/android/ServerController.kt @@ -2,6 +2,7 @@ package com.ts3client.android import com.ts3client.contacts.ContactType import com.ts3client.net.ChannelNode +import com.ts3client.net.Details import com.ts3client.net.ConnectionListener.ChatScope import com.ts3client.session.ServerSession import com.ts3client.text.BBCode @@ -67,6 +68,10 @@ class ServerController internal constructor( if (value is ChatKey.Private) app.notifier.clear(id, value.clientId) } + /** Counts the answers to info requests, for the details on screen to be read again. */ + private val _infoUpdates = MutableStateFlow(0) + val infoUpdates: StateFlow = _infoUpdates + /** A private chat a notification asked to show, until the UI took it up. */ private val _chatRequest = MutableStateFlow(null) val chatRequest: StateFlow = _chatRequest @@ -149,6 +154,27 @@ class ServerController internal constructor( conn.model.getClient(clientId)?.let { conn.setClientVolumeModifier(it, decibels) } } + // ---- details ---- + + fun serverDetails(): Details? = + if (session.isConnected) Details.server(conn.model, "${conn.serverHost}:${conn.serverPort}") else null + + fun channelDetails(channelId: Int): Details? = conn.model.getChannel(channelId)?.let(Details::channel) + + fun clientDetails(clientId: Int): Details? = conn.model.getClient(clientId)?.let { + Details.client(it, conn.model, app.contactStore.find(it.uniqueId)) + } + + /** Asks the server for what the channel's details lack; they change once it answers. */ + fun requestChannelDetails(channelId: Int) { + val channel = conn.model.getChannel(channelId) ?: return + if (session.isConnected && !channel.descriptionLoaded) conn.requestChannelInfo(channelId) + } + + fun requestClientDetails(clientId: Int) { + if (session.isConnected) conn.requestClientInfo(clientId) + } + /** Files a client on the server as a contact; a new entry takes the type's defaults. */ fun addContact(clientId: Int, type: ContactType) { val client = conn.model.getClient(clientId) ?: return @@ -291,7 +317,7 @@ class ServerController internal constructor( } override fun onModelChanged() = scheduleTree(structure = true) - override fun onInfoUpdated() {} + override fun onInfoUpdated() = _infoUpdates.update { it + 1 } override fun onIconsUpdated() {} override fun onChat(scope: ChatScope, fromClientId: Int, fromUniqueId: String?, fromName: String, message: String) { diff --git a/android/app/src/main/java/com/ts3client/android/ui/ChannelTree.kt b/android/app/src/main/java/com/ts3client/android/ui/ChannelTree.kt index e8e0466..b2c5ee9 100644 --- a/android/app/src/main/java/com/ts3client/android/ui/ChannelTree.kt +++ b/android/app/src/main/java/com/ts3client/android/ui/ChannelTree.kt @@ -77,12 +77,13 @@ fun ChannelTree( onToggle: (ChannelItem) -> Unit, onChannel: (ChannelItem) -> Unit, onClient: (ClientItem) -> Unit, + onServer: () -> Unit, modifier: Modifier = Modifier, ) { val rows = remember(tree, collapsed, currentChannel) { flatten(tree, collapsed, currentChannel) } LazyColumn(modifier.fillMaxSize(), contentPadding = PaddingValues(vertical = 4.dp)) { item(key = "server") { - RowLayout(depth = 0, onClick = null) { + RowLayout(depth = 0, onClick = onServer) { PackIcon("SERVER_GREEN", null, size = ICON) Text(serverName, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis) } diff --git a/android/app/src/main/java/com/ts3client/android/ui/ChatPane.kt b/android/app/src/main/java/com/ts3client/android/ui/ChatPane.kt index 54299ce..2cd7b04 100644 --- a/android/app/src/main/java/com/ts3client/android/ui/ChatPane.kt +++ b/android/app/src/main/java/com/ts3client/android/ui/ChatPane.kt @@ -132,7 +132,6 @@ private fun ConversationChip( @Composable private fun Lines(lines: List, onLink: (String) -> Unit, modifier: Modifier) { val listState = rememberLazyListState(initialFirstVisibleItemIndex = (lines.size - 1).coerceAtLeast(0)) - val linkColor = MaterialTheme.colorScheme.primary val link by rememberUpdatedState(onLink) LaunchedEffect(lines.lastOrNull()?.id) { val info = listState.layoutInfo @@ -141,8 +140,7 @@ private fun Lines(lines: List, onLink: (String) -> Unit, modifier: Mod } LazyColumn(modifier.fillMaxWidth(), state = listState, contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp)) { items(lines, key = { it.id }) { line -> - val text = remember(line.html, linkColor) { chatText(line.html, linkColor) { link(it) } } - if (text.isNotEmpty()) Text(text, Modifier.padding(vertical = 2.dp), style = MaterialTheme.typography.bodyMedium) + RichText(line.html, { link(it) }, Modifier.padding(vertical = 2.dp), MaterialTheme.typography.bodyMedium) } } } 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 54204b6..f7be549 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 @@ -1,20 +1,58 @@ package com.ts3client.android.ui +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.LinkAnnotation import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextLinkStyles +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.fromHtml +import androidx.compose.ui.unit.dp import com.ts3client.text.ChatHtml /** - * The HTML of a chat line as styled text. Android's HTML parser knows no stylesheets, so - * the classes [ChatHtml] marks lines with become inline colours first. + * HTML as [BBCode][com.ts3client.text.BBCode] and [ChatHtml] write it, with its rules as dividers: + * Android's HTML parser knows none, so the text is split at them. + */ +@Composable +fun RichText(html: String, onLink: (String) -> Unit, modifier: Modifier = Modifier, style: TextStyle = LocalTextStyle.current) { + val link by rememberUpdatedState(onLink) + val color = linkColor() + val parts = remember(html, color) { html.split(RULE).map { chatText(it, color) { href -> link(href) } } } + Column(modifier) { + parts.forEachIndexed { i, text -> + if (i > 0) HorizontalDivider(Modifier.padding(vertical = 6.dp)) + // As wide as there is room, for aligned paragraphs to have somewhere to go. + if (text.isNotEmpty()) Text(text, Modifier.fillMaxWidth(), style = style) + } + } +} + +/** + * HTML without rules as styled text. Android's parser knows no stylesheets, tables, + * numbered lists, font sizes or `align`, so the classes [ChatHtml] marks lines with become + * inline colours, table rows lines of their cells, numbered items numbered lines, sizes + * ``/``, and alignment the style it does know. */ fun chatText(html: String, linkColor: Color, onLink: (String) -> Unit): AnnotatedString { - val styled = CLASS.replace(html) { m -> CLASS_COLORS[m.groupValues[1]]?.let { "style=\"color:$it\"" } ?: "" } + val styled = sizes(numbered(CLASS.replace(html) { m -> + CLASS_COLORS[m.groupValues[1]]?.let { "style=\"color:$it\"" } ?: "" + })) + .replace(ALIGN) { m -> "style=\"text-align:${ALIGNS.getValue(m.groupValues[1])}\"" } + .replace(CELL_END, " ") + .replace(ROW_END, "
") val text = AnnotatedString.fromHtml( styled, TextLinkStyles(SpanStyle(color = linkColor, fontWeight = FontWeight.SemiBold)), @@ -25,7 +63,61 @@ fun chatText(html: String, linkColor: Color, onLink: (String) -> Unit): Annotate return if (end <= start) AnnotatedString("") else text.subSequence(start, end) } +/** Numbers the items of each `
    `, innermost first. */ +private fun numbered(html: String): String { + var out = html + while (true) { + val list = INNER_OL.find(out) ?: return out + val letters = list.groupValues[1].isNotEmpty() + var n = 0 + val items = LI.replace(list.groupValues[2]) { m -> + if (m.value.startsWith("" + (if (letters) ('a' + (n - 1) % 26).toString() else n.toString()) + ". " + } + } + out = out.replaceRange(list.range, "
    $items
    ") + } +} + +/** + * Turns each font-size span into ``s or `` (or a plain span), closing it alike. + * Sizes are TeamSpeak's, around its 11 px text. + */ +private fun sizes(html: String): String { + val closes = ArrayDeque() + return SPAN.replace(html) { m -> + if (m.value.startsWith(" listOf("span") + px >= BIGGER_PX -> listOf("big", "big") + px >= BIG_PX -> listOf("big") + px <= SMALL_PX -> listOf("small") + else -> listOf("span") + } + closes.addLast(tags.reversed().joinToString("") { "" }) + if (px == null) m.value else tags.joinToString("") { "<$it>" } + } + } +} + +private const val BIGGER_PX = 18 +private const val BIG_PX = 13 +private const val SMALL_PX = 9 + +private val INNER_OL = Regex("((?:(?!]).)*?)
", RegexOption.DOT_MATCHES_ALL) +private val LI = Regex("") +private val SPAN = Regex("|]*>|") +private val ALIGNS = mapOf("left" to "start", "justify" to "start", "center" to "center", "right" to "end") + +private val RULE = Regex("", RegexOption.IGNORE_CASE) private val CLASS = Regex("class=\"([a-z]+)\"") +private val ALIGN = Regex("align=\"(left|center|right|justify)\"") +private val CELL_END = Regex("") +private val ROW_END = Regex("") private val CLASS_COLORS = mapOf( ChatHtml.MUTED_CLASS to "#8c8c8c", 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 new file mode 100644 index 0000000..26f5104 --- /dev/null +++ b/android/app/src/main/java/com/ts3client/android/ui/DetailsSection.kt @@ -0,0 +1,108 @@ +package com.ts3client.android.ui + +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Dp +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. */ +@Composable +fun DetailsSection(details: Details, onLink: (String) -> Unit, modifier: Modifier = Modifier) { + 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.label(), field.value()) + } + } + when { + details.description().isNotBlank() -> Description(details.description(), onLink) + details.pending() -> Text("Loading description…", style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } +} + +@Composable +private fun Fact(label: String, value: String) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text(label, Modifier.width(96.dp), style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(value, style = MaterialTheme.typography.bodySmall) + } +} + +/** The description in a box that opens up, when there is more of it than fits folded. */ +@Composable +private fun Description(bbcode: String, onLink: (String) -> Unit) { + val html = remember(bbcode) { BBCode.toHtml(bbcode) } + var expanded by rememberSaveable(bbcode) { mutableStateOf(false) } + var overflows by remember(bbcode) { mutableStateOf(false) } + val expandable = overflows || expanded + val background = MaterialTheme.colorScheme.surfaceContainerHigh + Surface( + Modifier.fillMaxWidth().then(if (expandable) Modifier.clickable { expanded = !expanded } else Modifier), + shape = RoundedCornerShape(12.dp), + color = background, + ) { + Column(Modifier.animateContentSize().padding(12.dp)) { + Box { + Folded(FOLDED_HEIGHT, expanded, onOverflow = { overflows = it }) { + RichText(html, onLink, style = MaterialTheme.typography.bodyMedium) + } + if (overflows && !expanded) { + Box(Modifier.align(Alignment.BottomCenter).fillMaxWidth().height(24.dp) + .background(Brush.verticalGradient(listOf(Color.Transparent, background)))) + } + } + if (expandable) { + Text(if (expanded) "Less" else "More", Modifier.padding(top = 4.dp), + style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary) + } + } + } +} + +/** Its content cut to [height] unless [open], telling whether there is more of it than that. */ +@Composable +private fun Folded(height: Dp, open: Boolean, onOverflow: (Boolean) -> Unit, content: @Composable () -> Unit) { + val report by rememberUpdatedState(onOverflow) + Layout(content, Modifier.clipToBounds()) { measurables, constraints -> + val placeable = measurables.first().measure(constraints.copy(maxHeight = Constraints.Infinity)) + val limit = height.roundToPx() + report(placeable.height > limit) + val shown = if (open) placeable.height else minOf(placeable.height, limit) + layout(placeable.width, shown) { placeable.place(0, 0) } + } +} + +private val FOLDED_HEIGHT = 128.dp diff --git a/android/app/src/main/java/com/ts3client/android/ui/ServerScreen.kt b/android/app/src/main/java/com/ts3client/android/ui/ServerScreen.kt index 5de04b3..c3ea9ff 100644 --- a/android/app/src/main/java/com/ts3client/android/ui/ServerScreen.kt +++ b/android/app/src/main/java/com/ts3client/android/ui/ServerScreen.kt @@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -64,11 +65,13 @@ import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.ts3client.android.ChatKey import com.ts3client.android.ServerController +import com.ts3client.android.ServerTree import com.ts3client.android.SessionController import com.ts3client.android.SessionState import com.ts3client.config.Settings import com.ts3client.net.ClientStatus import com.ts3client.text.TsLink +import kotlinx.coroutines.flow.MutableStateFlow private val ChatKeySaver = Saver( save = { key -> @@ -110,6 +113,8 @@ fun ServerScreen( val conversation = chats[chatKey] ?: chats.channel var clientSheet by remember { mutableStateOf(null) } var channelSheet by remember { mutableStateOf(null) } + var serverSheet by remember { mutableStateOf(false) } + val infoUpdates by server.infoUpdates.collectAsStateWithLifecycle() val chatRequest by server.chatRequest.collectAsStateWithLifecycle() LaunchedEffect(chatRequest) { @@ -149,9 +154,17 @@ fun ServerScreen( ) }, bottomBar = { - val self = tree.client(state.selfClientId) + // Push-to-talk talks on whichever server holds the microphone, which need not be this one. + val owner by vm.micOwner.collectAsStateWithLifecycle() + val ownerState by remember(owner) { owner?.state ?: MutableStateFlow(SessionState()) } + .collectAsStateWithLifecycle() + val ownerTree by remember(owner) { owner?.tree ?: MutableStateFlow(ServerTree()) } + .collectAsStateWithLifecycle() + val self = ownerTree.client(ownerState.selfClientId) val talking = self?.status == ClientStatus.TALKING || self?.status == ClientStatus.COMMANDER_TALKING - VoiceBar(state, talking, vm, server, onEnableMicrophone, showChat, chats.anyUnread) { showChat = !showChat } + VoiceBar(state, owner != null, talking, vm, server, onEnableMicrophone, showChat, chats.anyUnread) { + showChat = !showChat + } }, snackbarHost = { SnackbarHost(snackbar) }, ) { padding -> @@ -165,6 +178,7 @@ fun ServerScreen( onToggle = { server.toggleCollapsed(it.id) }, onChannel = { if (it.spacer == null) channelSheet = it.id }, onClient = { clientSheet = it.id }, + onServer = { serverSheet = true }, modifier = Modifier.weight(1f), ) } else { @@ -187,9 +201,12 @@ fun ServerScreen( // A sheet goes with its client or channel when that leaves the tree. clientSheet?.let { id -> + LaunchedEffect(id) { server.requestClientDetails(id) } tree.client(id)?.let { client -> ClientSheet( client = client, + details = remember(id, tree, infoUpdates) { server.clientDetails(id) }, + onLink = onLink, away = state.away, initialVolume = remember(id) { server.clientVolume(id) }, onDismiss = { clientSheet = null }, @@ -208,9 +225,12 @@ fun ServerScreen( } } channelSheet?.let { id -> + LaunchedEffect(id) { server.requestChannelDetails(id) } tree.channel(id)?.let { channel -> ChannelSheet( channel = channel, + details = remember(id, tree, infoUpdates) { server.channelDetails(id) }, + onLink = onLink, current = id == state.channelId, onDismiss = { channelSheet = null }, onJoin = { server.joinChannel(id, it); channelSheet = null }, @@ -218,6 +238,11 @@ fun ServerScreen( ) } } + if (serverSheet) { + remember(tree, infoUpdates) { server.serverDetails() }?.let { + ServerSheet(it, onLink, onDismiss = { serverSheet = false }) + } + } } /** @@ -227,6 +252,8 @@ fun ServerScreen( @Composable private fun VoiceBar( state: SessionState, + /** Whether some server holds the microphone, for push-to-talk to talk on. */ + captured: Boolean, talking: Boolean, vm: SessionController, server: ServerController, @@ -236,7 +263,7 @@ private fun VoiceBar( onChat: () -> Unit, ) { val context = LocalContext.current - val ptt = state.connected && state.micActive && state.inputMode == Settings.InputMode.PUSH_TO_TALK + val ptt = state.connected && captured && state.inputMode == Settings.InputMode.PUSH_TO_TALK val mic = @Composable { BarButton( onClick = { server.setMicMuted(!state.micMuted) }, @@ -258,7 +285,12 @@ private fun VoiceBar( else PackIcon("PLAYBACK", "Deafen") } } - // Without the talk button, what the microphone needs next: the permission, or to come to this server. + // Another server holds the microphone. + val activate = @Composable { + if (state.connected && state.micPermission && !state.micActive) { + IconButton(onClick = onEnableMicrophone) { PackIcon("ACTIVATE_MICROPHONE", "Speak on this server") } + } + } val middle = @Composable { when { ptt -> PushToTalk(talking, vm::setPushToTalk, Modifier.fillMaxWidth()) @@ -266,8 +298,6 @@ private fun VoiceBar( PackIcon("CAPTURE", null) Text("Use microphone", Modifier.padding(start = 8.dp), maxLines = 1) } - state.connected && !state.micActive -> - IconButton(onClick = onEnableMicrophone) { PackIcon("ACTIVATE_MICROPHONE", "Speak on this server") } } } val away = @Composable { @@ -284,17 +314,30 @@ private fun VoiceBar( } } Surface(tonalElevation = 3.dp) { - // The same places whatever shows: equal sides keep the talk button's space centred. + // The same places whatever shows: sides of three buttons each keep the talk button centred. Row(Modifier.fillMaxWidth().navigationBarsPadding().padding(horizontal = 8.dp, vertical = 4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) { - Row(Modifier.weight(1f), horizontalArrangement = Arrangement.SpaceEvenly) { mic(); deafen() } - Box(Modifier.weight(1.3f).height(48.dp), contentAlignment = Alignment.CenterStart) { middle() } - Row(Modifier.weight(1f), horizontalArrangement = Arrangement.SpaceEvenly) { away(); chat() } + Row(Modifier.width(SIDE), verticalAlignment = Alignment.CenterVertically) { + Slot { mic() }; Slot { deafen() }; Slot { activate() } + } + Box(Modifier.weight(1f).height(48.dp), contentAlignment = Alignment.Center) { middle() } + Row(Modifier.width(SIDE), horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically) { + Slot { away() }; Slot { chat() } + } } } } +private val SLOT = 48.dp +private val SIDE = SLOT * 3 + +/** One button's place in the bar, kept whether or not the button shows. */ +@Composable +private fun Slot(content: @Composable () -> Unit) { + Box(Modifier.size(SLOT), contentAlignment = Alignment.Center) { content() } +} + /** An icon button that also answers a long press. */ @Composable private fun BarButton(onClick: () -> Unit, onLongClick: () -> Unit, enabled: Boolean, content: @Composable () -> Unit) { 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 1bfc512..abb6317 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 @@ -29,9 +29,12 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp import androidx.compose.foundation.clickable +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import com.ts3client.android.ChannelItem import com.ts3client.android.ClientItem import com.ts3client.contacts.ContactType +import com.ts3client.net.Details import kotlin.math.roundToInt /** What can be done with a client: the private chat, a poke, their volume, filing them as a contact. */ @@ -39,6 +42,8 @@ import kotlin.math.roundToInt @Composable fun ClientSheet( client: ClientItem, + details: Details?, + onLink: (String) -> Unit, away: Boolean, initialVolume: Double, onDismiss: () -> Unit, @@ -52,7 +57,7 @@ fun ClientSheet( ) { var poking by remember { mutableStateOf(false) } ModalBottomSheet(onDismissRequest = onDismiss) { - Column(Modifier.navigationBarsPadding().padding(bottom = 16.dp)) { + Column(Modifier.verticalScroll(rememberScrollState()).navigationBarsPadding().padding(bottom = 16.dp)) { Row(Modifier.padding(horizontal = 24.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { StatusIcon(client.status) @@ -62,6 +67,7 @@ fun ClientSheet( if (client.awayMessage.isNotEmpty()) Text(client.awayMessage, style = MaterialTheme.typography.bodyMedium) } } + details?.let { DetailsSection(it, onLink, Modifier.padding(bottom = 8.dp)) } if (client.self) { Action("AWAY", if (away) "Back" else "Away") { onAway(!away) } } else { @@ -106,6 +112,8 @@ fun ClientSheet( @Composable fun ChannelSheet( channel: ChannelItem, + details: Details?, + onLink: (String) -> Unit, current: Boolean, onDismiss: () -> Unit, onJoin: (String?) -> Unit, @@ -113,9 +121,10 @@ fun ChannelSheet( ) { var askPassword by remember { mutableStateOf(false) } ModalBottomSheet(onDismissRequest = onDismiss) { - Column(Modifier.navigationBarsPadding().padding(bottom = 16.dp)) { + Column(Modifier.verticalScroll(rememberScrollState()).navigationBarsPadding().padding(bottom = 16.dp)) { Text(channel.name, Modifier.padding(horizontal = 24.dp, vertical = 8.dp), style = MaterialTheme.typography.titleLarge) + details?.let { DetailsSection(it, onLink, Modifier.padding(bottom = 8.dp)) } if (!current) { Action("CHANNEL_SWITCH", "Join") { if (channel.locked) askPassword = true else onJoin(null) @@ -140,6 +149,22 @@ fun ChannelSheet( } } +/** The server's details: its version, who is on it, its welcome message. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ServerSheet(details: Details, onLink: (String) -> Unit, onDismiss: () -> Unit) { + ModalBottomSheet(onDismissRequest = onDismiss) { + Column(Modifier.verticalScroll(rememberScrollState()).navigationBarsPadding().padding(bottom = 16.dp)) { + Row(Modifier.padding(horizontal = 24.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp)) { + PackIcon("SERVER_GREEN", null) + Text(details.title(), style = MaterialTheme.typography.titleLarge) + } + DetailsSection(details, onLink) + } + } +} + @Composable private fun Action(iconKey: String, label: String, onClick: () -> Unit) { ListItem( diff --git a/android/app/src/main/java/com/ts3client/android/ui/Theme.kt b/android/app/src/main/java/com/ts3client/android/ui/Theme.kt index 0fcea09..c7895d2 100644 --- a/android/app/src/main/java/com/ts3client/android/ui/Theme.kt +++ b/android/app/src/main/java/com/ts3client/android/ui/Theme.kt @@ -17,6 +17,10 @@ object StatusColors { val blocked = Color(0xFFD9443A) } +/** Links in chat and descriptions: blue, as links are, whatever the wallpaper's colours. */ +@Composable +fun linkColor(): Color = if (isSystemInDarkTheme()) Color(0xFF7AB4FF) else Color(0xFF1A62D0) + /** The system's own (wallpaper) colours, which every supported Android version offers. */ @Composable fun Ts3Theme(content: @Composable () -> Unit) { diff --git a/ts3-client/core/src/main/java/com/ts3client/net/ConnectionEventHandler.java b/ts3-client/core/src/main/java/com/ts3client/net/ConnectionEventHandler.java index 3d3405b..88ceba5 100644 --- a/ts3-client/core/src/main/java/com/ts3client/net/ConnectionEventHandler.java +++ b/ts3-client/core/src/main/java/com/ts3client/net/ConnectionEventHandler.java @@ -493,6 +493,7 @@ final class ConnectionEventHandler implements TS3Listener { @Override public void onServerEdit(ServerEditedEvent e) { if (has(e, "virtualserver_name")) conn.getModel().setServerName(e.get("virtualserver_name")); + conn.getModel().putServerProperties(e.getMap()); if (conn.isConnected()) { conn.sound(byInvoker(e, SoundEvent.SERVER_EDITED_BY_YOU, SoundEvent.SERVER_EDITED_BY_OTHER, SoundEvent.SERVER_EDITED_BY_OTHER), conn.serverVars()); 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 new file mode 100644 index 0000000..4fce731 --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/net/Details.java @@ -0,0 +1,110 @@ +package com.ts3client.net; + +import com.ts3client.contacts.Contact; + +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; + +/** + * What the info box tells about the server, a channel or a client, as the TeamSpeak 3 client's + * info frame does: labelled facts, then a description in BBCode. The same on every frontend, + * which only lays it out. + * + * @param description BBCode; empty when there is none + * @param pending the description is still being asked for + */ +public record Details(String title, List fields, String description, boolean pending) { + + /** + * One labelled fact. + * + * @param groups the groups {@code value} names, in order, for frontends that show their icons; + * empty when it names none + */ + public record Field(String label, String value, List groups) { + Field(String label, String value) { + this(label, value, List.of()); + } + } + + private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + + public static Details server(ServerModel model, String address) { + List fields = new ArrayList<>(); + fields.add(new Field("Address", address)); + String version = model.serverProperty("virtualserver_version"); + String platform = model.serverProperty("virtualserver_platform"); + if (!version.isEmpty()) fields.add(new Field("Version", version + (platform.isEmpty() ? "" : " on " + platform))); + String max = model.serverProperty("virtualserver_maxclients"); + fields.add(new Field("Clients", model.clientCount() + (max.isEmpty() ? "" : " / " + max))); + fields.add(new Field("Channels", Integer.toString(model.channelCount()))); + long created = parseLong(model.serverProperty("virtualserver_created")); + if (created > 0) { + fields.add(new Field("Created", DATE.format(Instant.ofEpochSecond(created).atZone(ZoneId.systemDefault())))); + } + String welcome = model.serverProperty("virtualserver_welcomemessage").trim(); + String host = model.serverProperty("virtualserver_hostmessage").trim(); + String description = welcome.isEmpty() || host.isEmpty() ? welcome + host : welcome + "\n\n" + host; + return new Details(model.getServerName(), fields, description, false); + } + + public static Details channel(ChannelNode ch) { + List fields = new ArrayList<>(); + if (ch.maxClients >= 0) fields.add(new Field("Max clients", Integer.toString(ch.maxClients))); + if (ch.hasPassword) fields.add(new Field("Password", "protected")); + if (!ch.topic.isEmpty()) fields.add(new Field("Topic", ch.topic)); + String description = ch.description == null ? "" : ch.description; + return new Details(ch.name, fields, description, description.isEmpty() && !ch.descriptionLoaded); + } + + /** @param contact the client's contact entry, or null */ + public static Details client(ClientEntry cl, ServerModel model, Contact contact) { + List fields = new ArrayList<>(); + if (contact != null) { + String type = contact.type.label(); + if (!contact.phoneticNickname.isEmpty()) type += " (" + contact.phoneticNickname + ")"; + fields.add(new Field("Contact", type)); + } + + List serverGroups = model.serverGroupsOf(cl.serverGroupIds); + if (serverGroups.isEmpty()) { + List names = model.serverGroupNames(cl.serverGroupIds); + fields.add(new Field("Server groups", names.isEmpty() ? "—" : String.join(", ", names))); + } else { + List names = new ArrayList<>(); + for (Group g : serverGroups) names.add(g.name); + fields.add(new Field("Server groups", String.join(", ", names), serverGroups)); + } + 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.version.isEmpty()) { + fields.add(new Field("Version", cl.version + (cl.platform.isEmpty() ? "" : " on " + cl.platform))); + } else if (!cl.platform.isEmpty()) { + fields.add(new Field("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)); + + String name = contact == null ? cl.nickname : contact.displayName(cl.nickname); + return new Details(name, fields, cl.description == null ? "" : cl.description, false); + } + + private static long parseLong(String s) { + try { + return s.isEmpty() ? 0 : Long.parseLong(s.trim()); + } catch (NumberFormatException e) { + return 0; + } + } +} diff --git a/ts3-client/core/src/main/java/com/ts3client/net/ServerModel.java b/ts3-client/core/src/main/java/com/ts3client/net/ServerModel.java index 5d76afb..e98bbab 100644 --- a/ts3-client/core/src/main/java/com/ts3client/net/ServerModel.java +++ b/ts3-client/core/src/main/java/com/ts3client/net/ServerModel.java @@ -33,6 +33,8 @@ public final class ServerModel { /** Permission id -> the local client's resolved value, from {@code notifyclientneededpermissions}. */ private final Map selfPermissionValues = new LinkedHashMap<>(); private String serverName = "TeamSpeak Server"; + /** The {@code virtualserver_*} properties from {@code initserver}, kept up to date by server edits. */ + private final Map serverProperties = new HashMap<>(); /** The channel group everyone starts in, e.g. "Guest" — not worth offering to (re)assign. */ private int defaultChannelGroupId; @@ -45,6 +47,7 @@ public final class ServerModel { permissionIds.clear(); nextPermissionId = FIRST_PERMISSION_ID; selfPermissionValues.clear(); + serverProperties.clear(); defaultChannelGroupId = 0; } @@ -207,6 +210,19 @@ public final class ServerModel { if (name != null && !name.isEmpty()) this.serverName = name; } + /** Takes on the server properties among {@code properties}, keeping the others. */ + public synchronized void putServerProperties(Map properties) { + properties.forEach((key, value) -> { + // An empty property comes as a bare key, without a value. + if (key.startsWith("virtualserver_")) serverProperties.put(key, value == null ? "" : value); + }); + } + + /** @return the server property, empty when unknown */ + public synchronized String serverProperty(String key) { + return serverProperties.getOrDefault(key, ""); + } + public synchronized int defaultChannelGroupId() { return defaultChannelGroupId; } @@ -450,6 +466,10 @@ public final class ServerModel { siblings.addAll(ordered); } + public synchronized int channelCount() { + return channels.size(); + } + public synchronized int clientCount() { int n = 0; for (ClientEntry c : clients.values()) if (!c.isQuery()) n++; diff --git a/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java b/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java index 9ad1f55..e31200e 100644 --- a/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java +++ b/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java @@ -500,6 +500,7 @@ public final class TeamspeakConnection implements TS3Listener { } // The name comes with initserver; servergetvariables never reports it. model.setServerName(client.getServerName()); + model.putServerProperties(client.getServerProperties()); model.setDefaultChannelGroupId( (int) safeLong(client.getServerProperties().get("virtualserver_default_channel_group"))); } diff --git a/ts3-client/core/src/main/java/com/ts3client/text/BBCode.java b/ts3-client/core/src/main/java/com/ts3client/text/BBCode.java index 132de96..411ab1d 100644 --- a/ts3-client/core/src/main/java/com/ts3client/text/BBCode.java +++ b/ts3-client/core/src/main/java/com/ts3client/text/BBCode.java @@ -136,10 +136,28 @@ public final class BBCode { return push(out, open, name, "", ""); case "s": return push(out, open, name, "", ""); + case "hr": + // Stands alone; a closing tag some write after it is dropped, see closeTag. + out.append("
"); + return true; + case "left": case "center": - return push(out, open, name, "
", "
"); + case "right": + case "justify": + return push(out, open, name, "
", "
"); case "list": + if (value.equals("1")) return push(out, open, name, "
    ", "
"); + if (value.equalsIgnoreCase("a")) return push(out, open, name, "
    ", "
"); return push(out, open, name, "
    ", "
"); + case "table": + return push(out, open, name, "", "
"); + case "tr": + if (!contains(open, "table")) return false; + return push(out, open, name, "", ""); + case "td": + case "th": + if (!contains(open, "tr")) return false; + return push(out, open, name, "<" + name + ">", ""); case "color": { if (!COLOR.matcher(value).matches()) return false; return push(out, open, name, "", ""); @@ -159,6 +177,7 @@ public final class BBCode { } private static boolean closeTag(StringBuilder out, String name, Deque open) { + if (name.equals("hr")) return true; if (!contains(open, name)) return false; // Close anything left dangling inside, then the tag itself. while (!open.isEmpty()) { diff --git a/ts3-client/core/src/test/java/com/ts3client/net/DetailsTest.java b/ts3-client/core/src/test/java/com/ts3client/net/DetailsTest.java new file mode 100644 index 0000000..90a8e5d --- /dev/null +++ b/ts3-client/core/src/test/java/com/ts3client/net/DetailsTest.java @@ -0,0 +1,47 @@ +package com.ts3client.net; + +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class DetailsTest { + + @Test + void serverTakesInitserverAndEditsAndBareKeys() { + ServerModel model = new ServerModel(); + model.setServerName("Test"); + Map init = new HashMap<>(); + init.put("virtualserver_version", "3.13.7"); + init.put("virtualserver_platform", "Linux"); + init.put("virtualserver_maxclients", "32"); + init.put("virtualserver_welcomemessage", "Hello"); + // An empty property comes as a bare key. + init.put("virtualserver_hostmessage", null); + model.putServerProperties(init); + model.putServerProperties(Map.of("virtualserver_hostmessage", "Be nice", "invokerid", "5")); + + Details d = Details.server(model, "host:9987"); + assertEquals("Test", d.title()); + assertEquals(new Details.Field("Version", "3.13.7 on Linux"), d.fields().get(1)); + assertEquals(new Details.Field("Clients", "0 / 32"), d.fields().get(2)); + assertEquals("Hello\n\nBe nice", d.description()); + assertEquals("", model.serverProperty("invokerid")); + } + + @Test + void channelDescriptionIsPendingUntilAnswered() { + ChannelNode ch = new ChannelNode(1, "Lobby"); + ch.topic = "Entry"; + assertTrue(Details.channel(ch).pending()); + ch.descriptionLoaded = true; + Details d = Details.channel(ch); + assertFalse(d.pending()); + assertEquals(List.of(new Details.Field("Topic", "Entry")), d.fields()); + } +} diff --git a/ts3-client/core/src/test/java/com/ts3client/text/BBCodeTagsTest.java b/ts3-client/core/src/test/java/com/ts3client/text/BBCodeTagsTest.java new file mode 100644 index 0000000..3a61e3d --- /dev/null +++ b/ts3-client/core/src/test/java/com/ts3client/text/BBCodeTagsTest.java @@ -0,0 +1,34 @@ +package com.ts3client.text; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** The block tags TeamSpeak's own client renders in descriptions. */ +class BBCodeTagsTest { + + @Test + void rulesStandAlone() { + assertEquals("a
b", BBCode.toHtml("a[hr]b")); + assertEquals("a
b", BBCode.toHtml("a[hr][/hr]b")); + } + + @Test + void alignsBlocks() { + assertEquals("
x
y
", + BBCode.toHtml("[right]x[/right][justify]y[/justify]")); + } + + @Test + void numbersLists() { + assertEquals("
  1. one
  2. two
", BBCode.toHtml("[list=1][*]one[*]two[/list]")); + assertEquals("
  1. x
", BBCode.toHtml("[list=a][*]x[/list]")); + } + + @Test + void buildsTablesOnlyWhereTheyNest() { + assertEquals("
hd
", + BBCode.toHtml("[table][tr][th]h[/th][td]d[/td][/tr][/table]")); + assertEquals("[td]loose[/td]", BBCode.toHtml("[td]loose[/td]")); + } +} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/InfoPanel.java b/ts3-client/swing/src/main/java/com/ts3client/ui/InfoPanel.java index 7d27b0d..f37c985 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/InfoPanel.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/InfoPanel.java @@ -5,6 +5,7 @@ import com.formdev.flatlaf.util.UIScale; import com.ts3client.contacts.Contact; import com.ts3client.net.ChannelNode; import com.ts3client.net.ClientEntry; +import com.ts3client.net.Details; import com.ts3client.net.Group; import com.ts3client.net.IconRepository; import com.ts3client.net.ServerModel; @@ -29,9 +30,8 @@ import java.io.File; import java.util.List; /** - * Read-only detail view for the currently selected channel or client, mirroring - * the TeamSpeak 3 info box: channel topic/description, or a client's server and - * channel groups, platform and version. + * Read-only detail view for the server, channel or client selected, mirroring the + * TeamSpeak 3 info box; what it says comes from {@link Details}. */ public final class InfoPanel extends JPanel { @@ -57,6 +57,8 @@ public final class InfoPanel extends JPanel { private DescriptionHandler descriptionHandler; private ChannelNode shownChannel; private ClientEntry shownClient; + /** The server's address while the server itself is shown. */ + private String shownServer; private ServerModel shownModel; private IconRepository shownIcons; private Contact shownContact; @@ -143,6 +145,7 @@ public final class InfoPanel extends JPanel { public void clear() { shownChannel = null; shownClient = null; + shownServer = null; inChatTab = false; render(); } @@ -157,9 +160,18 @@ public final class InfoPanel extends JPanel { render(); } + public void showServer(ServerModel model, String address) { + shownServer = address; + shownModel = model; + shownChannel = null; + shownClient = null; + render(); + } + public void showChannel(ChannelNode ch) { shownChannel = ch; shownClient = null; + shownServer = null; render(); } @@ -168,6 +180,7 @@ public final class InfoPanel extends JPanel { Avatar avatar) { shownClient = cl; shownChannel = null; + shownServer = null; shownModel = model; shownIcons = icons; shownContact = contact; @@ -176,7 +189,7 @@ public final class InfoPanel extends JPanel { } private void render() { - boolean selected = shownChannel != null || shownClient != null; + boolean selected = shownChannel != null || shownClient != null || shownServer != null; boolean hide = inChatTab && selected; if (descriptionHandler != null) descriptionHandler.setPanelHidden(hide); toggle.setVisible(selected && !hide); @@ -188,12 +201,13 @@ public final class InfoPanel extends JPanel { } private String body() { - if (shownChannel != null) return channelBody(shownChannel); + if (shownServer != null) return html(Details.server(shownModel, shownServer), "", null, null); + if (shownChannel != null) return html(Details.channel(shownChannel), "", null, "No description."); if (shownClient != null) { renderedAvatarBox = inChatTab ? UIScale.scale(AvatarHtml.MAX_SIZE) : avatarBox(); return clientBody(shownClient, shownModel, shownIcons, shownContact, shownAvatar, renderedAvatarBox); } - return "Select a channel or client to see details."; + return "Select the server, a channel or a client to see details."; } /** @@ -210,23 +224,6 @@ public final class InfoPanel extends JPanel { return Math.max(UIScale.scale(AVATAR_MIN_SIZE), Math.min(max, box)); } - private static String channelBody(ChannelNode ch) { - StringBuilder sb = new StringBuilder(); - sb.append(heading(esc(ch.name))); - if (ch.maxClients >= 0) row(sb, "Max clients", Integer.toString(ch.maxClients)); - if (ch.hasPassword) row(sb, "Password", "protected"); - if (!ch.topic.isEmpty()) row(sb, "Topic", esc(ch.topic)); - sb.append("
"); - if (ch.description != null && !ch.description.isEmpty()) { - sb.append("
").append(multiline(ch.description)).append("
"); - } else if (ch.descriptionLoaded) { - sb.append("No description."); - } else { - sb.append("Loading description…"); - } - return sb.toString(); - } - private static String clientBody(ClientEntry cl, ServerModel model, IconRepository icons, Contact contact, Avatar avatar, int avatarBox) { String details = clientDetails(cl, model, icons, contact); @@ -240,49 +237,35 @@ public final class InfoPanel extends JPanel { } private static String clientDetails(ClientEntry cl, ServerModel model, IconRepository icons, Contact contact) { + String you = cl.self ? " (you)" : ""; + return html(Details.client(cl, model, contact), you, icons, null); + } + + /** + * @param titleSuffix HTML after the title + * @param icons for the icons of the groups a field names; null for none + * @param none what to say when there is no description; null to say nothing + */ + private static String html(Details details, String titleSuffix, IconRepository icons, String none) { StringBuilder sb = new StringBuilder(); - String name = contact == null ? cl.nickname : contact.displayName(cl.nickname); - sb.append(heading(esc(name) + (cl.self ? " (you)" : ""))); - if (contact != null) { - String type = contact.type.label(); - if (!contact.phoneticNickname.isEmpty()) type += " (" + esc(contact.phoneticNickname) + ")"; - row(sb, "Contact", type); + sb.append(heading(esc(details.title()) + titleSuffix)); + for (Details.Field f : details.fields()) row(sb, esc(f.label()), value(f, icons)); + if (!details.description().isEmpty()) { + sb.append("
").append(multiline(details.description())).append("
"); + } else if (details.pending()) { + sb.append("
Loading description…"); + } else if (none != null) { + sb.append("
").append(none).append(""); } + return sb.toString(); + } - List serverGroups = model.serverGroupsOf(cl.serverGroupIds); - if (serverGroups.isEmpty()) { - List names = model.serverGroupNames(cl.serverGroupIds); - row(sb, "Server groups", names.isEmpty() ? "—" : esc(String.join(", ", names))); - } else { - StringBuilder groups = new StringBuilder(); - for (Group g : serverGroups) { - if (groups.length() > 0) groups.append(", "); - groups.append(iconTag(g, icons)).append(esc(g.name)); - } - row(sb, "Server groups", groups.toString()); - } - - Group channelGroup = model.channelGroup(cl.channelGroupId); - row(sb, "Channel group", channelGroup != null - ? iconTag(channelGroup, icons) + esc(channelGroup.name) - : "#" + cl.channelGroupId); - - if (cl.talkPower != 0) row(sb, "Talk power", Integer.toString(cl.talkPower)); - if (!cl.version.isEmpty()) { - row(sb, "Version", esc(cl.version) - + (cl.platform.isEmpty() ? "" : " on " + esc(cl.platform))); - } else if (!cl.platform.isEmpty()) { - row(sb, "Platform", esc(cl.platform)); - } - if (cl.away) row(sb, "Status", "Away"); - if (!cl.inputHardware) row(sb, "Microphone", "disabled"); - else if (cl.inputMuted) row(sb, "Microphone", "muted"); - if (!cl.outputHardware) row(sb, "Speakers", "disabled"); - else if (cl.outputMuted) row(sb, "Speakers", "muted"); - if (!cl.uniqueId.isEmpty()) row(sb, "Unique ID", esc(cl.uniqueId)); - - if (cl.description != null && !cl.description.isEmpty()) { - sb.append("
").append(multiline(cl.description)).append("
"); + private static String value(Details.Field field, IconRepository icons) { + if (field.groups().isEmpty() || icons == null) return esc(field.value()); + StringBuilder sb = new StringBuilder(); + for (Group g : field.groups()) { + if (sb.length() > 0) sb.append(", "); + sb.append(iconTag(g, icons)).append(esc(g.name)); } return sb.toString(); } diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabTreeActions.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabTreeActions.java index 1de4e1e..ed7612e 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabTreeActions.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabTreeActions.java @@ -64,6 +64,9 @@ final class ServerTabTreeActions implements ServerTreePanel.Actions { ClientEntry client = (ClientEntry) sel; infoPanel.showClient(client, conn.getModel(), conn.getIcons(), conn.getContacts().find(client.uniqueId), conn.avatarOf(client)); + } else if (sel instanceof String && conn.isConnected()) { + // The root row is the server itself, whose user object is just its name. + infoPanel.showServer(conn.getModel(), conn.getServerHost() + ":" + conn.getServerPort()); } else { infoPanel.clear(); }