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 f2172be..f573dc7 100644 --- a/android/app/src/main/java/com/ts3client/android/ServerController.kt +++ b/android/app/src/main/java/com/ts3client/android/ServerController.kt @@ -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) } 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 c3ea9ff..b2c3051 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 @@ -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(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. */ + var moving by remember { mutableStateOf(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. 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 abb6317..b1bb8cb 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 @@ -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) } diff --git a/ts3-client/core/src/main/java/com/ts3client/net/ConnectionEventHandler.java b/ts3-client/core/src/main/java/com/ts3client/net/ConnectionEventHandler.java index 88ceba5..3a2ef59 100644 --- a/ts3-client/core/src/main/java/com/ts3client/net/ConnectionEventHandler.java +++ b/ts3-client/core/src/main/java/com/ts3client/net/ConnectionEventHandler.java @@ -506,6 +506,7 @@ final class ConnectionEventHandler implements TS3Listener { ClientEntry c = conn.getModel().getClient(e.getClientId()); if (c != null) c.addServerGroup(e.getServerGroupId()); boolean self = e.getClientId() == conn.getSelfClientId(); + if (self) conn.requestMovePower(); conn.sound(self ? byInvoker(e, SoundEvent.YOU_SERVERGROUP_ADDED_BY_USER, SoundEvent.YOU_SERVERGROUP_ADDED_BY_USER, SoundEvent.YOU_SERVERGROUP_ADDED_BY_SERVER) @@ -523,6 +524,7 @@ final class ConnectionEventHandler implements TS3Listener { ClientEntry c = conn.getModel().getClient(clientId); if (c != null) c.removeServerGroup(e.getServerGroupId()); boolean self = clientId == conn.getSelfClientId(); + if (self) conn.requestMovePower(); conn.sound(self ? byInvoker(e, SoundEvent.YOU_SERVERGROUP_REMOVED_BY_USER, SoundEvent.YOU_SERVERGROUP_REMOVED_BY_USER, SoundEvent.YOU_SERVERGROUP_REMOVED_BY_SERVER) @@ -539,6 +541,7 @@ final class ConnectionEventHandler implements TS3Listener { ClientEntry c = conn.getModel().getClient(e.getClientId()); if (c != null) c.channelGroupId = e.getChannelGroupId(); boolean self = e.getClientId() == conn.getSelfClientId(); + if (self) conn.requestMovePower(); String groupName = conn.getModel().channelGroupName(e.getChannelGroupId()); conn.sound(self ? byInvoker(e, SoundEvent.YOU_CHANNELGROUP_CHANGED_BY_USER, diff --git a/ts3-client/core/src/main/java/com/ts3client/net/ServerModel.java b/ts3-client/core/src/main/java/com/ts3client/net/ServerModel.java index e98bbab..50e257b 100644 --- a/ts3-client/core/src/main/java/com/ts3client/net/ServerModel.java +++ b/ts3-client/core/src/main/java/com/ts3client/net/ServerModel.java @@ -32,6 +32,8 @@ public final class ServerModel { private int nextPermissionId = FIRST_PERMISSION_ID; /** Permission id -> the local client's resolved value, from {@code notifyclientneededpermissions}. */ private final Map selfPermissionValues = new LinkedHashMap<>(); + /** The local client's {@code i_client_move_power}, which the needed permissions leave out; 0 until known. */ + private int selfMovePower; private String serverName = "TeamSpeak Server"; /** The {@code virtualserver_*} properties from {@code initserver}, kept up to date by server edits. */ private final Map serverProperties = new HashMap<>(); @@ -47,6 +49,7 @@ public final class ServerModel { permissionIds.clear(); nextPermissionId = FIRST_PERMISSION_ID; selfPermissionValues.clear(); + selfMovePower = 0; serverProperties.clear(); defaultChannelGroupId = 0; } @@ -154,6 +157,14 @@ public final class ServerModel { return value == null ? 0 : value; } + public synchronized void setSelfMovePower(int power) { + selfMovePower = power; + } + + public synchronized int selfMovePower() { + return selfMovePower; + } + // ---- what the local client may do ---- /** diff --git a/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java b/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java index e31200e..57320fe 100644 --- a/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java +++ b/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java @@ -352,6 +352,7 @@ public final class TeamspeakConnection implements TS3Listener { loadChatLogs(); ui.onConnected(); requestPermissionNames(); + requestMovePower(); ui.onStatus("Retrieving channels…"); syncAll(); @@ -728,6 +729,23 @@ public final class TeamspeakConnection implements TS3Listener { attempt(sock -> sock.executeCommand(new SingleCommand("permissionlist", ProtocolRole.CLIENT)).complete()); } + /** + * Asks for our own {@code i_client_move_power}, which {@code notifyclientneededpermissions} + * never carries. Asking needs {@code b_client_permissionoverview_own}; refused, we take it + * as none, which is what the groups that lack the one usually have of the other. + */ + void requestMovePower() { + submit(sock -> { + int power = 0; + for (SingleCommand answer : sock.executeCommand(new SingleCommand("permget", ProtocolRole.CLIENT, + new CommandSingleParameter("permsid", "i_client_move_power"))).get()) { + String value = answer.toMap().get("permvalue"); + if (value != null) power = Integer.parseInt(value); + } + model.setSelfMovePower(power); + }, e -> model.setSelfMovePower(0)); + } + /** Assigns or removes a server group for a client (by database id, as {@code servergroupaddclient} needs). */ public void setClientServerGroup(int clientDatabaseId, int groupId, boolean assign) { act("Could not " + (assign ? "assign" : "remove") + " server group: ", sock -> { diff --git a/ts3-client/core/src/main/java/com/ts3client/session/ServerSession.java b/ts3-client/core/src/main/java/com/ts3client/session/ServerSession.java index a50e9e5..0fca445 100644 --- a/ts3-client/core/src/main/java/com/ts3client/session/ServerSession.java +++ b/ts3-client/core/src/main/java/com/ts3client/session/ServerSession.java @@ -383,6 +383,14 @@ public final class ServerSession { return !model.hasPermissionNames() || model.selfPermissionValue("b_client_ban_list") > 0; } + /** + * Whether we have any {@code i_client_move_power}; whether it beats a client's needed + * move power only the server knows, so it is left to refuse. + */ + public boolean canMoveClients() { + return conn.isConnected() && conn.getModel().selfMovePower() != 0; + } + // ---- connection events ---- private void setStatus(String text) {