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:
@@ -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<Int> = _infoUpdates
|
||||
|
||||
/** A private chat a notification asked to show, until the UI took it up. */
|
||||
private val _chatRequest = MutableStateFlow<ChatKey.Private?>(null)
|
||||
val chatRequest: StateFlow<ChatKey.Private?> = _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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -132,7 +132,6 @@ private fun ConversationChip(
|
||||
@Composable
|
||||
private fun Lines(lines: List<ChatLine>, 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<ChatLine>, 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
* `<big>`/`<small>`, 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, "<br>")
|
||||
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 `<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 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(
|
||||
ChatHtml.MUTED_CLASS to "#8c8c8c",
|
||||
|
||||
@@ -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
|
||||
@@ -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<ChatKey, String>(
|
||||
save = { key ->
|
||||
@@ -110,6 +113,8 @@ fun ServerScreen(
|
||||
val conversation = chats[chatKey] ?: chats.channel
|
||||
var clientSheet 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()
|
||||
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,16 +314,29 @@ 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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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());
|
||||
|
||||
110
ts3-client/core/src/main/java/com/ts3client/net/Details.java
Normal file
110
ts3-client/core/src/main/java/com/ts3client/net/Details.java
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,8 @@ public final class ServerModel {
|
||||
/** Permission id -> the local client's resolved value, from {@code notifyclientneededpermissions}. */
|
||||
private final Map<Integer, Integer> 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<String, String> 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<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() {
|
||||
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++;
|
||||
|
||||
@@ -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")));
|
||||
}
|
||||
|
||||
@@ -136,10 +136,28 @@ public final class BBCode {
|
||||
return push(out, open, name, "<u>", "</u>");
|
||||
case "s":
|
||||
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":
|
||||
return push(out, open, name, "<div align=\"center\">", "</div>");
|
||||
case "right":
|
||||
case "justify":
|
||||
return push(out, open, name, "<div align=\"" + name + "\">", "</div>");
|
||||
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>");
|
||||
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": {
|
||||
if (!COLOR.matcher(value).matches()) return false;
|
||||
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) {
|
||||
if (name.equals("hr")) return true;
|
||||
if (!contains(open, name)) return false;
|
||||
// Close anything left dangling inside, then the tag itself.
|
||||
while (!open.isEmpty()) {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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]"));
|
||||
}
|
||||
}
|
||||
@@ -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 "<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));
|
||||
}
|
||||
|
||||
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,
|
||||
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 ? " <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();
|
||||
String name = contact == null ? cl.nickname : contact.displayName(cl.nickname);
|
||||
sb.append(heading(esc(name) + (cl.self ? " <span class='muted'>(you)</span>" : "")));
|
||||
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("<hr><div>").append(multiline(details.description())).append("</div>");
|
||||
} else if (details.pending()) {
|
||||
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);
|
||||
if (serverGroups.isEmpty()) {
|
||||
List<String> 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("<hr><div>").append(multiline(cl.description)).append("</div>");
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user