Show server, channel and client details on Android, and the server's on the desktop

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 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 21:52:28 +00:00
parent 9b1aa45611
commit cd02e4b2d8
17 changed files with 599 additions and 84 deletions

View File

@@ -2,6 +2,7 @@ package com.ts3client.android
import com.ts3client.contacts.ContactType import com.ts3client.contacts.ContactType
import com.ts3client.net.ChannelNode import com.ts3client.net.ChannelNode
import com.ts3client.net.Details
import com.ts3client.net.ConnectionListener.ChatScope import com.ts3client.net.ConnectionListener.ChatScope
import com.ts3client.session.ServerSession import com.ts3client.session.ServerSession
import com.ts3client.text.BBCode import com.ts3client.text.BBCode
@@ -67,6 +68,10 @@ class ServerController internal constructor(
if (value is ChatKey.Private) app.notifier.clear(id, value.clientId) 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<Int> = _infoUpdates
/** A private chat a notification asked to show, until the UI took it up. */ /** A private chat a notification asked to show, until the UI took it up. */
private val _chatRequest = MutableStateFlow<ChatKey.Private?>(null) private val _chatRequest = MutableStateFlow<ChatKey.Private?>(null)
val chatRequest: StateFlow<ChatKey.Private?> = _chatRequest val chatRequest: StateFlow<ChatKey.Private?> = _chatRequest
@@ -149,6 +154,27 @@ class ServerController internal constructor(
conn.model.getClient(clientId)?.let { conn.setClientVolumeModifier(it, decibels) } 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. */ /** Files a client on the server as a contact; a new entry takes the type's defaults. */
fun addContact(clientId: Int, type: ContactType) { fun addContact(clientId: Int, type: ContactType) {
val client = conn.model.getClient(clientId) ?: return val client = conn.model.getClient(clientId) ?: return
@@ -291,7 +317,7 @@ class ServerController internal constructor(
} }
override fun onModelChanged() = scheduleTree(structure = true) override fun onModelChanged() = scheduleTree(structure = true)
override fun onInfoUpdated() {} override fun onInfoUpdated() = _infoUpdates.update { it + 1 }
override fun onIconsUpdated() {} override fun onIconsUpdated() {}
override fun onChat(scope: ChatScope, fromClientId: Int, fromUniqueId: String?, fromName: String, message: String) { override fun onChat(scope: ChatScope, fromClientId: Int, fromUniqueId: String?, fromName: String, message: String) {

View File

@@ -77,12 +77,13 @@ fun ChannelTree(
onToggle: (ChannelItem) -> Unit, onToggle: (ChannelItem) -> Unit,
onChannel: (ChannelItem) -> Unit, onChannel: (ChannelItem) -> Unit,
onClient: (ClientItem) -> Unit, onClient: (ClientItem) -> Unit,
onServer: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val rows = remember(tree, collapsed, currentChannel) { flatten(tree, collapsed, currentChannel) } val rows = remember(tree, collapsed, currentChannel) { flatten(tree, collapsed, currentChannel) }
LazyColumn(modifier.fillMaxSize(), contentPadding = PaddingValues(vertical = 4.dp)) { LazyColumn(modifier.fillMaxSize(), contentPadding = PaddingValues(vertical = 4.dp)) {
item(key = "server") { item(key = "server") {
RowLayout(depth = 0, onClick = null) { RowLayout(depth = 0, onClick = onServer) {
PackIcon("SERVER_GREEN", null, size = ICON) PackIcon("SERVER_GREEN", null, size = ICON)
Text(serverName, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis) Text(serverName, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis)
} }

View File

@@ -132,7 +132,6 @@ private fun ConversationChip(
@Composable @Composable
private fun Lines(lines: List<ChatLine>, onLink: (String) -> Unit, modifier: Modifier) { private fun Lines(lines: List<ChatLine>, onLink: (String) -> Unit, modifier: Modifier) {
val listState = rememberLazyListState(initialFirstVisibleItemIndex = (lines.size - 1).coerceAtLeast(0)) val listState = rememberLazyListState(initialFirstVisibleItemIndex = (lines.size - 1).coerceAtLeast(0))
val linkColor = MaterialTheme.colorScheme.primary
val link by rememberUpdatedState(onLink) val link by rememberUpdatedState(onLink)
LaunchedEffect(lines.lastOrNull()?.id) { LaunchedEffect(lines.lastOrNull()?.id) {
val info = listState.layoutInfo val info = listState.layoutInfo
@@ -141,8 +140,7 @@ private fun Lines(lines: List<ChatLine>, onLink: (String) -> Unit, modifier: Mod
} }
LazyColumn(modifier.fillMaxWidth(), state = listState, contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp)) { LazyColumn(modifier.fillMaxWidth(), state = listState, contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp)) {
items(lines, key = { it.id }) { line -> items(lines, key = { it.id }) { line ->
val text = remember(line.html, linkColor) { chatText(line.html, linkColor) { link(it) } } RichText(line.html, { link(it) }, Modifier.padding(vertical = 2.dp), MaterialTheme.typography.bodyMedium)
if (text.isNotEmpty()) Text(text, Modifier.padding(vertical = 2.dp), style = MaterialTheme.typography.bodyMedium)
} }
} }
} }

View File

@@ -1,20 +1,58 @@
package com.ts3client.android.ui 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.graphics.Color
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.LinkAnnotation import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.fromHtml import androidx.compose.ui.text.fromHtml
import androidx.compose.ui.unit.dp
import com.ts3client.text.ChatHtml import com.ts3client.text.ChatHtml
/** /**
* The HTML of a chat line as styled text. Android's HTML parser knows no stylesheets, so * HTML as [BBCode][com.ts3client.text.BBCode] and [ChatHtml] write it, with its rules as dividers:
* the classes [ChatHtml] marks lines with become inline colours first. * 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
* `<big>`/`<small>`, and alignment the style it does know.
*/ */
fun chatText(html: String, linkColor: Color, onLink: (String) -> Unit): AnnotatedString { 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, "<br>")
val text = AnnotatedString.fromHtml( val text = AnnotatedString.fromHtml(
styled, styled,
TextLinkStyles(SpanStyle(color = linkColor, fontWeight = FontWeight.SemiBold)), 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) return if (end <= start) AnnotatedString("") else text.subSequence(start, end)
} }
/** Numbers the items of each `<ol>`, 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("</")) "" else {
n++
"<br>" + (if (letters) ('a' + (n - 1) % 26).toString() else n.toString()) + ". "
}
}
out = out.replaceRange(list.range, "<div>$items</div>")
}
}
/**
* Turns each font-size span into `<big>`s or `<small>` (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<String>()
return SPAN.replace(html) { m ->
if (m.value.startsWith("</")) {
closes.removeLastOrNull() ?: m.value
} else {
val px = m.groupValues[1].toIntOrNull()
val tags = when {
px == null -> 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("") { "</$it>" })
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("<ol( type=\"a\")?>((?:(?!<ol[ >]).)*?)</ol>", RegexOption.DOT_MATCHES_ALL)
private val LI = Regex("</?li>")
private val SPAN = Regex("<span style=\"font-size:(\\d+)px\">|<span[^>]*>|</span>")
private val ALIGNS = mapOf("left" to "start", "justify" to "start", "center" to "center", "right" to "end")
private val RULE = Regex("<hr\\s*/?>", RegexOption.IGNORE_CASE)
private val CLASS = Regex("class=\"([a-z]+)\"") private val CLASS = Regex("class=\"([a-z]+)\"")
private val ALIGN = Regex("align=\"(left|center|right|justify)\"")
private val CELL_END = Regex("</t[dh]>")
private val ROW_END = Regex("</tr>")
private val CLASS_COLORS = mapOf( private val CLASS_COLORS = mapOf(
ChatHtml.MUTED_CLASS to "#8c8c8c", ChatHtml.MUTED_CLASS to "#8c8c8c",

View File

@@ -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

View File

@@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@@ -64,11 +65,13 @@ import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.ts3client.android.ChatKey import com.ts3client.android.ChatKey
import com.ts3client.android.ServerController import com.ts3client.android.ServerController
import com.ts3client.android.ServerTree
import com.ts3client.android.SessionController import com.ts3client.android.SessionController
import com.ts3client.android.SessionState import com.ts3client.android.SessionState
import com.ts3client.config.Settings import com.ts3client.config.Settings
import com.ts3client.net.ClientStatus import com.ts3client.net.ClientStatus
import com.ts3client.text.TsLink import com.ts3client.text.TsLink
import kotlinx.coroutines.flow.MutableStateFlow
private val ChatKeySaver = Saver<ChatKey, String>( private val ChatKeySaver = Saver<ChatKey, String>(
save = { key -> save = { key ->
@@ -110,6 +113,8 @@ fun ServerScreen(
val conversation = chats[chatKey] ?: chats.channel val conversation = chats[chatKey] ?: chats.channel
var clientSheet by remember { mutableStateOf<Int?>(null) } var clientSheet by remember { mutableStateOf<Int?>(null) }
var channelSheet by remember { mutableStateOf<Int?>(null) } var channelSheet by remember { mutableStateOf<Int?>(null) }
var serverSheet by remember { mutableStateOf(false) }
val infoUpdates by server.infoUpdates.collectAsStateWithLifecycle()
val chatRequest by server.chatRequest.collectAsStateWithLifecycle() val chatRequest by server.chatRequest.collectAsStateWithLifecycle()
LaunchedEffect(chatRequest) { LaunchedEffect(chatRequest) {
@@ -149,9 +154,17 @@ fun ServerScreen(
) )
}, },
bottomBar = { 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 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) }, snackbarHost = { SnackbarHost(snackbar) },
) { padding -> ) { padding ->
@@ -165,6 +178,7 @@ fun ServerScreen(
onToggle = { server.toggleCollapsed(it.id) }, onToggle = { server.toggleCollapsed(it.id) },
onChannel = { if (it.spacer == null) channelSheet = it.id }, onChannel = { if (it.spacer == null) channelSheet = it.id },
onClient = { clientSheet = it.id }, onClient = { clientSheet = it.id },
onServer = { serverSheet = true },
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
} else { } else {
@@ -187,9 +201,12 @@ fun ServerScreen(
// A sheet goes with its client or channel when that leaves the tree. // A sheet goes with its client or channel when that leaves the tree.
clientSheet?.let { id -> clientSheet?.let { id ->
LaunchedEffect(id) { server.requestClientDetails(id) }
tree.client(id)?.let { client -> tree.client(id)?.let { client ->
ClientSheet( ClientSheet(
client = client, client = client,
details = remember(id, tree, infoUpdates) { server.clientDetails(id) },
onLink = onLink,
away = state.away, away = state.away,
initialVolume = remember(id) { server.clientVolume(id) }, initialVolume = remember(id) { server.clientVolume(id) },
onDismiss = { clientSheet = null }, onDismiss = { clientSheet = null },
@@ -208,9 +225,12 @@ fun ServerScreen(
} }
} }
channelSheet?.let { id -> channelSheet?.let { id ->
LaunchedEffect(id) { server.requestChannelDetails(id) }
tree.channel(id)?.let { channel -> tree.channel(id)?.let { channel ->
ChannelSheet( ChannelSheet(
channel = channel, channel = channel,
details = remember(id, tree, infoUpdates) { server.channelDetails(id) },
onLink = onLink,
current = id == state.channelId, current = id == state.channelId,
onDismiss = { channelSheet = null }, onDismiss = { channelSheet = null },
onJoin = { server.joinChannel(id, it); 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 @Composable
private fun VoiceBar( private fun VoiceBar(
state: SessionState, state: SessionState,
/** Whether some server holds the microphone, for push-to-talk to talk on. */
captured: Boolean,
talking: Boolean, talking: Boolean,
vm: SessionController, vm: SessionController,
server: ServerController, server: ServerController,
@@ -236,7 +263,7 @@ private fun VoiceBar(
onChat: () -> Unit, onChat: () -> Unit,
) { ) {
val context = LocalContext.current 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 { val mic = @Composable {
BarButton( BarButton(
onClick = { server.setMicMuted(!state.micMuted) }, onClick = { server.setMicMuted(!state.micMuted) },
@@ -258,7 +285,12 @@ private fun VoiceBar(
else PackIcon("PLAYBACK", "Deafen") 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 { val middle = @Composable {
when { when {
ptt -> PushToTalk(talking, vm::setPushToTalk, Modifier.fillMaxWidth()) ptt -> PushToTalk(talking, vm::setPushToTalk, Modifier.fillMaxWidth())
@@ -266,8 +298,6 @@ private fun VoiceBar(
PackIcon("CAPTURE", null) PackIcon("CAPTURE", null)
Text("Use microphone", Modifier.padding(start = 8.dp), maxLines = 1) 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 { val away = @Composable {
@@ -284,17 +314,30 @@ private fun VoiceBar(
} }
} }
Surface(tonalElevation = 3.dp) { 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), Row(Modifier.fillMaxWidth().navigationBarsPadding().padding(horizontal = 8.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically) { verticalAlignment = Alignment.CenterVertically) {
Row(Modifier.weight(1f), horizontalArrangement = Arrangement.SpaceEvenly) { mic(); deafen() } Row(Modifier.width(SIDE), verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.weight(1.3f).height(48.dp), contentAlignment = Alignment.CenterStart) { middle() } Slot { mic() }; Slot { deafen() }; Slot { activate() }
Row(Modifier.weight(1f), horizontalArrangement = Arrangement.SpaceEvenly) { away(); chat() } }
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. */ /** An icon button that also answers a long press. */
@Composable @Composable
private fun BarButton(onClick: () -> Unit, onLongClick: () -> Unit, enabled: Boolean, content: @Composable () -> Unit) { private fun BarButton(onClick: () -> Unit, onLongClick: () -> Unit, enabled: Boolean, content: @Composable () -> Unit) {

View File

@@ -29,9 +29,12 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import com.ts3client.android.ChannelItem import com.ts3client.android.ChannelItem
import com.ts3client.android.ClientItem import com.ts3client.android.ClientItem
import com.ts3client.contacts.ContactType import com.ts3client.contacts.ContactType
import com.ts3client.net.Details
import kotlin.math.roundToInt import kotlin.math.roundToInt
/** What can be done with a client: the private chat, a poke, their volume, filing them as a contact. */ /** 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 @Composable
fun ClientSheet( fun ClientSheet(
client: ClientItem, client: ClientItem,
details: Details?,
onLink: (String) -> Unit,
away: Boolean, away: Boolean,
initialVolume: Double, initialVolume: Double,
onDismiss: () -> Unit, onDismiss: () -> Unit,
@@ -52,7 +57,7 @@ fun ClientSheet(
) { ) {
var poking by remember { mutableStateOf(false) } var poking by remember { mutableStateOf(false) }
ModalBottomSheet(onDismissRequest = onDismiss) { 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, Row(Modifier.padding(horizontal = 24.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)) { horizontalArrangement = Arrangement.spacedBy(12.dp)) {
StatusIcon(client.status) StatusIcon(client.status)
@@ -62,6 +67,7 @@ fun ClientSheet(
if (client.awayMessage.isNotEmpty()) Text(client.awayMessage, style = MaterialTheme.typography.bodyMedium) if (client.awayMessage.isNotEmpty()) Text(client.awayMessage, style = MaterialTheme.typography.bodyMedium)
} }
} }
details?.let { DetailsSection(it, onLink, Modifier.padding(bottom = 8.dp)) }
if (client.self) { if (client.self) {
Action("AWAY", if (away) "Back" else "Away") { onAway(!away) } Action("AWAY", if (away) "Back" else "Away") { onAway(!away) }
} else { } else {
@@ -106,6 +112,8 @@ fun ClientSheet(
@Composable @Composable
fun ChannelSheet( fun ChannelSheet(
channel: ChannelItem, channel: ChannelItem,
details: Details?,
onLink: (String) -> Unit,
current: Boolean, current: Boolean,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onJoin: (String?) -> Unit, onJoin: (String?) -> Unit,
@@ -113,9 +121,10 @@ fun ChannelSheet(
) { ) {
var askPassword by remember { mutableStateOf(false) } var askPassword by remember { mutableStateOf(false) }
ModalBottomSheet(onDismissRequest = onDismiss) { 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), Text(channel.name, Modifier.padding(horizontal = 24.dp, vertical = 8.dp),
style = MaterialTheme.typography.titleLarge) style = MaterialTheme.typography.titleLarge)
details?.let { DetailsSection(it, onLink, Modifier.padding(bottom = 8.dp)) }
if (!current) { if (!current) {
Action("CHANNEL_SWITCH", "Join") { Action("CHANNEL_SWITCH", "Join") {
if (channel.locked) askPassword = true else onJoin(null) 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 @Composable
private fun Action(iconKey: String, label: String, onClick: () -> Unit) { private fun Action(iconKey: String, label: String, onClick: () -> Unit) {
ListItem( ListItem(

View File

@@ -17,6 +17,10 @@ object StatusColors {
val blocked = Color(0xFFD9443A) 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. */ /** The system's own (wallpaper) colours, which every supported Android version offers. */
@Composable @Composable
fun Ts3Theme(content: @Composable () -> Unit) { fun Ts3Theme(content: @Composable () -> Unit) {

View File

@@ -493,6 +493,7 @@ final class ConnectionEventHandler implements TS3Listener {
@Override @Override
public void onServerEdit(ServerEditedEvent e) { public void onServerEdit(ServerEditedEvent e) {
if (has(e, "virtualserver_name")) conn.getModel().setServerName(e.get("virtualserver_name")); if (has(e, "virtualserver_name")) conn.getModel().setServerName(e.get("virtualserver_name"));
conn.getModel().putServerProperties(e.getMap());
if (conn.isConnected()) { if (conn.isConnected()) {
conn.sound(byInvoker(e, SoundEvent.SERVER_EDITED_BY_YOU, SoundEvent.SERVER_EDITED_BY_OTHER, conn.sound(byInvoker(e, SoundEvent.SERVER_EDITED_BY_YOU, SoundEvent.SERVER_EDITED_BY_OTHER,
SoundEvent.SERVER_EDITED_BY_OTHER), conn.serverVars()); SoundEvent.SERVER_EDITED_BY_OTHER), conn.serverVars());

View File

@@ -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<Field> 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<Group> 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<Field> 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<Field> 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<Field> 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<Group> serverGroups = model.serverGroupsOf(cl.serverGroupIds);
if (serverGroups.isEmpty()) {
List<String> names = model.serverGroupNames(cl.serverGroupIds);
fields.add(new Field("Server groups", names.isEmpty() ? "—" : String.join(", ", names)));
} else {
List<String> 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;
}
}
}

View File

@@ -33,6 +33,8 @@ public final class ServerModel {
/** Permission id -> the local client's resolved value, from {@code notifyclientneededpermissions}. */ /** Permission id -> the local client's resolved value, from {@code notifyclientneededpermissions}. */
private final Map<Integer, Integer> selfPermissionValues = new LinkedHashMap<>(); private final Map<Integer, Integer> selfPermissionValues = new LinkedHashMap<>();
private String serverName = "TeamSpeak Server"; private String serverName = "TeamSpeak Server";
/** The {@code virtualserver_*} properties from {@code initserver}, kept up to date by server edits. */
private final Map<String, String> serverProperties = new HashMap<>();
/** The channel group everyone starts in, e.g. "Guest" — not worth offering to (re)assign. */ /** The channel group everyone starts in, e.g. "Guest" — not worth offering to (re)assign. */
private int defaultChannelGroupId; private int defaultChannelGroupId;
@@ -45,6 +47,7 @@ public final class ServerModel {
permissionIds.clear(); permissionIds.clear();
nextPermissionId = FIRST_PERMISSION_ID; nextPermissionId = FIRST_PERMISSION_ID;
selfPermissionValues.clear(); selfPermissionValues.clear();
serverProperties.clear();
defaultChannelGroupId = 0; defaultChannelGroupId = 0;
} }
@@ -207,6 +210,19 @@ public final class ServerModel {
if (name != null && !name.isEmpty()) this.serverName = name; if (name != null && !name.isEmpty()) this.serverName = name;
} }
/** Takes on the server properties among {@code properties}, keeping the others. */
public synchronized void putServerProperties(Map<String, String> 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() { public synchronized int defaultChannelGroupId() {
return defaultChannelGroupId; return defaultChannelGroupId;
} }
@@ -450,6 +466,10 @@ public final class ServerModel {
siblings.addAll(ordered); siblings.addAll(ordered);
} }
public synchronized int channelCount() {
return channels.size();
}
public synchronized int clientCount() { public synchronized int clientCount() {
int n = 0; int n = 0;
for (ClientEntry c : clients.values()) if (!c.isQuery()) n++; for (ClientEntry c : clients.values()) if (!c.isQuery()) n++;

View File

@@ -500,6 +500,7 @@ public final class TeamspeakConnection implements TS3Listener {
} }
// The name comes with initserver; servergetvariables never reports it. // The name comes with initserver; servergetvariables never reports it.
model.setServerName(client.getServerName()); model.setServerName(client.getServerName());
model.putServerProperties(client.getServerProperties());
model.setDefaultChannelGroupId( model.setDefaultChannelGroupId(
(int) safeLong(client.getServerProperties().get("virtualserver_default_channel_group"))); (int) safeLong(client.getServerProperties().get("virtualserver_default_channel_group")));
} }

View File

@@ -136,10 +136,28 @@ public final class BBCode {
return push(out, open, name, "<u>", "</u>"); return push(out, open, name, "<u>", "</u>");
case "s": case "s":
return push(out, open, name, "<strike>", "</strike>"); return push(out, open, name, "<strike>", "</strike>");
case "hr":
// Stands alone; a closing tag some write after it is dropped, see closeTag.
out.append("<hr>");
return true;
case "left":
case "center": case "center":
return push(out, open, name, "<div align=\"center\">", "</div>"); case "right":
case "justify":
return push(out, open, name, "<div align=\"" + name + "\">", "</div>");
case "list": case "list":
if (value.equals("1")) return push(out, open, name, "<ol>", "</ol>");
if (value.equalsIgnoreCase("a")) return push(out, open, name, "<ol type=\"a\">", "</ol>");
return push(out, open, name, "<ul>", "</ul>"); return push(out, open, name, "<ul>", "</ul>");
case "table":
return push(out, open, name, "<table>", "</table>");
case "tr":
if (!contains(open, "table")) return false;
return push(out, open, name, "<tr>", "</tr>");
case "td":
case "th":
if (!contains(open, "tr")) return false;
return push(out, open, name, "<" + name + ">", "</" + name + ">");
case "color": { case "color": {
if (!COLOR.matcher(value).matches()) return false; if (!COLOR.matcher(value).matches()) return false;
return push(out, open, name, "<font color=\"" + value + "\">", "</font>"); return push(out, open, name, "<font color=\"" + value + "\">", "</font>");
@@ -159,6 +177,7 @@ public final class BBCode {
} }
private static boolean closeTag(StringBuilder out, String name, Deque<Open> open) { private static boolean closeTag(StringBuilder out, String name, Deque<Open> open) {
if (name.equals("hr")) return true;
if (!contains(open, name)) return false; if (!contains(open, name)) return false;
// Close anything left dangling inside, then the tag itself. // Close anything left dangling inside, then the tag itself.
while (!open.isEmpty()) { while (!open.isEmpty()) {

View File

@@ -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<String, String> 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());
}
}

View File

@@ -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<hr>b", BBCode.toHtml("a[hr]b"));
assertEquals("a<hr>b", BBCode.toHtml("a[hr][/hr]b"));
}
@Test
void alignsBlocks() {
assertEquals("<div align=\"right\">x</div><div align=\"justify\">y</div>",
BBCode.toHtml("[right]x[/right][justify]y[/justify]"));
}
@Test
void numbersLists() {
assertEquals("<ol><li>one</li><li>two</li></ol>", BBCode.toHtml("[list=1][*]one[*]two[/list]"));
assertEquals("<ol type=\"a\"><li>x</li></ol>", BBCode.toHtml("[list=a][*]x[/list]"));
}
@Test
void buildsTablesOnlyWhereTheyNest() {
assertEquals("<table><tr><th>h</th><td>d</td></tr></table>",
BBCode.toHtml("[table][tr][th]h[/th][td]d[/td][/tr][/table]"));
assertEquals("[td]loose[/td]", BBCode.toHtml("[td]loose[/td]"));
}
}

View File

@@ -5,6 +5,7 @@ import com.formdev.flatlaf.util.UIScale;
import com.ts3client.contacts.Contact; import com.ts3client.contacts.Contact;
import com.ts3client.net.ChannelNode; import com.ts3client.net.ChannelNode;
import com.ts3client.net.ClientEntry; import com.ts3client.net.ClientEntry;
import com.ts3client.net.Details;
import com.ts3client.net.Group; import com.ts3client.net.Group;
import com.ts3client.net.IconRepository; import com.ts3client.net.IconRepository;
import com.ts3client.net.ServerModel; import com.ts3client.net.ServerModel;
@@ -29,9 +30,8 @@ import java.io.File;
import java.util.List; import java.util.List;
/** /**
* Read-only detail view for the currently selected channel or client, mirroring * Read-only detail view for the server, channel or client selected, mirroring the
* the TeamSpeak 3 info box: channel topic/description, or a client's server and * TeamSpeak 3 info box; what it says comes from {@link Details}.
* channel groups, platform and version.
*/ */
public final class InfoPanel extends JPanel { public final class InfoPanel extends JPanel {
@@ -57,6 +57,8 @@ public final class InfoPanel extends JPanel {
private DescriptionHandler descriptionHandler; private DescriptionHandler descriptionHandler;
private ChannelNode shownChannel; private ChannelNode shownChannel;
private ClientEntry shownClient; private ClientEntry shownClient;
/** The server's address while the server itself is shown. */
private String shownServer;
private ServerModel shownModel; private ServerModel shownModel;
private IconRepository shownIcons; private IconRepository shownIcons;
private Contact shownContact; private Contact shownContact;
@@ -143,6 +145,7 @@ public final class InfoPanel extends JPanel {
public void clear() { public void clear() {
shownChannel = null; shownChannel = null;
shownClient = null; shownClient = null;
shownServer = null;
inChatTab = false; inChatTab = false;
render(); render();
} }
@@ -157,9 +160,18 @@ public final class InfoPanel extends JPanel {
render(); render();
} }
public void showServer(ServerModel model, String address) {
shownServer = address;
shownModel = model;
shownChannel = null;
shownClient = null;
render();
}
public void showChannel(ChannelNode ch) { public void showChannel(ChannelNode ch) {
shownChannel = ch; shownChannel = ch;
shownClient = null; shownClient = null;
shownServer = null;
render(); render();
} }
@@ -168,6 +180,7 @@ public final class InfoPanel extends JPanel {
Avatar avatar) { Avatar avatar) {
shownClient = cl; shownClient = cl;
shownChannel = null; shownChannel = null;
shownServer = null;
shownModel = model; shownModel = model;
shownIcons = icons; shownIcons = icons;
shownContact = contact; shownContact = contact;
@@ -176,7 +189,7 @@ public final class InfoPanel extends JPanel {
} }
private void render() { private void render() {
boolean selected = shownChannel != null || shownClient != null; boolean selected = shownChannel != null || shownClient != null || shownServer != null;
boolean hide = inChatTab && selected; boolean hide = inChatTab && selected;
if (descriptionHandler != null) descriptionHandler.setPanelHidden(hide); if (descriptionHandler != null) descriptionHandler.setPanelHidden(hide);
toggle.setVisible(selected && !hide); toggle.setVisible(selected && !hide);
@@ -188,12 +201,13 @@ public final class InfoPanel extends JPanel {
} }
private String body() { 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) { if (shownClient != null) {
renderedAvatarBox = inChatTab ? UIScale.scale(AvatarHtml.MAX_SIZE) : avatarBox(); renderedAvatarBox = inChatTab ? UIScale.scale(AvatarHtml.MAX_SIZE) : avatarBox();
return clientBody(shownClient, shownModel, shownIcons, shownContact, shownAvatar, renderedAvatarBox); return clientBody(shownClient, shownModel, shownIcons, shownContact, shownAvatar, renderedAvatarBox);
} }
return "<i class='muted'>Select a channel or client to see details.</i>"; return "<i class='muted'>Select the server, a channel or a client to see details.</i>";
} }
/** /**
@@ -210,23 +224,6 @@ public final class InfoPanel extends JPanel {
return Math.max(UIScale.scale(AVATAR_MIN_SIZE), Math.min(max, box)); 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("<hr>");
if (ch.description != null && !ch.description.isEmpty()) {
sb.append("<div>").append(multiline(ch.description)).append("</div>");
} else if (ch.descriptionLoaded) {
sb.append("<i class='muted'>No description.</i>");
} else {
sb.append("<i class='muted'>Loading description…</i>");
}
return sb.toString();
}
private static String clientBody(ClientEntry cl, ServerModel model, IconRepository icons, Contact contact, private static String clientBody(ClientEntry cl, ServerModel model, IconRepository icons, Contact contact,
Avatar avatar, int avatarBox) { Avatar avatar, int avatarBox) {
String details = clientDetails(cl, model, icons, contact); 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) { private static String clientDetails(ClientEntry cl, ServerModel model, IconRepository icons, Contact contact) {
String you = cl.self ? " <span class='muted'>(you)</span>" : "";
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(); StringBuilder sb = new StringBuilder();
String name = contact == null ? cl.nickname : contact.displayName(cl.nickname); sb.append(heading(esc(details.title()) + titleSuffix));
sb.append(heading(esc(name) + (cl.self ? " <span class='muted'>(you)</span>" : ""))); for (Details.Field f : details.fields()) row(sb, esc(f.label()), value(f, icons));
if (contact != null) { if (!details.description().isEmpty()) {
String type = contact.type.label(); sb.append("<hr><div>").append(multiline(details.description())).append("</div>");
if (!contact.phoneticNickname.isEmpty()) type += " (" + esc(contact.phoneticNickname) + ")"; } else if (details.pending()) {
row(sb, "Contact", type); sb.append("<hr><i class='muted'>Loading description…</i>");
} else if (none != null) {
sb.append("<hr><i class='muted'>").append(none).append("</i>");
} }
return sb.toString();
}
List<Group> serverGroups = model.serverGroupsOf(cl.serverGroupIds); private static String value(Details.Field field, IconRepository icons) {
if (serverGroups.isEmpty()) { if (field.groups().isEmpty() || icons == null) return esc(field.value());
List<String> names = model.serverGroupNames(cl.serverGroupIds); StringBuilder sb = new StringBuilder();
row(sb, "Server groups", names.isEmpty() ? "—" : esc(String.join(", ", names))); for (Group g : field.groups()) {
} else { if (sb.length() > 0) sb.append(", ");
StringBuilder groups = new StringBuilder(); sb.append(iconTag(g, icons)).append(esc(g.name));
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("<hr><div>").append(multiline(cl.description)).append("</div>");
} }
return sb.toString(); return sb.toString();
} }

View File

@@ -64,6 +64,9 @@ final class ServerTabTreeActions implements ServerTreePanel.Actions {
ClientEntry client = (ClientEntry) sel; ClientEntry client = (ClientEntry) sel;
infoPanel.showClient(client, conn.getModel(), conn.getIcons(), infoPanel.showClient(client, conn.getModel(), conn.getIcons(),
conn.getContacts().find(client.uniqueId), conn.avatarOf(client)); 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 { } else {
infoPanel.clear(); infoPanel.clear();
} }