diff --git a/android/app/src/main/java/com/ts3client/android/ServerController.kt b/android/app/src/main/java/com/ts3client/android/ServerController.kt index ebe16b1..20670c7 100644 --- a/android/app/src/main/java/com/ts3client/android/ServerController.kt +++ b/android/app/src/main/java/com/ts3client/android/ServerController.kt @@ -148,6 +148,8 @@ class ServerController internal constructor( fun toggleCollapsed(channelId: Int) = _collapsed.update { if (channelId in it) it - channelId else it + channelId } + fun expand(channelIds: Collection) = _collapsed.update { it - channelIds.toSet() } + val canMoveClients: Boolean get() = session.canMoveClients() fun moveClient(clientId: Int, channelId: Int) { diff --git a/android/app/src/main/java/com/ts3client/android/ServerTree.kt b/android/app/src/main/java/com/ts3client/android/ServerTree.kt index abd67e7..17a9c50 100644 --- a/android/app/src/main/java/com/ts3client/android/ServerTree.kt +++ b/android/app/src/main/java/com/ts3client/android/ServerTree.kt @@ -30,6 +30,18 @@ data class ServerTree( return found } + /** The channels from the root down to the one [clientId] is in; null when they are not in the tree. */ + fun pathTo(clientId: Int): List? { + fun walk(items: List): List? { + for (c in items) { + if (c.clients.any { it.id == clientId }) return listOf(c.id) + walk(c.children)?.let { return listOf(c.id) + it } + } + return null + } + return walk(channels) + } + private fun find(items: List, test: (ChannelItem) -> Boolean): ChannelItem? { for (c in items) { if (test(c)) return c diff --git a/android/app/src/main/java/com/ts3client/android/ui/ChannelTree.kt b/android/app/src/main/java/com/ts3client/android/ui/ChannelTree.kt index fad4a51..a0c3bc4 100644 --- a/android/app/src/main/java/com/ts3client/android/ui/ChannelTree.kt +++ b/android/app/src/main/java/com/ts3client/android/ui/ChannelTree.kt @@ -1,5 +1,8 @@ package com.ts3client.android.ui +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -16,6 +19,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material3.HorizontalDivider @@ -24,7 +28,12 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ProvideTextStyle import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate @@ -39,6 +48,8 @@ import com.ts3client.android.ServerTree import com.ts3client.contacts.ContactType import com.ts3client.gfx.IconKeys import com.ts3client.net.ClientStatus +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first /** One line of the tree as the list shows it. */ private sealed interface TreeRow { @@ -79,9 +90,30 @@ fun ChannelTree( onClient: (ClientItem) -> Unit, onServer: () -> Unit, modifier: Modifier = Modifier, + /** A client to scroll to and flash, once their channels are expanded. */ + reveal: Int? = null, + onRevealed: () -> Unit = {}, ) { val rows = remember(tree, collapsed, currentChannel) { flatten(tree, collapsed, currentChannel) } - LazyColumn(modifier.fillMaxSize(), contentPadding = PaddingValues(vertical = 4.dp)) { + val listState = rememberLazyListState() + var flashing by remember { mutableStateOf(null) } + LaunchedEffect(reveal, rows) { + val id = reveal ?: return@LaunchedEffect + val index = rows.indexOfFirst { it is TreeRow.Client && it.client.id == id } + if (index < 0) return@LaunchedEffect + // Index 0 is the server's row. The client ends up in the middle of the list. + val middle = snapshotFlow { listState.layoutInfo.viewportSize.height }.first { it > 0 } / 2 + listState.animateScrollToItem(index + 1, -middle) + flashing = id + onRevealed() + } + LaunchedEffect(flashing) { + if (flashing != null) { + delay(1500) + flashing = null + } + } + LazyColumn(modifier.fillMaxSize(), state = listState, contentPadding = PaddingValues(vertical = 4.dp)) { item(key = "server") { RowLayout(depth = 0, onClick = onServer) { PackIcon("SERVER_GREEN", null, size = ICON) @@ -93,7 +125,7 @@ fun ChannelTree( items(rows, key = { it.key }) { row -> when (row) { is TreeRow.Channel -> ChannelRow(row, onToggle, onChannel) - is TreeRow.Client -> ClientRow(row, tree.countryFlags, onClient) + is TreeRow.Client -> ClientRow(row, tree.countryFlags, row.client.id == flashing, onClient) } } } @@ -102,12 +134,20 @@ fun ChannelTree( private val ICON = 16.dp private val INDENT = 14.dp private val ROW = 26.dp +/** Amber, see-through so the row's text keeps its own colour and contrast. */ +private val FLASH = Color(0x80FFB300) @Composable -private fun RowLayout(depth: Int, onClick: (() -> Unit)?, content: @Composable RowScope.() -> Unit) { +private fun RowLayout( + depth: Int, + onClick: (() -> Unit)?, + background: Color = Color.Transparent, + content: @Composable RowScope.() -> Unit, +) { Row( Modifier .fillMaxWidth() + .background(background) .height(ROW) .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier) .padding(start = 4.dp + INDENT * depth, end = 8.dp), @@ -170,9 +210,14 @@ private fun SpacerLabel(align: Char, caption: String, modifier: Modifier) { } @Composable -private fun ClientRow(row: TreeRow.Client, countryFlags: Boolean, onClient: (ClientItem) -> Unit) { +private fun ClientRow(row: TreeRow.Client, countryFlags: Boolean, flashing: Boolean, onClient: (ClientItem) -> Unit) { val client = row.client - RowLayout(row.depth, onClick = { onClient(client) }) { + val background by animateColorAsState( + if (flashing) FLASH else FLASH.copy(alpha = 0f), + tween(if (flashing) 150 else 600), + label = "flash", + ) + RowLayout(row.depth, onClick = { onClient(client) }, background = background) { Spacer(Modifier.width(ICON + 4.dp)) StatusIcon(client.status) Row(Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(4.dp)) { diff --git a/android/app/src/main/java/com/ts3client/android/ui/ServerScreen.kt b/android/app/src/main/java/com/ts3client/android/ui/ServerScreen.kt index f5ee62b..a664cd2 100644 --- a/android/app/src/main/java/com/ts3client/android/ui/ServerScreen.kt +++ b/android/app/src/main/java/com/ts3client/android/ui/ServerScreen.kt @@ -113,6 +113,9 @@ fun ServerScreen( var chatKey by rememberSaveable(stateSaver = ChatKeySaver) { mutableStateOf(ChatKey.Channel) } val conversation = chats[chatKey] ?: chats.channel var clientSheet by remember { mutableStateOf(null) } + var clientSheetFromTree by remember { mutableStateOf(false) } + /** A client to show in the tree. */ + var reveal by remember { mutableStateOf(null) } var channelSheet by remember { mutableStateOf(null) } var serverSheet by remember { mutableStateOf(false) } /** The client to move, while a channel for them is being picked. */ @@ -138,7 +141,7 @@ fun ServerScreen( val ref = TsLink.parse(href) when { ref == null -> runCatching { uriHandler.openUri(href) } - ref.client -> clientSheet = ref.id + ref.client -> { clientSheet = ref.id; clientSheetFromTree = false } else -> channelSheet = ref.id } } @@ -196,9 +199,11 @@ fun ServerScreen( else -> channelSheet = channel.id } }, - onClient = { clientSheet = it.id }, + onClient = { clientSheet = it.id; clientSheetFromTree = true }, onServer = { serverSheet = true }, modifier = Modifier.weight(1f), + reveal = reveal, + onRevealed = { reveal = null }, ) } else { ChatPane( @@ -243,6 +248,12 @@ fun ServerScreen( onMoveToOwnChannel = if (server.canMoveClients && tree.channel(state.channelId)?.clients?.none { it.id == id } == true) { { server.moveClient(id, state.channelId); clientSheet = null } } else null, + onFindInTree = if (clientSheetFromTree) null else ({ + clientSheet = null + tree.pathTo(id)?.let { server.expand(it) } + showChat = false + reveal = id + }), ) } } diff --git a/android/app/src/main/java/com/ts3client/android/ui/Sheets.kt b/android/app/src/main/java/com/ts3client/android/ui/Sheets.kt index 54e65b8..4c60062 100644 --- a/android/app/src/main/java/com/ts3client/android/ui/Sheets.kt +++ b/android/app/src/main/java/com/ts3client/android/ui/Sheets.kt @@ -57,6 +57,8 @@ fun ClientSheet( onMove: (() -> Unit)?, /** Null when the client cannot be moved, or is in our channel already. */ onMoveToOwnChannel: (() -> Unit)?, + /** Null when the sheet was opened from the tree. */ + onFindInTree: (() -> Unit)?, ) { var poking by remember { mutableStateOf(false) } ModalBottomSheet(onDismissRequest = onDismiss) { @@ -82,6 +84,7 @@ fun ClientSheet( }) } details?.let { DetailsSection(it, onLink, Modifier.padding(bottom = 8.dp)) } + onFindInTree?.let { Action("CHANNEL_SWITCH", "Find client in channel tree", onClick = it) } if (client.self) { Action("AWAY", if (away) "Back" else "Away") { onAway(!away) } } else {