Move clients to another channel or our own from the Android client sheet

"Move to channel…" closes the sheet and marks the client, and the next
channel tapped is where they go; a bar above the tree names them and cancels.
"Move to own channel" shows while they are elsewhere.

Both show only with some i_client_move_power. The server's needed
permissions never carry it, even for an admin, so core asks for it with
permget on connect and whenever our own groups change; a refusal (guests may
not ask) counts as none. Whether it beats the client's needed move power only
the server knows, and it refuses when not.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 22:16:21 +00:00
parent cd02e4b2d8
commit b1ad403fdf
7 changed files with 90 additions and 2 deletions

View File

@@ -142,6 +142,12 @@ class ServerController internal constructor(
fun toggleCollapsed(channelId: Int) =
_collapsed.update { if (channelId in it) it - channelId else it + channelId }
val canMoveClients: Boolean get() = session.canMoveClients()
fun moveClient(clientId: Int, channelId: Int) {
if (session.isConnected) conn.moveClient(clientId, channelId, null)
}
fun poke(clientId: Int, message: String) {
if (session.isConnected) conn.poke(clientId, message)
}

View File

@@ -39,6 +39,7 @@ import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.minimumInteractiveComponentSize
import androidx.compose.runtime.Composable
@@ -114,6 +115,8 @@ fun ServerScreen(
var clientSheet by remember { mutableStateOf<Int?>(null) }
var channelSheet by remember { mutableStateOf<Int?>(null) }
var serverSheet by remember { mutableStateOf(false) }
/** The client to move, while a channel for them is being picked. */
var moving by remember { mutableStateOf<Int?>(null) }
val infoUpdates by server.infoUpdates.collectAsStateWithLifecycle()
val chatRequest by server.chatRequest.collectAsStateWithLifecycle()
@@ -141,6 +144,7 @@ fun ServerScreen(
}
BackHandler(showChat) { showChat = false }
BackHandler(moving != null && !showChat) { moving = null }
Scaffold(
topBar = {
@@ -170,13 +174,28 @@ fun ServerScreen(
) { padding ->
Column(Modifier.padding(padding).consumeWindowInsets(padding).imePadding()) {
if (!showChat) {
moving?.let { id ->
val client = tree.client(id)
if (client == null) {
LaunchedEffect(id) { moving = null }
} else {
MovePrompt(client.name) { moving = null }
}
}
ChannelTree(
serverName = state.title,
tree = tree,
collapsed = collapsed,
currentChannel = state.channelId,
onToggle = { server.toggleCollapsed(it.id) },
onChannel = { if (it.spacer == null) channelSheet = it.id },
onChannel = { channel ->
val client = moving
when {
channel.spacer != null -> {}
client != null -> { server.moveClient(client, channel.id); moving = null }
else -> channelSheet = channel.id
}
},
onClient = { clientSheet = it.id },
onServer = { serverSheet = true },
modifier = Modifier.weight(1f),
@@ -221,6 +240,10 @@ fun ServerScreen(
onAddContact = { server.addContact(id, it) },
onEditContact = { clientSheet = null; onContacts(client.uniqueId) },
onRemoveContact = { vm.removeContact(client.uniqueId) },
onMove = if (server.canMoveClients) ({ clientSheet = null; moving = id }) else null,
onMoveToOwnChannel = if (server.canMoveClients && tree.channel(state.channelId)?.clients?.none { it.id == id } == true) {
{ server.moveClient(id, state.channelId); clientSheet = null }
} else null,
)
}
}
@@ -245,6 +268,19 @@ fun ServerScreen(
}
}
/** Says a channel is being picked for [name] to move to, until it is or [onCancel]. */
@Composable
private fun MovePrompt(name: String, onCancel: () -> Unit) {
Surface(color = MaterialTheme.colorScheme.secondaryContainer) {
Row(Modifier.fillMaxWidth().padding(start = 16.dp, end = 8.dp), verticalAlignment = Alignment.CenterVertically) {
PackIcon("CHANNEL_SWITCH", null)
Text("Move $name to…", Modifier.weight(1f).padding(horizontal = 12.dp), maxLines = 1,
overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodyMedium)
TextButton(onClick = onCancel) { Text("Cancel") }
}
}
}
/**
* Mute, deafen, away and the chat, around the push-to-talk button when that is how the
* microphone is used. Holding the mute button switches between push to talk and voice activation.

View File

@@ -37,7 +37,7 @@ 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. */
/** What can be done with a client: the private chat, a poke, a move, their volume, filing them as a contact. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ClientSheet(
@@ -54,6 +54,10 @@ fun ClientSheet(
onAddContact: (ContactType) -> Unit,
onEditContact: () -> Unit,
onRemoveContact: () -> Unit,
/** Null when the client cannot be moved. */
onMove: (() -> Unit)?,
/** Null when the client cannot be moved, or is in our channel already. */
onMoveToOwnChannel: (() -> Unit)?,
) {
var poking by remember { mutableStateOf(false) }
ModalBottomSheet(onDismissRequest = onDismiss) {
@@ -73,6 +77,8 @@ fun ClientSheet(
} else {
Action("PLAYER_CHAT", "Send message", onMessage)
Action("POKE", "Poke") { poking = true }
onMove?.let { Action("CHANNEL_SWITCH", "Move to channel…", it) }
onMoveToOwnChannel?.let { Action("MOVE_CLIENT_TO_OWN_CHANNEL", "Move to own channel", it) }
when (client.contactType) {
null -> {
Action("ADD_FRIEND", "Add as friend") { onAddContact(ContactType.FRIEND) }