diff --git a/android/app/src/main/java/com/ts3client/android/ConnectionService.kt b/android/app/src/main/java/com/ts3client/android/ConnectionService.kt index f95a516..2746857 100644 --- a/android/app/src/main/java/com/ts3client/android/ConnectionService.kt +++ b/android/app/src/main/java/com/ts3client/android/ConnectionService.kt @@ -13,18 +13,22 @@ import android.os.PowerManager import androidx.lifecycle.LifecycleService import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay import com.ts3client.config.Settings import com.ts3client.net.ClientStatus import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch /** * Keeps the process in the foreground while connected, so the connection and its audio * survive the app leaving the screen, and offers the essentials in its notification. */ +@OptIn(ExperimentalCoroutinesApi::class) class ConnectionService : LifecycleService() { private lateinit var sessions: SessionController @@ -47,11 +51,14 @@ class ConnectionService : LifecycleService() { .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "ts3j:connection") .apply { setReferenceCounted(false); acquire() } - show(sessions.state.value) + show(sessions.current.value?.state?.value ?: SessionState(), capturing = false) lifecycleScope.launch { - sessions.state.collectLatest { state -> - if (state.busy) { - show(state) + val currentState = sessions.current.flatMapLatest { it?.state ?: flowOf(null) } + // The server count shows in the notification too. + combine(currentState, sessions.micOwner, sessions.servers) { state, owner, _ -> state to (owner != null) } + .collectLatest { (state, capturing) -> + if (state != null) { + show(state, capturing) } else { // A reconnect leaves the server briefly; only stop once it stays gone. delay(STOP_DELAY_MS) @@ -62,7 +69,11 @@ class ConnectionService : LifecycleService() { overlay = PttOverlay(this, sessions.icons, sessions::setPushToTalk) lifecycleScope.launch { - combine(sessions.state, sessions.tree, sessions.onScreen, sessions.pttOverlay) { state, tree, onScreen, enabled -> + val owner = sessions.micOwner.flatMapLatest { server -> + server?.let { combine(it.state, it.tree) { state, tree -> state to tree } } ?: flowOf(null) + } + combine(owner, sessions.onScreen, sessions.pttOverlay) { held, onScreen, enabled -> + val (state, tree) = held ?: return@combine null val wanted = enabled && !onScreen && state.connected && state.micActive && state.inputMode == Settings.InputMode.PUSH_TO_TALK val status = tree.client(state.selfClientId)?.status @@ -80,11 +91,12 @@ class ConnectionService : LifecycleService() { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { super.onStartCommand(intent, flags, startId) - val state = sessions.state.value + val server = sessions.current.value ?: return START_NOT_STICKY + val state = server.state.value when (intent?.action) { - ACTION_TOGGLE_MUTE -> sessions.setMicMuted(!state.micMuted) - ACTION_TOGGLE_DEAFEN -> sessions.setDeafened(!state.deafened) - ACTION_DISCONNECT -> sessions.disconnect() + ACTION_TOGGLE_MUTE -> server.setMicMuted(!state.micMuted) + ACTION_TOGGLE_DEAFEN -> server.setDeafened(!state.deafened) + ACTION_DISCONNECT -> server.disconnect() } return START_NOT_STICKY } @@ -109,9 +121,9 @@ class ConnectionService : LifecycleService() { } /** Posts the notification, re-declaring what the service uses when that changed. */ - private fun show(state: SessionState) { + private fun show(state: SessionState, capturing: Boolean) { val types = ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK or - if (state.micActive && hasMicPermission()) ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE else 0 + if (capturing && hasMicPermission()) ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE else 0 val notification = notification(state) if (types != foregroundTypes) { startForeground(NOTIFICATION_ID, notification, types) @@ -128,9 +140,10 @@ class ConnectionService : LifecycleService() { val open = PendingIntent.getActivity( this, 0, Intent(this, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE ) + val others = sessions.servers.value.size - 1 return Notification.Builder(this, CHANNEL) .setSmallIcon(android.R.drawable.ic_btn_speak_now) - .setContentTitle(state.title) + .setContentTitle(if (others > 0) "${state.title} +$others" else state.title) .setContentText(state.status) .setContentIntent(open) .setOngoing(true) diff --git a/android/app/src/main/java/com/ts3client/android/MainActivity.kt b/android/app/src/main/java/com/ts3client/android/MainActivity.kt index eea4680..ca8844e 100644 --- a/android/app/src/main/java/com/ts3client/android/MainActivity.kt +++ b/android/app/src/main/java/com/ts3client/android/MainActivity.kt @@ -38,7 +38,8 @@ class MainActivity : ComponentActivity() { /** A private message notification opens its conversation. */ private fun openRequestedChat(intent: Intent) { + val serverId = intent.getIntExtra(MessageNotifier.EXTRA_SERVER, -1) val clientId = intent.getIntExtra(MessageNotifier.EXTRA_CHAT, -1) - if (clientId >= 0) sessions.requestChat(clientId) + if (serverId >= 0 && clientId >= 0) sessions.requestChat(serverId, clientId) } } diff --git a/android/app/src/main/java/com/ts3client/android/MessageNotifier.kt b/android/app/src/main/java/com/ts3client/android/MessageNotifier.kt index c767389..0cbd795 100644 --- a/android/app/src/main/java/com/ts3client/android/MessageNotifier.kt +++ b/android/app/src/main/java/com/ts3client/android/MessageNotifier.kt @@ -19,7 +19,7 @@ class MessageNotifier(private val context: Context) { private val manager = context.getSystemService(NotificationManager::class.java) private val me = Person.Builder().setName("You").build() - /** What each private conversation's notification shows, by the peer's client id. */ + /** What each private conversation's notification shows, by [id] of server and peer. */ private val conversations = HashMap() private class Conversation(val peer: Person, val messages: MutableList) @@ -43,7 +43,7 @@ class MessageNotifier(private val context: Context) { .setContentTitle("$from poked you") .setContentText(message.ifBlank { null }) .setCategory(Notification.CATEGORY_MESSAGE) - .setContentIntent(open(null)) + .setContentIntent(open(null, null)) .setAutoCancel(true) .build() manager.notify(POKE_TAG, from.hashCode(), notification) @@ -57,21 +57,22 @@ class MessageNotifier(private val context: Context) { /** A message the peer sent, which starts the conversation's notification if it had none. */ @Synchronized - fun received(clientId: Int, from: String, text: String) { - val conversation = conversations[clientId]?.takeIf { it.peer.name == from } - ?: Conversation(Person.Builder().setName(from).setKey("client:$clientId").build(), ArrayList()) - conversations[clientId] = conversation - add(clientId, conversation, text, conversation.peer, alert = true) + fun received(serverId: Int, clientId: Int, from: String, text: String) { + val id = id(serverId, clientId) + val conversation = conversations[id]?.takeIf { it.peer.name == from } + ?: Conversation(Person.Builder().setName(from).setKey("client:$serverId:$clientId").build(), ArrayList()) + conversations[id] = conversation + add(serverId, clientId, conversation, text, conversation.peer, alert = true) } /** Our own reply, shown in a conversation already notified of; answers the reply action too. */ @Synchronized - fun sent(clientId: Int, text: String) { - val conversation = conversations[clientId] ?: return - add(clientId, conversation, text, me, alert = false) + fun sent(serverId: Int, clientId: Int, text: String) { + val conversation = conversations[id(serverId, clientId)] ?: return + add(serverId, clientId, conversation, text, me, alert = false) } - private fun add(clientId: Int, conversation: Conversation, text: String, sender: Person, alert: Boolean) { + private fun add(serverId: Int, clientId: Int, conversation: Conversation, text: String, sender: Person, alert: Boolean) { conversation.messages += Notification.MessagingStyle.Message(text, System.currentTimeMillis(), sender) if (conversation.messages.size > MAX_MESSAGES) conversation.messages.removeAt(0) val style = Notification.MessagingStyle(me) @@ -80,38 +81,42 @@ class MessageNotifier(private val context: Context) { .setSmallIcon(android.R.drawable.stat_notify_chat) .setStyle(style) .setCategory(Notification.CATEGORY_MESSAGE) - .setContentIntent(open(clientId)) + .setContentIntent(open(serverId, clientId)) .setOnlyAlertOnce(!alert) .setAutoCancel(true) - .addAction(replyAction(clientId)) + .addAction(replyAction(serverId, clientId)) .build() - manager.notify(MESSAGE_TAG, clientId, notification) + manager.notify(MESSAGE_TAG, id(serverId, clientId), notification) } /** The conversation came on screen, or ended with the connection. */ @Synchronized - fun clear(clientId: Int) { - conversations.remove(clientId) - manager.cancel(MESSAGE_TAG, clientId) + fun clear(serverId: Int, clientId: Int) { + val id = id(serverId, clientId) + conversations.remove(id) + manager.cancel(MESSAGE_TAG, id) } + /** The server's conversations, as its connection ended. */ @Synchronized - fun clearAll() { - conversations.keys.toList().forEach(::clear) - clearPokes() + fun clearServer(serverId: Int) { + conversations.keys.filter { it ushr 16 == serverId }.forEach { clear(serverId, it and 0xFFFF) } } - private fun open(clientId: Int?): PendingIntent { + private fun open(serverId: Int?, clientId: Int?): PendingIntent { val intent = Intent(context, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) - if (clientId != null) intent.putExtra(EXTRA_CHAT, clientId) - return PendingIntent.getActivity(context, clientId ?: -1, intent, - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT) + if (serverId != null && clientId != null) { + intent.putExtra(EXTRA_SERVER, serverId).putExtra(EXTRA_CHAT, clientId) + } + return PendingIntent.getActivity(context, if (serverId != null && clientId != null) id(serverId, clientId) else -1, + intent, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT) } - private fun replyAction(clientId: Int): Notification.Action { - val intent = Intent(context, ReplyReceiver::class.java).putExtra(EXTRA_CHAT, clientId) + private fun replyAction(serverId: Int, clientId: Int): Notification.Action { + val intent = Intent(context, ReplyReceiver::class.java) + .putExtra(EXTRA_SERVER, serverId).putExtra(EXTRA_CHAT, clientId) // Mutable: the system fills the typed reply into it. - val pending = PendingIntent.getBroadcast(context, clientId, intent, + val pending = PendingIntent.getBroadcast(context, id(serverId, clientId), intent, PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT) return Notification.Action.Builder(null, "Reply", pending) .addRemoteInput(RemoteInput.Builder(KEY_REPLY).setLabel("Message").build()) @@ -119,8 +124,12 @@ class MessageNotifier(private val context: Context) { .build() } + /** Client ids are 16 bits, and only unique on their server. */ + private fun id(serverId: Int, clientId: Int) = serverId shl 16 or clientId + companion object { - /** The client id of the private chat a notification opens or replies to. */ + /** The server and the client id of the private chat a notification opens or replies to. */ + const val EXTRA_SERVER = "com.ts3client.android.SERVER" const val EXTRA_CHAT = "com.ts3client.android.CHAT" const val KEY_REPLY = "reply" private const val MESSAGES = "messages" diff --git a/android/app/src/main/java/com/ts3client/android/NetworkFollower.kt b/android/app/src/main/java/com/ts3client/android/NetworkFollower.kt index 1dde4ef..5d3997a 100644 --- a/android/app/src/main/java/com/ts3client/android/NetworkFollower.kt +++ b/android/app/src/main/java/com/ts3client/android/NetworkFollower.kt @@ -12,179 +12,201 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeoutOrNull +import java.util.concurrent.CopyOnWriteArrayList /** - * Keeps the connection on the network Android routes over. + * Keeps the connections on the network Android routes over. * * A TS3 server knows its clients by address, so once the phone moves from Wi-Fi to mobile - * data (or back) the old connection is dead weight: it can only time out. This notices the + * data (or back) an old connection is dead weight: it can only time out. This notices the * move, says goodbye over the old network while that still works, and connects again over * the new one, back into the channel we were in. A server that still holds our old * connection may refuse the new one for a while as a clone, so a failed attempt is retried. * * The process's sockets are bound to the network a connection was made over. Otherwise the * old connection would silently follow the new default network, and its goodbye would come - * from an address the server does not know. + * from an address the server does not know. A socket keeps the network it was made on, so + * each connection remembers its own. */ -class NetworkFollower( - context: Context, - private val session: ServerSession, - private val onChange: () -> Unit, - private val system: (String) -> Unit, -) { +class NetworkFollower(context: Context) { private val connectivity = context.getSystemService(ConnectivityManager::class.java) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val follows = CopyOnWriteArrayList() /** The system's default network; null while there is none. */ @Volatile private var network: Network? = null - /** The network the connection was made over. */ - @Volatile - private var bound: Network? = null - - /** The user wants to be on the server: from their connect until they, or the server, end it. */ - @Volatile - private var wanted = false - - private var settle: Job? = null - @Volatile - private var recovery: Job? = null - @Volatile - private var attempt: CompletableDeferred? = null - - /** What to show in place of the session's status while getting back on the server. */ - @Volatile - var status: String? = null - private set - - val isRecovering: Boolean get() = recovery?.isActive == true - init { connectivity.registerDefaultNetworkCallback(object : ConnectivityManager.NetworkCallback() { override fun onAvailable(available: Network) { network = available - reconsider() + follows.forEach(Follow::reconsider) } override fun onLost(lost: Network) { if (network == lost) network = null - reconsider() + follows.forEach(Follow::reconsider) } }) } - /** Before a connect the user asked for, which goes over the network there is now. */ - fun connecting() { - recovery?.cancel() - wanted = true - bind(connectivity.activeNetwork) - } - - /** The user leaves the server. */ - fun leaving() { - wanted = false - recovery?.cancel() - unbind() - } - - fun onConnected() { - attempt?.complete(true) - } - - fun onDisconnected() { - val pending = attempt - when { - pending != null -> pending.complete(false) - isRecovering -> {} - // A socket on a network that went away fails at once, before we hear of the change. - wanted && networkGone() -> synchronized(this) { - if (!isRecovering) recovery = recover() - } - // The server let us go (a kick, a shutdown, a failed first connect): leave it at that. - else -> { - wanted = false - unbind() - } - } - } - - /** Waits for the network to settle: a handover often shows several changes in a row. */ - private fun reconsider() { - synchronized(this) { - settle?.cancel() - settle = scope.launch { - delay(SETTLE_MS) - synchronized(this@NetworkFollower) { - if (wanted && bound != network && !isRecovering) recovery = recover() - } - } - } - } - - private fun recover() = scope.launch { - try { - val back = withTimeoutOrNull(GIVE_UP_MS) { - system(if (connectivity.activeNetwork == null) "Network lost." else "Network changed, reconnecting…") - if (session.isBusy) session.connection().disconnectBlocking("Network changed") - var retry = RETRY_MS - while (true) { - val current = awaitNetwork() - bind(current) - show("Reconnecting…") - val done = CompletableDeferred() - attempt = done - session.reconnect() - val connected = done.await() - attempt = null - if (connected) break - if (session.connection().connectErrorId == ERROR_BANNED) break - show("Reconnecting, retrying soon…") - delay(retry) - retry = (retry * 2).coerceAtMost(MAX_RETRY_MS) - } - } - if (session.connection().connectErrorId == ERROR_BANNED) { - wanted = false - unbind() - } else if (back == null) { - system("Could not get back on the server.") - wanted = false - unbind() - } - } finally { - attempt = null - status = null - onChange() - // The network may have moved again while we were busy. - reconsider() - } - } - - private fun networkGone(): Boolean { - val on = bound ?: return false - return connectivity.getNetworkCapabilities(on) == null || connectivity.activeNetwork != on - } - - private suspend fun awaitNetwork(): Network { - while (true) { - connectivity.activeNetwork?.let { return it } - show("Waiting for network…") - delay(POLL_MS) - } - } - - private fun show(text: String) { - status = text - onChange() - } + /** Follows [session] until [Follow.forget]. */ + fun follow(session: ServerSession, onChange: () -> Unit, system: (String) -> Unit): Follow = + Follow(session, onChange, system).also(follows::add) + /** Binds new sockets to [to], or lets them go by the default network once no connection wants one. */ private fun bind(to: Network?) { - connectivity.bindProcessToNetwork(to) - bound = to + connectivity.bindProcessToNetwork(to ?: follows.firstNotNullOfOrNull { f -> f.bound.takeIf { f.wanted } }) } - private fun unbind() = bind(null) + inner class Follow internal constructor( + private val session: ServerSession, + private val onChange: () -> Unit, + private val system: (String) -> Unit, + ) { + /** The network the connection was made over. */ + @Volatile + internal var bound: Network? = null + + /** The user wants to be on the server: from their connect until they, or the server, end it. */ + @Volatile + internal var wanted = false + + private var settle: Job? = null + @Volatile + private var recovery: Job? = null + @Volatile + private var attempt: CompletableDeferred? = null + + /** What to show in place of the session's status while getting back on the server. */ + @Volatile + var status: String? = null + private set + + val isRecovering: Boolean get() = recovery?.isActive == true + + /** Before a connect the user asked for, which goes over the network there is now. */ + fun connecting() { + recovery?.cancel() + wanted = true + bindHere(connectivity.activeNetwork) + } + + /** The user leaves the server. */ + fun leaving() { + wanted = false + recovery?.cancel() + unbind() + } + + /** The session is thrown away. */ + fun forget() { + leaving() + follows.remove(this) + } + + fun onConnected() { + attempt?.complete(true) + } + + fun onDisconnected() { + val pending = attempt + when { + pending != null -> pending.complete(false) + isRecovering -> {} + // A socket on a network that went away fails at once, before we hear of the change. + wanted && networkGone() -> synchronized(this) { + if (!isRecovering) recovery = recover() + } + // The server let us go (a kick, a shutdown, a failed first connect): leave it at that. + else -> { + wanted = false + unbind() + } + } + } + + /** Waits for the network to settle: a handover often shows several changes in a row. */ + internal fun reconsider() { + synchronized(this) { + settle?.cancel() + settle = scope.launch { + delay(SETTLE_MS) + synchronized(this@Follow) { + if (wanted && bound != network && !isRecovering) recovery = recover() + } + } + } + } + + private fun recover() = scope.launch { + try { + val back = withTimeoutOrNull(GIVE_UP_MS) { + system(if (connectivity.activeNetwork == null) "Network lost." else "Network changed, reconnecting…") + if (session.isBusy) session.connection().disconnectBlocking("Network changed") + var retry = RETRY_MS + while (true) { + val current = awaitNetwork() + bindHere(current) + show("Reconnecting…") + val done = CompletableDeferred() + attempt = done + session.reconnect() + val connected = done.await() + attempt = null + if (connected) break + if (session.connection().connectErrorId == ERROR_BANNED) break + show("Reconnecting, retrying soon…") + delay(retry) + retry = (retry * 2).coerceAtMost(MAX_RETRY_MS) + } + } + if (session.connection().connectErrorId == ERROR_BANNED) { + wanted = false + unbind() + } else if (back == null) { + system("Could not get back on the server.") + wanted = false + unbind() + } + } finally { + attempt = null + status = null + onChange() + // The network may have moved again while we were busy. + reconsider() + } + } + + private fun networkGone(): Boolean { + val on = bound ?: return false + return connectivity.getNetworkCapabilities(on) == null || connectivity.activeNetwork != on + } + + private suspend fun awaitNetwork(): Network { + while (true) { + connectivity.activeNetwork?.let { return it } + show("Waiting for network…") + delay(POLL_MS) + } + } + + private fun show(text: String) { + status = text + onChange() + } + + private fun bindHere(to: Network?) { + bound = to + bind(to) + } + + private fun unbind() { + bound = null + bind(null) + } + } private companion object { const val SETTLE_MS = 1_500L diff --git a/android/app/src/main/java/com/ts3client/android/ReplyReceiver.kt b/android/app/src/main/java/com/ts3client/android/ReplyReceiver.kt index 0fdd5cf..c84b94a 100644 --- a/android/app/src/main/java/com/ts3client/android/ReplyReceiver.kt +++ b/android/app/src/main/java/com/ts3client/android/ReplyReceiver.kt @@ -8,9 +8,10 @@ import android.content.Intent /** Sends what the user typed into a private message notification. */ class ReplyReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { + val serverId = intent.getIntExtra(MessageNotifier.EXTRA_SERVER, -1) val clientId = intent.getIntExtra(MessageNotifier.EXTRA_CHAT, -1) val text = RemoteInput.getResultsFromIntent(intent)?.getCharSequence(MessageNotifier.KEY_REPLY)?.toString() - if (clientId < 0 || text.isNullOrBlank()) return - (context.applicationContext as Ts3Application).sessions.reply(clientId, text) + if (serverId < 0 || clientId < 0 || text.isNullOrBlank()) return + (context.applicationContext as Ts3Application).sessions.reply(serverId, clientId, text) } } diff --git a/android/app/src/main/java/com/ts3client/android/ServerController.kt b/android/app/src/main/java/com/ts3client/android/ServerController.kt new file mode 100644 index 0000000..e82fc1e --- /dev/null +++ b/android/app/src/main/java/com/ts3client/android/ServerController.kt @@ -0,0 +1,333 @@ +package com.ts3client.android + +import com.ts3client.contacts.ContactType +import com.ts3client.net.ChannelNode +import com.ts3client.net.ConnectionListener.ChatScope +import com.ts3client.session.ServerSession +import com.ts3client.text.BBCode +import com.ts3client.text.ChatHtml +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicLong + +/** + * One server the app is on: its [ServerSession], and the snapshots the UI shows of it. Session + * callbacks arrive on connection threads and are turned into immutable snapshots. + */ +class ServerController internal constructor( + /** Unique among the servers of this process run. */ + val id: Int, + private val app: SessionController, +) { + internal val session = ServerSession( + app.settings, app.identityStore, app.audio, app.sounds, app.contactStore, Listener() + ) + internal val conn get() = session.connection() + private val network = app.network.follow(session, ::refresh, ::system) + + /** Whether this server holds the microphone; set by [SessionController] as it moves. */ + @Volatile + internal var micOwner = false + + /** Set once connecting began, so a session that never started is not taken for one that ended. */ + @Volatile + private var started = false + + /** Set when the user left, so the end needs no telling. */ + @Volatile + internal var left = false + + private val _state = MutableStateFlow(SessionState()) + val state: StateFlow = _state + + private val _tree = MutableStateFlow(ServerTree()) + val tree: StateFlow = _tree + + private val _chats = MutableStateFlow(Chats()) + val chats: StateFlow = _chats + + /** Channels the user folded away in the tree. */ + private val _collapsed = MutableStateFlow(emptySet()) + val collapsed: StateFlow> = _collapsed + + /** The conversation on screen, whose new lines are read as they come. */ + @Volatile + var viewing: ChatKey? = null + set(value) { + field = value + if (value != null) markRead(value) + if (value is ChatKey.Private) app.notifier.clear(id, value.clientId) + } + + /** A private chat a notification asked to show, until the UI took it up. */ + private val _chatRequest = MutableStateFlow(null) + val chatRequest: StateFlow = _chatRequest + + private val lineIds = AtomicLong() + + /** Tree snapshots are taken one at a time, at most once per [TREE_FRAME_MS]. */ + private val treeScope = CoroutineScope(SupervisorJob() + Dispatchers.Default.limitedParallelism(1)) + private val treePending = AtomicBoolean() + private val structureChanged = AtomicBoolean(true) + private var roots: List = emptyList() + + val isConnected: Boolean get() = session.isConnected + internal val isBusy: Boolean get() = session.isBusy || network.isRecovering + internal val microphone get() = if (session.isConnected) conn.microphone else null + internal val playback get() = if (session.isConnected) conn.playback else null + + /** Not on the server and not trying to get back on it: done with. */ + internal val ended: Boolean get() = started && !isBusy + + // ---- connection ---- + + internal fun start(host: String, port: Int, nickname: String, password: String, identityId: String, + channel: String, channelPassword: String) { + network.connecting() + started = true + session.connect(host, port, nickname, password, identityId, channel, channelPassword) + refresh() + } + + fun disconnect() { + left = true + network.leaving() + session.disconnect() + } + + /** Leaves the server before returning, for when the app goes away with the connection. */ + internal fun leave() { + left = true + network.leaving() + session.shutdown() + } + + internal fun dispose() { + network.forget() + treeScope.cancel() + session.dispose() + } + + // ---- the local client ---- + + fun setMicMuted(muted: Boolean) = session.setMicMuted(muted) + + fun setDeafened(deafened: Boolean) = session.setDeafened(deafened) + + fun setAway(away: Boolean) = session.setAway(away, null) + + // ---- channels and clients ---- + + fun joinChannel(channelId: Int, password: String? = null) { + if (session.isConnected) conn.joinChannel(channelId, password) + } + + fun setSubscribed(channelId: Int, subscribed: Boolean) { + if (session.isConnected) conn.setChannelsSubscribed(listOf(channelId), subscribed) + } + + fun toggleCollapsed(channelId: Int) = + _collapsed.update { if (channelId in it) it - channelId else it + channelId } + + fun poke(clientId: Int, message: String) { + if (session.isConnected) conn.poke(clientId, message) + } + + /** The client's playback volume change in dB; 0 when unchanged. */ + fun clientVolume(clientId: Int): Double = + conn.model.getClient(clientId)?.let { conn.getClientVolumeModifier(it) } ?: 0.0 + + fun setClientVolume(clientId: Int, decibels: Double) { + conn.model.getClient(clientId)?.let { conn.setClientVolumeModifier(it, decibels) } + } + + /** 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 + app.contactStore.add(client.uniqueId, client.nickname, type, app.settings.contactDefaults) + } + + internal fun contactsChanged() { + treeScope.launch { conn.applyContacts() } + scheduleTree(structure = false) + } + + // ---- chat ---- + + /** Opens the private chat with a client, loading what was said before. */ + fun openPrivateChat(clientId: Int): ChatKey.Private { + val key = ChatKey.Private(clientId) + val client = conn.model.getClient(clientId) + if (client != null) { + _chats.update { chats -> + chats.update(key, conn.displayName(client)) { it.copy(uniqueId = client.uniqueId) } + } + conn.loadPrivateChatHistory(clientId, client.uniqueId) + } + return key + } + + fun closePrivateChat(key: ChatKey.Private) = _chats.update { it.close(key) } + + fun send(key: ChatKey, text: String) { + when (key) { + ChatKey.Server -> session.sendChat(ChatScope.SERVER, 0, text) + ChatKey.Channel -> session.sendChat(ChatScope.CHANNEL, 0, text) + is ChatKey.Private -> session.sendChat(ChatScope.PRIVATE, key.clientId, text) + } + } + + fun markRead(key: ChatKey) { + if (_chats.value[key]?.unread == true) _chats.update { chats -> chats.update(key) { it.copy(unread = false) } } + } + + internal fun requestChat(clientId: Int) { + _chatRequest.value = ChatKey.Private(clientId) + } + + fun chatRequestHandled() { + _chatRequest.value = null + } + + /** Answers a private message from its notification. */ + internal fun reply(clientId: Int, text: String) { + if (session.isConnected) send(ChatKey.Private(clientId), text) else app.notifier.clear(id, clientId) + } + + private fun append(key: ChatKey, title: String, html: String) { + if (html.isEmpty()) return + val line = ChatLine(lineIds.incrementAndGet(), html) + _chats.update { chats -> chats.update(key, title) { it.append(line, read = viewing == key) } } + } + + private fun setHistory(key: ChatKey, title: String, entries: List) { + val lines = entries.map(ChatHtml::logEntry).filter { it.isNotEmpty() } + .map { ChatLine(lineIds.incrementAndGet(), it) } + _chats.update { chats -> chats.update(key, title) { it.copy(lines = lines) } } + } + + private fun system(text: String) = append(ChatKey.Server, "", ChatHtml.system(text)) + + // ---- snapshots ---- + + private fun snapshotState(): SessionState { + val self = conn.model.getClient(conn.selfClientId) + return SessionState( + title = session.title(), + status = network.status ?: session.status(), + connected = session.isConnected, + busy = isBusy, + micActive = micOwner && app.hasMicPermission(), + micPermission = app.hasMicPermission(), + micMuted = session.isMicMuted, + deafened = session.isDeafened, + away = session.isAway, + selfClientId = if (session.isConnected) conn.selfClientId else 0, + channelId = if (session.isConnected) self?.channelId ?: 0 else 0, + inputMode = app.settings.inputMode, + ) + } + + internal fun refresh() { + _state.value = snapshotState() + app.serverChanged(this) + } + + /** Takes a new tree snapshot soon; [structure] when channels or clients came, went or moved. */ + internal fun scheduleTree(structure: Boolean) { + if (structure) structureChanged.set(true) + if (!treePending.compareAndSet(false, true)) return + treeScope.launch { + delay(TREE_FRAME_MS) + treePending.set(false) + if (structureChanged.getAndSet(false)) roots = conn.model.buildTree() + _tree.value = ServerTree.of(roots, conn.contacts, app.settings.colorContacts) + val serverName = if (session.isConnected) conn.model.serverName.orEmpty() else "" + val channelName = session.currentChannel()?.name.orEmpty() + _chats.update { + it.copy( + server = it.server.copy(title = serverName.ifBlank { "Server" }), + channel = it.channel.copy(title = channelName.ifBlank { "Channel" }), + ) + } + refresh() + } + } + + private inner class Listener : ServerSession.Listener { + override fun onSessionChanged() = refresh() + override fun onSystemMessage(text: String) = system(text) + + override fun onOwnChat(scope: ChatScope, peerClientId: Int, peerName: String, text: String) { + val self = conn.model.getClient(conn.selfClientId)?.nickname ?: app.settings.nickname + val html = ChatHtml.message(conn.selfClientId, conn.selfUniqueId, "$self (you)", text) + append(key(scope, peerClientId), peerName, html) + if (scope == ChatScope.PRIVATE) app.notifier.sent(id, peerClientId, BBCode.toPlain(text)) + } + + override fun onStatus(status: String) {} + + override fun onConnected() { + network.onConnected() + app.connected(this@ServerController) + refresh() + } + + override fun onDisconnected(reason: String?) { + network.onDisconnected() + app.notifier.clearServer(id) + _chats.update { it.copy(private = emptyList()) } + app.disconnected(this@ServerController) + scheduleTree(structure = true) + refresh() + } + + override fun onModelChanged() = scheduleTree(structure = true) + override fun onInfoUpdated() {} + override fun onIconsUpdated() {} + + override fun onChat(scope: ChatScope, fromClientId: Int, fromUniqueId: String?, fromName: String, message: String) { + append(key(scope, fromClientId), fromName, ChatHtml.message(fromClientId, fromUniqueId, fromName, message)) + if (scope == ChatScope.PRIVATE && !app.foreground) { + app.notifier.received(id, fromClientId, fromName, BBCode.toPlain(message)) + } + } + + override fun onChatHistory(scope: ChatScope, htmlEntries: List) { + if (scope != ChatScope.PRIVATE) setHistory(key(scope, 0), "", htmlEntries) + } + + override fun onPrivateChatHistory(clientId: Int, nickname: String, htmlEntries: List) = + setHistory(ChatKey.Private(clientId), nickname, htmlEntries) + + override fun onTalkStateChanged(clientId: Int, talking: Boolean) { + if (clientId == conn.selfClientId && micOwner) app.talking(talking) + scheduleTree(structure = false) + } + + override fun onError(message: String) = app.error(message) + + override fun onPoke(fromName: String, message: String) = app.poked(fromName, message) + + override fun onServerLog(message: String) = append(ChatKey.Server, "", ChatHtml.serverLog(message)) + override fun onServerLogHtml(html: String) = append(ChatKey.Server, "", ChatHtml.logEntry(html)) + } + + private fun key(scope: ChatScope, peerClientId: Int): ChatKey = when (scope) { + ChatScope.SERVER -> ChatKey.Server + ChatScope.CHANNEL -> ChatKey.Channel + ChatScope.PRIVATE -> ChatKey.Private(peerClientId) + } + + private companion object { + const val TREE_FRAME_MS = 30L + } +} diff --git a/android/app/src/main/java/com/ts3client/android/SessionController.kt b/android/app/src/main/java/com/ts3client/android/SessionController.kt index f6d6573..96d0638 100644 --- a/android/app/src/main/java/com/ts3client/android/SessionController.kt +++ b/android/app/src/main/java/com/ts3client/android/SessionController.kt @@ -5,6 +5,8 @@ import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.net.Uri +import android.os.Handler +import android.os.Looper import com.ts3client.android.audio.AndroidAudioBackend import com.ts3client.android.audio.AudioRoute import com.ts3client.audio.AudioDevice @@ -14,40 +16,33 @@ import com.ts3client.config.IdentityStore import com.ts3client.config.Settings import com.ts3client.contacts.Contact import com.ts3client.contacts.ContactStore -import com.ts3client.contacts.ContactType import com.ts3client.myts.MyTeamSpeak import com.ts3client.myts.MyTeamSpeakLogin -import com.ts3client.net.ChannelNode -import com.ts3client.net.ConnectionListener.ChatScope -import com.ts3client.session.ServerSession +import com.ts3client.session.Sessions import com.ts3client.sound.SoundNotifier import com.ts3client.sound.SoundPack import com.ts3client.teamspeak.SyncItem import com.ts3client.teamspeak.TeamSpeakImporter import com.ts3client.teamspeak.TeamSpeakSettingsDb import com.ts3client.text.BBCode -import com.ts3client.text.ChatHtml -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import java.util.concurrent.atomic.AtomicBoolean -import java.util.concurrent.atomic.AtomicLong +import java.util.concurrent.atomic.AtomicInteger -/** What the app shows of the connection and the local client on it. */ +/** What the app shows of a connection and the local client on it. */ data class SessionState( val title: String = "", val status: String = "Not connected", val connected: Boolean = false, val busy: Boolean = false, + /** Whether this server holds the microphone, which only one server can at a time. */ val micActive: Boolean = false, + val micPermission: Boolean = false, val micMuted: Boolean = false, val deafened: Boolean = false, val away: Boolean = false, @@ -63,38 +58,46 @@ sealed interface SessionEvent { } /** - * The app's one [ServerSession], outliving any activity: [ConnectionService] keeps the - * process in the foreground while it is connected. Session callbacks arrive on connection - * threads and are turned into immutable snapshots the UI collects. + * The servers the app is on and what they share, outliving any activity: [ConnectionService] + * keeps the process in the foreground while any is connected. Only one server captures with + * the microphone at a time; [Sessions] hands it over. */ class SessionController(private val context: Context) { val settings: Settings = Settings.load() val icons = IconTheme(context, settings) private val bookmarkStore = Bookmarks.load() - private val identityStore = IdentityStore.load(settings) + internal val identityStore = IdentityStore.load(settings) val identities = IdentityManager(context, identityStore, settings, bookmarkStore) { _bookmarks.value = bookmarkStore.all().toList() } - private val audio = AndroidAudioBackend(context) + internal val audio = AndroidAudioBackend(context) private val soundPlayer = SpeakingSoundPlayer(context, audio.createSoundPlayer(settings)) - private val sounds = SoundNotifier(settings).also { it.setPlayer(soundPlayer) } - private val notifier = MessageNotifier(context) - private val contactStore = ContactStore.load() - private val session = ServerSession( - settings, identityStore, audio, sounds, contactStore, Listener() - ) - private val conn get() = session.connection() - private val network = NetworkFollower(context, session, ::refresh, ::system) + internal val sounds = SoundNotifier(settings).also { it.setPlayer(soundPlayer) } + internal val notifier = MessageNotifier(context) + internal val contactStore = ContactStore.load() + internal val network = NetworkFollower(context) - private val _state = MutableStateFlow(snapshotState()) - val state: StateFlow = _state + private val main = Handler(Looper.getMainLooper()) + private val nextId = AtomicInteger() + /** Touched on the main thread only, as [Sessions] wants. */ + private val sessions = Sessions { main.post(::microphoneMoved) } - private val _tree = MutableStateFlow(ServerTree()) - val tree: StateFlow = _tree + private val _servers = MutableStateFlow(emptyList()) + /** Every server the app is on or getting on, in the order they were connected. */ + val servers: StateFlow> = _servers - private val _chats = MutableStateFlow(Chats()) - val chats: StateFlow = _chats + private val _current = MutableStateFlow(null) + /** The server on screen; null when there is none. */ + val current: StateFlow = _current + + private val _micOwner = MutableStateFlow(null) + /** The server capturing with the microphone; null when none is. */ + val micOwner: StateFlow = _micOwner + + /** Why the last connection that ended did, for the connect form to show; null when it was left. */ + private val _lastEnd = MutableStateFlow(null) + val lastEnd: StateFlow = _lastEnd /** Copies of the contacts, as the store's own entries change in place. */ private val _contacts = MutableStateFlow(contactCopies()) @@ -103,22 +106,9 @@ class SessionController(private val context: Context) { private val _bookmarks = MutableStateFlow(bookmarkStore.all().toList()) val bookmarks: StateFlow> = _bookmarks - /** Channels the user folded away in the tree. */ - private val _collapsed = MutableStateFlow(emptySet()) - val collapsed: StateFlow> = _collapsed - private val _events = MutableSharedFlow(extraBufferCapacity = 16) val events: SharedFlow = _events - /** The conversation on screen, whose new lines are read as they come. */ - @Volatile - var viewing: ChatKey? = null - set(value) { - field = value - if (value != null) markRead(value) - if (value is ChatKey.Private) notifier.clear(value.clientId) - } - private val _onScreen = MutableStateFlow(false) val onScreen: StateFlow = _onScreen @@ -128,20 +118,10 @@ class SessionController(private val context: Context) { set(value) { _onScreen.value = value if (value) notifier.clearPokes() + // The process may be let go of any time once out of sight. + else contactStore.flush() } - /** A private chat a notification asked to show, until the UI took it up. */ - private val _chatRequest = MutableStateFlow(null) - val chatRequest: StateFlow = _chatRequest - - private val lineIds = AtomicLong() - - /** Tree snapshots are taken one at a time, at most once per [TREE_FRAME_MS]. */ - private val treeScope = CoroutineScope(SupervisorJob() + Dispatchers.Default.limitedParallelism(1)) - private val treePending = AtomicBoolean() - private val structureChanged = AtomicBoolean(true) - private var roots: List = emptyList() - private val levels = LevelMonitor(settings, audio) val micLevel: StateFlow = levels.level @Volatile @@ -166,11 +146,8 @@ class SessionController(private val context: Context) { } // Mutations report on whichever thread made them, a connection thread included. contactStore.addListener { - treeScope.launch { - _contacts.value = contactCopies() - conn.applyContacts() - } - scheduleTree(structure = false) + _contacts.value = contactCopies() + _servers.value.forEach(ServerController::contactsChanged) } } @@ -180,7 +157,7 @@ class SessionController(private val context: Context) { // ---- connection ---- - /** Connects to `host[:port]`; an empty [identityId] means the default identity. */ + /** Connects to `host[:port]`, as another server; an empty [identityId] means the default identity. */ fun connect(address: String, nickname: String, password: String = "", identityId: String = "") { val (host, port) = parseAddress(address) settings.lastAddress = address @@ -196,24 +173,69 @@ class SessionController(private val context: Context) { private fun start(host: String, port: Int, nickname: String, password: String, identityId: String, channel: String, channelPassword: String) { - // Capture starts once connected; without the permission yet, the UI hands it over later. - if (hasMicPermission()) session.setMicrophoneActive(true) + val server = ServerController(nextId.incrementAndGet(), this) + sessions.add(server.session) + _servers.update { it + server } + _current.value = server + _lastEnd.value = null context.startForegroundService(Intent(context, ConnectionService::class.java)) - network.connecting() - session.connect(host, port, nickname, password, identityId, channel, channelPassword) + server.start(host, port, nickname, password, identityId, channel, channelPassword) } - fun disconnect() { - network.leaving() - session.disconnect() + fun select(server: ServerController) { + if (server in _servers.value) _current.value = server } - /** Leaves the server before returning, for when the app goes away with the connection. */ + /** Leaves every server before returning, for when the app goes away with the connections. */ fun leave() { - network.leaving() - session.shutdown() + _servers.value.forEach(ServerController::leave) + contactStore.flush() } + fun server(id: Int): ServerController? = _servers.value.firstOrNull { it.id == id } + + /** A server's state changed; one that is done with goes from the list. */ + internal fun serverChanged(server: ServerController) { + if (server.micOwner) updateLevels() + if (server.ended) main.post { remove(server) } + } + + private fun remove(server: ServerController) { + if (server !in _servers.value || !server.ended) return + sessions.remove(server.session) + val rest = _servers.value - server + _servers.value = rest + if (_current.value == server) _current.value = rest.lastOrNull() + server.dispose() + val reason = server.state.value.status + _lastEnd.value = if (server.left) null else reason + if (!server.left && rest.isNotEmpty()) error("${server.state.value.title}: $reason") + } + + internal fun connected(server: ServerController) { + // The first connect generates an identity when there was none. + identities.refresh() + main.post { + // Without the permission yet, the UI hands the microphone over once it is granted. + if (hasMicPermission()) sessions.connected(server.session) + } + } + + internal fun disconnected(server: ServerController) { + main.post { sessions.disconnected(server.session) } + } + + internal fun error(message: String) { + _events.tryEmit(SessionEvent.Error(message)) + } + + internal fun poked(from: String, message: String) { + _events.tryEmit(SessionEvent.Poke(from, message)) + if (!foreground) notifier.poke(from, BBCode.toPlain(message)) + } + + internal fun talking(transmitting: Boolean) = levels.talking(transmitting) + // ---- bookmarks ---- fun addBookmark(label: String, address: String, nickname: String, password: String, identityId: String) { @@ -254,12 +276,6 @@ class SessionController(private val context: Context) { /** A copy of the contact to edit and hand back to [saveContact]. */ fun contact(uniqueId: String): Contact? = contactStore.find(uniqueId)?.copy() - /** 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 - contactStore.add(client.uniqueId, client.nickname, type, settings.contactDefaults) - } - fun saveContact(contact: Contact) = contactStore.put(contact) fun removeContact(uniqueId: String) = contactStore.remove(uniqueId) @@ -319,20 +335,22 @@ class SessionController(private val context: Context) { fun hasMicPermission() = context.checkSelfPermission(Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED - /** Hands the microphone to the connection; needs RECORD_AUDIO granted. */ - fun setMicrophoneActive(active: Boolean) { - session.setMicrophoneActive(active) - refresh() + /** Moves the microphone to [server]; needs RECORD_AUDIO granted. */ + fun giveMicrophoneTo(server: ServerController) { + if (server.isConnected) sessions.giveMicrophoneTo(server.session) } - fun setMicMuted(muted: Boolean) = session.setMicMuted(muted) + /** Opens the microphone of the server holding it while push-to-talk is held. */ + fun setPushToTalk(pressed: Boolean) = sessions.setPushToTalk(pressed) - fun setDeafened(deafened: Boolean) = session.setDeafened(deafened) - - fun setAway(away: Boolean) = session.setAway(away, null) - - fun setPushToTalk(pressed: Boolean) { - if (session.isConnected) conn.microphone?.setPushToTalk(pressed) + private fun microphoneMoved() { + val owner = sessions.microphoneOwner() + for (server in _servers.value) { + server.micOwner = server.session == owner + server.refresh() + } + _micOwner.value = _servers.value.firstOrNull { it.micOwner } + updateLevels() } private val overlayPrefs = context.getSharedPreferences("overlay", Context.MODE_PRIVATE) @@ -345,22 +363,30 @@ class SessionController(private val context: Context) { _pttOverlay.value = enabled } + /** Switches between push to talk and voice activation; returns the new mode. */ + fun toggleInputMode(): Settings.InputMode { + settings.inputMode = if (settings.inputMode == Settings.InputMode.PUSH_TO_TALK) + Settings.InputMode.VOICE_ACTIVATION else Settings.InputMode.PUSH_TO_TALK + applySettings() + return settings.inputMode + } + /** Saves the settings and puts the voice and device ones into effect at once. */ fun applySettings() { settings.save() soundPlayer.setOutputDevice(settings.outputDevice) - if (session.isConnected) { - conn.playback?.setMasterVolume(settings.outputVolume) - conn.playback?.setOutputDevice(settings.outputDevice) - } try { - if (session.isConnected) conn.microphone?.apply(settings) + for (server in _servers.value) { + server.playback?.setMasterVolume(settings.outputVolume) + server.playback?.setOutputDevice(settings.outputDevice) + server.microphone?.apply(settings) + server.refresh() + server.scheduleTree(structure = false) + } levels.apply() } catch (e: RuntimeException) { - _events.tryEmit(SessionEvent.Error(e.message ?: "Microphone unavailable")) + error(e.message ?: "Microphone unavailable") } - refresh() - scheduleTree(structure = false) } /** Microphone preset and playback usage; the open lines move over at once. */ @@ -377,206 +403,25 @@ class SessionController(private val context: Context) { } private fun updateLevels() { - val source = if (session.isConnected && conn.isMicrophoneActive) conn.microphone else null - levels.update(watchingLevel, source, session.isBusy || network.isRecovering, hasMicPermission()) + val owner = _micOwner.value + levels.update(watchingLevel, owner?.microphone, owner?.isBusy == true, hasMicPermission()) } - // ---- channels and clients ---- + // ---- notifications ---- - fun joinChannel(channelId: Int, password: String? = null) { - if (session.isConnected) conn.joinChannel(channelId, password) - } - - fun setSubscribed(channelId: Int, subscribed: Boolean) { - if (session.isConnected) conn.setChannelsSubscribed(listOf(channelId), subscribed) - } - - fun toggleCollapsed(channelId: Int) = - _collapsed.update { if (channelId in it) it - channelId else it + channelId } - - fun poke(clientId: Int, message: String) { - if (session.isConnected) conn.poke(clientId, message) - } - - /** The client's playback volume change in dB; 0 when unchanged. */ - fun clientVolume(clientId: Int): Double = - conn.model.getClient(clientId)?.let { conn.getClientVolumeModifier(it) } ?: 0.0 - - fun setClientVolume(clientId: Int, decibels: Double) { - conn.model.getClient(clientId)?.let { conn.setClientVolumeModifier(it, decibels) } - } - - // ---- chat ---- - - /** Opens the private chat with a client, loading what was said before. */ - fun openPrivateChat(clientId: Int): ChatKey.Private { - val key = ChatKey.Private(clientId) - val client = conn.model.getClient(clientId) - if (client != null) { - _chats.update { chats -> - chats.update(key, conn.displayName(client)) { it.copy(uniqueId = client.uniqueId) } - } - conn.loadPrivateChatHistory(clientId, client.uniqueId) - } - return key - } - - fun closePrivateChat(key: ChatKey.Private) = _chats.update { it.close(key) } - - fun send(key: ChatKey, text: String) { - when (key) { - ChatKey.Server -> session.sendChat(ChatScope.SERVER, 0, text) - ChatKey.Channel -> session.sendChat(ChatScope.CHANNEL, 0, text) - is ChatKey.Private -> session.sendChat(ChatScope.PRIVATE, key.clientId, text) - } - } - - fun markRead(key: ChatKey) { - if (_chats.value[key]?.unread == true) _chats.update { chats -> chats.update(key) { it.copy(unread = false) } } - } - - fun requestChat(clientId: Int) { - _chatRequest.value = ChatKey.Private(clientId) - } - - fun chatRequestHandled() { - _chatRequest.value = null + /** Shows a private chat a notification was tapped for. */ + fun requestChat(serverId: Int, clientId: Int) { + val server = server(serverId) ?: return + _current.value = server + server.requestChat(clientId) } /** Answers a private message from its notification. */ - fun reply(clientId: Int, text: String) { - if (session.isConnected) send(ChatKey.Private(clientId), text) else notifier.clear(clientId) - } - - private fun append(key: ChatKey, title: String, html: String) { - if (html.isEmpty()) return - val line = ChatLine(lineIds.incrementAndGet(), html) - _chats.update { chats -> chats.update(key, title) { it.append(line, read = viewing == key) } } - } - - private fun setHistory(key: ChatKey, title: String, entries: List) { - val lines = entries.map(ChatHtml::logEntry).filter { it.isNotEmpty() } - .map { ChatLine(lineIds.incrementAndGet(), it) } - _chats.update { chats -> chats.update(key, title) { it.copy(lines = lines) } } - } - - private fun system(text: String) = append(ChatKey.Server, "", ChatHtml.system(text)) - - // ---- snapshots ---- - - private fun snapshotState(): SessionState { - val self = conn.model.getClient(conn.selfClientId) - return SessionState( - title = session.title(), - status = network.status ?: session.status(), - connected = session.isConnected, - busy = session.isBusy || network.isRecovering, - micActive = conn.isMicrophoneActive, - micMuted = session.isMicMuted, - deafened = session.isDeafened, - away = session.isAway, - selfClientId = if (session.isConnected) conn.selfClientId else 0, - channelId = if (session.isConnected) self?.channelId ?: 0 else 0, - inputMode = settings.inputMode, - ) - } - - private fun refresh() { - _state.value = snapshotState() - updateLevels() - } - - /** Takes a new tree snapshot soon; [structure] when channels or clients came, went or moved. */ - private fun scheduleTree(structure: Boolean) { - if (structure) structureChanged.set(true) - if (!treePending.compareAndSet(false, true)) return - treeScope.launch { - delay(TREE_FRAME_MS) - treePending.set(false) - if (structureChanged.getAndSet(false)) roots = conn.model.buildTree() - _tree.value = ServerTree.of(roots, conn.contacts, settings.colorContacts) - val serverName = if (session.isConnected) conn.model.serverName.orEmpty() else "" - val channelName = session.currentChannel()?.name.orEmpty() - _chats.update { - it.copy( - server = it.server.copy(title = serverName.ifBlank { "Server" }), - channel = it.channel.copy(title = channelName.ifBlank { "Channel" }), - ) - } - refresh() - } - } - - private inner class Listener : ServerSession.Listener { - override fun onSessionChanged() = refresh() - override fun onSystemMessage(text: String) = system(text) - - override fun onOwnChat(scope: ChatScope, peerClientId: Int, peerName: String, text: String) { - val self = conn.model.getClient(conn.selfClientId)?.nickname ?: settings.nickname - val html = ChatHtml.message(conn.selfClientId, conn.selfUniqueId, "$self (you)", text) - append(key(scope, peerClientId), peerName, html) - if (scope == ChatScope.PRIVATE) notifier.sent(peerClientId, BBCode.toPlain(text)) - } - - override fun onStatus(status: String) {} - - override fun onConnected() { - // The first connect generates an identity when there was none. - identities.refresh() - network.onConnected() - refresh() - } - - override fun onDisconnected(reason: String?) { - network.onDisconnected() - notifier.clearAll() - _chats.update { it.copy(private = emptyList()) } - scheduleTree(structure = true) - refresh() - } - - override fun onModelChanged() = scheduleTree(structure = true) - override fun onInfoUpdated() {} - override fun onIconsUpdated() {} - - override fun onChat(scope: ChatScope, fromClientId: Int, fromUniqueId: String?, fromName: String, message: String) { - append(key(scope, fromClientId), fromName, ChatHtml.message(fromClientId, fromUniqueId, fromName, message)) - if (scope == ChatScope.PRIVATE && !foreground) notifier.received(fromClientId, fromName, BBCode.toPlain(message)) - } - - override fun onChatHistory(scope: ChatScope, htmlEntries: List) { - if (scope != ChatScope.PRIVATE) setHistory(key(scope, 0), "", htmlEntries) - } - - override fun onPrivateChatHistory(clientId: Int, nickname: String, htmlEntries: List) = - setHistory(ChatKey.Private(clientId), nickname, htmlEntries) - - override fun onTalkStateChanged(clientId: Int, talking: Boolean) { - if (clientId == conn.selfClientId) levels.talking(talking) - scheduleTree(structure = false) - } - - override fun onError(message: String) { - _events.tryEmit(SessionEvent.Error(message)) - } - - override fun onPoke(fromName: String, message: String) { - _events.tryEmit(SessionEvent.Poke(fromName, message)) - if (!foreground) notifier.poke(fromName, BBCode.toPlain(message)) - } - - override fun onServerLog(message: String) = append(ChatKey.Server, "", ChatHtml.serverLog(message)) - override fun onServerLogHtml(html: String) = append(ChatKey.Server, "", ChatHtml.logEntry(html)) - } - - private fun key(scope: ChatScope, peerClientId: Int): ChatKey = when (scope) { - ChatScope.SERVER -> ChatKey.Server - ChatScope.CHANNEL -> ChatKey.Channel - ChatScope.PRIVATE -> ChatKey.Private(peerClientId) + fun reply(serverId: Int, clientId: Int, text: String) { + server(serverId)?.reply(clientId, text) ?: notifier.clear(serverId, clientId) } private companion object { - const val TREE_FRAME_MS = 30L const val DEFAULT_PORT = 9987 const val OVERLAY_ENABLED = "enabled" diff --git a/android/app/src/main/java/com/ts3client/android/ui/App.kt b/android/app/src/main/java/com/ts3client/android/ui/App.kt index cd1f8ae..085f4d9 100644 --- a/android/app/src/main/java/com/ts3client/android/ui/App.kt +++ b/android/app/src/main/java/com/ts3client/android/ui/App.kt @@ -15,6 +15,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.saveable.rememberSaveableStateHolder import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -23,7 +24,10 @@ import com.ts3client.android.SessionEvent @Composable fun App(vm: SessionController) { - val state by vm.state.collectAsStateWithLifecycle() + val current by vm.current.collectAsStateWithLifecycle() + // Each server keeps its own screen state (chat open, conversation) while another is shown. + val serverStates = rememberSaveableStateHolder() + var connectOpen by rememberSaveable { mutableStateOf(false) } val snackbar = remember { SnackbarHostState() } var settingsOpen by rememberSaveable { mutableStateOf(false) } var contactsOpen by rememberSaveable { mutableStateOf(false) } @@ -43,7 +47,7 @@ fun App(vm: SessionController) { val context = LocalContext.current val permissions = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { granted -> - if (granted[Manifest.permission.RECORD_AUDIO] == true) vm.setMicrophoneActive(true) + if (granted[Manifest.permission.RECORD_AUDIO] == true) vm.current.value?.let(vm::giveMicrophoneTo) } fun missing(vararg names: String) = names.filter { context.checkSelfPermission(it) != PackageManager.PERMISSION_GRANTED } @@ -51,11 +55,12 @@ fun App(vm: SessionController) { // The connection runs without either permission, just silent or without its notification. val connectAsking: (() -> Unit) -> Unit = { connect -> connect() + connectOpen = false val ask = missing(Manifest.permission.RECORD_AUDIO, Manifest.permission.POST_NOTIFICATIONS) if (ask.isNotEmpty()) permissions.launch(ask.toTypedArray()) } - val enableMicrophone = { - if (missing(Manifest.permission.RECORD_AUDIO).isEmpty()) vm.setMicrophoneActive(true) + val enableMicrophone: () -> Unit = { + if (missing(Manifest.permission.RECORD_AUDIO).isEmpty()) vm.current.value?.let(vm::giveMicrophoneTo) else permissions.launch(arrayOf(Manifest.permission.RECORD_AUDIO)) } @@ -66,6 +71,7 @@ fun App(vm: SessionController) { } CompositionLocalProvider(LocalIconTheme provides vm.icons) { + val server = current when { settingsOpen -> SettingsScreen(vm, onBack = { settingsOpen = false }) contactsOpen -> ContactsScreen(vm, editingContact, onEdit = { uniqueId -> @@ -73,9 +79,14 @@ fun App(vm: SessionController) { contactDirect = false editingContact = uniqueId }, onBack = { contactsOpen = false }) - state.busy || state.connected -> - ServerScreen(vm, snackbar, { settingsOpen = true }, openContacts, enableMicrophone) - else -> ConnectScreen(vm, snackbar, { settingsOpen = true }, { openContacts(null) }, connectAsking) + server != null && !connectOpen -> { + serverStates.SaveableStateProvider(server.id) { + ServerScreen(vm, server, snackbar, { settingsOpen = true }, openContacts, enableMicrophone, + onConnectOther = { connectOpen = true }) + } + } + else -> ConnectScreen(vm, snackbar, { settingsOpen = true }, { openContacts(null) }, connectAsking, + onBack = if (current != null) ({ connectOpen = false }) else null) } } 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 9294a74..e8e0466 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 @@ -8,7 +8,9 @@ import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -19,6 +21,7 @@ import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ProvideTextStyle import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember @@ -77,7 +80,7 @@ fun ChannelTree( modifier: Modifier = Modifier, ) { val rows = remember(tree, collapsed, currentChannel) { flatten(tree, collapsed, currentChannel) } - LazyColumn(modifier.fillMaxSize()) { + LazyColumn(modifier.fillMaxSize(), contentPadding = PaddingValues(vertical = 4.dp)) { item(key = "server") { RowLayout(depth = 0, onClick = null) { PackIcon("SERVER_GREEN", null, size = ICON) @@ -93,20 +96,23 @@ fun ChannelTree( } } -private val ICON = 20.dp -private val INDENT = 16.dp +private val ICON = 16.dp +private val INDENT = 14.dp +private val ROW = 26.dp @Composable private fun RowLayout(depth: Int, onClick: (() -> Unit)?, content: @Composable RowScope.() -> Unit) { Row( Modifier .fillMaxWidth() - .heightIn(min = 40.dp) + .height(ROW) .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier) - .padding(start = 8.dp + INDENT * depth, end = 12.dp), + .padding(start = 4.dp + INDENT * depth, end = 8.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { content() } + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + ProvideTextStyle(MaterialTheme.typography.bodyMedium, content = { content() }) + } } @Composable @@ -114,10 +120,14 @@ private fun ChannelRow(row: TreeRow.Channel, onToggle: (ChannelItem) -> Unit, on val channel = row.channel val spacer = channel.spacer RowLayout(row.depth, onClick = { onChannel(channel) }) { - Box(Modifier.size(ICON).then(if (row.expandable) Modifier.clickable { onToggle(channel) } else Modifier)) { + Box( + Modifier.fillMaxHeight().width(ICON + 4.dp) + .then(if (row.expandable) Modifier.clickable { onToggle(channel) } else Modifier), + contentAlignment = Alignment.Center, + ) { if (row.expandable) { Icon(Icons.Filled.KeyboardArrowDown, if (row.expanded) "Collapse" else "Expand", - Modifier.rotate(if (row.expanded) 0f else -90f)) + Modifier.size(ICON + 2.dp).rotate(if (row.expanded) 0f else -90f)) } } if (spacer != null) { @@ -127,7 +137,6 @@ private fun ChannelRow(row: TreeRow.Channel, onToggle: (ChannelItem) -> Unit, on Text( channel.name, Modifier.weight(1f), - fontWeight = FontWeight.SemiBold, color = if (row.current) MaterialTheme.colorScheme.primary else Color.Unspecified, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -160,7 +169,7 @@ private fun SpacerLabel(align: Char, caption: String, modifier: Modifier) { private fun ClientRow(row: TreeRow.Client, onClient: (ClientItem) -> Unit) { val client = row.client RowLayout(row.depth, onClick = { onClient(client) }) { - Spacer(Modifier.width(ICON)) + Spacer(Modifier.width(ICON + 4.dp)) StatusIcon(client.status) Text( client.name, diff --git a/android/app/src/main/java/com/ts3client/android/ui/ConnectScreen.kt b/android/app/src/main/java/com/ts3client/android/ui/ConnectScreen.kt index 8e27f54..87a850c 100644 --- a/android/app/src/main/java/com/ts3client/android/ui/ConnectScreen.kt +++ b/android/app/src/main/java/com/ts3client/android/ui/ConnectScreen.kt @@ -1,5 +1,6 @@ package com.ts3client.android.ui +import androidx.activity.compose.BackHandler import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -15,6 +16,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button @@ -59,8 +61,10 @@ fun ConnectScreen( onSettings: () -> Unit, onContacts: () -> Unit, onConnect: (() -> Unit) -> Unit, + /** Back to the servers already connected; null when there are none. */ + onBack: (() -> Unit)?, ) { - val state by vm.state.collectAsStateWithLifecycle() + val lastEnd by vm.lastEnd.collectAsStateWithLifecycle() val bookmarks by vm.bookmarks.collectAsStateWithLifecycle() val identities by vm.identities.all.collectAsStateWithLifecycle() var address by rememberSaveable { mutableStateOf(vm.lastAddress) } @@ -70,10 +74,15 @@ fun ConnectScreen( var naming by rememberSaveable { mutableStateOf(false) } var choosingFor by remember { mutableStateOf(null) } + if (onBack != null) BackHandler(onBack = onBack) + Scaffold( topBar = { TopAppBar( title = { Text("TeamSpeak") }, + navigationIcon = { + if (onBack != null) IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") } + }, actions = { IconButton(onClick = onContacts) { PackIcon("CONTACT", "Contacts") } IconButton(onClick = onSettings) { PackIcon("SETTINGS", "Settings") } @@ -101,8 +110,8 @@ fun ConnectScreen( identityId = it nickname = vm.nicknameFor(it) } - if (state.status != "Not connected") { - Text(state.status, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyMedium) + lastEnd?.let { + Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyMedium) } Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Button( 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 1a314c5..5de04b3 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 @@ -1,5 +1,9 @@ package com.ts3client.android.ui +import android.widget.Toast +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -8,29 +12,38 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn 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.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material3.Badge import androidx.compose.material3.BadgedBox +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconToggleButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.PrimaryTabRow +import androidx.compose.material3.MenuDefaults import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface -import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar +import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.Saver @@ -39,14 +52,18 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.ts3client.android.ChatKey +import com.ts3client.android.ServerController import com.ts3client.android.SessionController import com.ts3client.android.SessionState import com.ts3client.config.Settings @@ -75,35 +92,37 @@ private val ChatKeySaver = Saver( @Composable fun ServerScreen( vm: SessionController, + server: ServerController, snackbar: SnackbarHostState, onSettings: () -> Unit, /** Opens the contacts, on the given client's page when one is named. */ onContacts: (String?) -> Unit, onEnableMicrophone: () -> Unit, + onConnectOther: () -> Unit, ) { - val state by vm.state.collectAsStateWithLifecycle() - val tree by vm.tree.collectAsStateWithLifecycle() - val chats by vm.chats.collectAsStateWithLifecycle() - val collapsed by vm.collapsed.collectAsStateWithLifecycle() + val state by server.state.collectAsStateWithLifecycle() + val tree by server.tree.collectAsStateWithLifecycle() + val chats by server.chats.collectAsStateWithLifecycle() + val collapsed by server.collapsed.collectAsStateWithLifecycle() - var tab by rememberSaveable { mutableIntStateOf(TAB_CHANNELS) } + var showChat by rememberSaveable { mutableStateOf(false) } var chatKey by rememberSaveable(stateSaver = ChatKeySaver) { mutableStateOf(ChatKey.Channel) } val conversation = chats[chatKey] ?: chats.channel var clientSheet by remember { mutableStateOf(null) } var channelSheet by remember { mutableStateOf(null) } - val chatRequest by vm.chatRequest.collectAsStateWithLifecycle() + val chatRequest by server.chatRequest.collectAsStateWithLifecycle() LaunchedEffect(chatRequest) { val key = chatRequest ?: return@LaunchedEffect - chatKey = if (chats[key] != null) key else vm.openPrivateChat(key.clientId) - tab = TAB_CHAT - vm.chatRequestHandled() + chatKey = if (chats[key] != null) key else server.openPrivateChat(key.clientId) + showChat = true + server.chatRequestHandled() } - val viewing = if (tab == TAB_CHAT) conversation.key else null + val viewing = if (showChat) conversation.key else null LifecycleResumeEffect(viewing) { - vm.viewing = viewing - onPauseOrDispose { vm.viewing = null } + server.viewing = viewing + onPauseOrDispose { server.viewing = null } } val uriHandler = LocalUriHandler.current @@ -116,46 +135,34 @@ fun ServerScreen( } } + BackHandler(showChat) { showChat = false } + Scaffold( topBar = { TopAppBar( - title = { - Column { - Text(state.title, maxLines = 1, overflow = TextOverflow.Ellipsis) - if (!state.connected) { - Text(state.status, style = MaterialTheme.typography.bodySmall, maxLines = 1, - overflow = TextOverflow.Ellipsis) - } - } - }, + title = { ServerSwitcher(vm, server, state, onConnectOther) }, + expandedHeight = 48.dp, actions = { IconButton(onClick = { onContacts(null) }) { PackIcon("CONTACT", "Contacts") } IconButton(onClick = onSettings) { PackIcon("SETTINGS", "Settings") } - IconButton(onClick = vm::disconnect) { PackIcon("DISCONNECT", "Disconnect") } }, ) }, bottomBar = { val self = tree.client(state.selfClientId) val talking = self?.status == ClientStatus.TALKING || self?.status == ClientStatus.COMMANDER_TALKING - VoiceBar(state, talking, vm, onEnableMicrophone) + VoiceBar(state, talking, vm, server, onEnableMicrophone, showChat, chats.anyUnread) { showChat = !showChat } }, snackbarHost = { SnackbarHost(snackbar) }, ) { padding -> Column(Modifier.padding(padding).consumeWindowInsets(padding).imePadding()) { - PrimaryTabRow(selectedTabIndex = tab) { - Tab(tab == TAB_CHANNELS, onClick = { tab = TAB_CHANNELS }, text = { Text("Channels") }) - Tab(tab == TAB_CHAT, onClick = { tab = TAB_CHAT }, text = { - BadgedBox(badge = { if (chats.anyUnread && tab != TAB_CHAT) Badge() }) { Text("Chat") } - }) - } - if (tab == TAB_CHANNELS) { + if (!showChat) { ChannelTree( serverName = state.title, tree = tree, collapsed = collapsed, currentChannel = state.channelId, - onToggle = { vm.toggleCollapsed(it.id) }, + onToggle = { server.toggleCollapsed(it.id) }, onChannel = { if (it.spacer == null) channelSheet = it.id }, onClient = { clientSheet = it.id }, modifier = Modifier.weight(1f), @@ -168,9 +175,9 @@ fun ServerScreen( onSelect = { chatKey = it }, onClose = { key -> if (chatKey == key) chatKey = ChatKey.Channel - vm.closePrivateChat(key) + server.closePrivateChat(key) }, - onSend = { vm.send(conversation.key, it) }, + onSend = { server.send(conversation.key, it) }, onLink = onLink, modifier = Modifier.weight(1f), ) @@ -184,17 +191,17 @@ fun ServerScreen( ClientSheet( client = client, away = state.away, - initialVolume = remember(id) { vm.clientVolume(id) }, + initialVolume = remember(id) { server.clientVolume(id) }, onDismiss = { clientSheet = null }, onMessage = { clientSheet = null - chatKey = vm.openPrivateChat(id) - tab = TAB_CHAT + chatKey = server.openPrivateChat(id) + showChat = true }, - onPoke = { vm.poke(id, it); clientSheet = null }, - onVolume = { vm.setClientVolume(id, it) }, - onAway = { vm.setAway(it); clientSheet = null }, - onAddContact = { vm.addContact(id, it) }, + onPoke = { server.poke(id, it); clientSheet = null }, + onVolume = { server.setClientVolume(id, it) }, + onAway = { server.setAway(it); clientSheet = null }, + onAddContact = { server.addContact(id, it) }, onEditContact = { clientSheet = null; onContacts(client.uniqueId) }, onRemoveContact = { vm.removeContact(client.uniqueId) }, ) @@ -206,57 +213,108 @@ fun ServerScreen( channel = channel, current = id == state.channelId, onDismiss = { channelSheet = null }, - onJoin = { vm.joinChannel(id, it); channelSheet = null }, - onSubscribe = { vm.setSubscribed(id, it); channelSheet = null }, + onJoin = { server.joinChannel(id, it); channelSheet = null }, + onSubscribe = { server.setSubscribed(id, it); channelSheet = null }, ) } } } -private const val TAB_CHANNELS = 0 -private const val TAB_CHAT = 1 - -/** Mute, deafen and away, and the push-to-talk button when that is how the microphone is used. */ +/** + * 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. + */ @Composable -private fun VoiceBar(state: SessionState, talking: Boolean, vm: SessionController, onEnableMicrophone: () -> Unit) { +private fun VoiceBar( + state: SessionState, + talking: Boolean, + vm: SessionController, + server: ServerController, + onEnableMicrophone: () -> Unit, + showChat: Boolean, + unread: Boolean, + onChat: () -> Unit, +) { + val context = LocalContext.current + val ptt = state.connected && state.micActive && state.inputMode == Settings.InputMode.PUSH_TO_TALK + val mic = @Composable { + BarButton( + onClick = { server.setMicMuted(!state.micMuted) }, + onLongClick = { + val mode = vm.toggleInputMode() + val text = if (mode == Settings.InputMode.PUSH_TO_TALK) "Switched to push-to-talk mode." + else "Switched to voice activation mode." + Toast.makeText(context, text, Toast.LENGTH_SHORT).show() + }, + enabled = state.connected, + ) { + if (state.micMuted) PackIcon("INPUT_MUTED", "Unmute microphone") + else PackIcon("CAPTURE", "Mute microphone") + } + } + val deafen = @Composable { + IconToggleButton(state.deafened, { server.setDeafened(it) }, enabled = state.connected) { + if (state.deafened) PackIcon("OUTPUT_MUTED", "Undeafen") + else PackIcon("PLAYBACK", "Deafen") + } + } + // Without the talk button, what the microphone needs next: the permission, or to come to this server. + val middle = @Composable { + when { + ptt -> PushToTalk(talking, vm::setPushToTalk, Modifier.fillMaxWidth()) + !state.micPermission -> FilledTonalButton(onClick = onEnableMicrophone) { + 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 { + IconToggleButton(state.away, { server.setAway(it) }, enabled = state.connected) { + PackIcon("AWAY", if (state.away) "Back" else "Away", Modifier.alpha(if (state.away) 1f else 0.5f)) + } + } + val chat = @Composable { + IconToggleButton(showChat, { onChat() }) { + BadgedBox(badge = { if (unread && !showChat) Badge() }) { + PackIcon(listOf("MESSAGES", "CHANNEL_CHAT"), if (showChat) "Channels" else "Chat", + Modifier.alpha(if (showChat) 1f else 0.7f)) + } + } + } Surface(tonalElevation = 3.dp) { - Column(Modifier.fillMaxWidth().navigationBarsPadding().padding(horizontal = 16.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp)) { - if (state.connected && state.micActive && state.inputMode == Settings.InputMode.PUSH_TO_TALK) { - PushToTalk(talking, vm::setPushToTalk) - } - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly, - verticalAlignment = Alignment.CenterVertically) { - if (!state.micActive) { - FilledTonalButton(onClick = onEnableMicrophone) { - PackIcon("CAPTURE", null) - Text("Use microphone", Modifier.padding(start = 8.dp)) - } - } else { - IconToggleButton(state.micMuted, { vm.setMicMuted(it) }, enabled = state.connected) { - if (state.micMuted) PackIcon("INPUT_MUTED", "Unmute microphone") - else PackIcon("CAPTURE", "Mute microphone") - } - } - IconToggleButton(state.deafened, { vm.setDeafened(it) }, enabled = state.connected) { - if (state.deafened) PackIcon("OUTPUT_MUTED", "Undeafen") - else PackIcon("PLAYBACK", "Deafen") - } - IconToggleButton(state.away, { vm.setAway(it) }, enabled = state.connected) { - PackIcon("AWAY", if (state.away) "Back" else "Away", Modifier.alpha(if (state.away) 1f else 0.5f)) - } - } + // The same places whatever shows: equal sides keep the talk button's space 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() } } } } +/** An icon button that also answers a long press. */ @Composable -private fun PushToTalk(talking: Boolean, onPressed: (Boolean) -> Unit) { +private fun BarButton(onClick: () -> Unit, onLongClick: () -> Unit, enabled: Boolean, content: @Composable () -> Unit) { + Box( + Modifier + .minimumInteractiveComponentSize() + .size(40.dp) + .clip(CircleShape) + .combinedClickable(enabled = enabled, role = Role.Button, onLongClick = onLongClick, onClick = onClick) + .alpha(if (enabled) 1f else 0.38f), + contentAlignment = Alignment.Center, + ) { content() } +} + +@Composable +private fun PushToTalk(talking: Boolean, onPressed: (Boolean) -> Unit, modifier: Modifier) { val colors = MaterialTheme.colorScheme Surface( - Modifier - .fillMaxWidth() - .height(64.dp) + modifier + .height(48.dp) .pointerInput(Unit) { detectTapGestures(onPress = { onPressed(true) @@ -267,7 +325,7 @@ private fun PushToTalk(talking: Boolean, onPressed: (Boolean) -> Unit) { } }) }, - shape = RoundedCornerShape(32.dp), + shape = RoundedCornerShape(24.dp), color = if (talking) StatusColors.talking else colors.primaryContainer, contentColor = if (talking) Color.White else colors.onPrimaryContainer, ) { @@ -276,3 +334,52 @@ private fun PushToTalk(talking: Boolean, onPressed: (Boolean) -> Unit) { } } } + +/** The server on screen, opening onto the others and connecting to another. */ +@Composable +private fun ServerSwitcher(vm: SessionController, server: ServerController, state: SessionState, onConnectOther: () -> Unit) { + val servers by vm.servers.collectAsStateWithLifecycle() + var open by remember { mutableStateOf(false) } + Box(Modifier.fillMaxWidth()) { + Row(Modifier.fillMaxWidth().heightIn(min = 48.dp).clickable { open = true }, + verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text(state.title, maxLines = 1, overflow = TextOverflow.Ellipsis) + if (!state.connected) { + Text(state.status, style = MaterialTheme.typography.bodySmall, maxLines = 1, + overflow = TextOverflow.Ellipsis) + } + } + Icon(Icons.Filled.ArrowDropDown, "Servers") + } + DropdownMenu(open, onDismissRequest = { open = false }) { + for (other in servers) { + key(other.id) { + val otherState by other.state.collectAsStateWithLifecycle() + DropdownMenuItem( + text = { Text(otherState.title, maxLines = 1, overflow = TextOverflow.Ellipsis) }, + onClick = { open = false; vm.select(other) }, + leadingIcon = { + PackIcon("SERVER_GREEN", null, Modifier.alpha(if (otherState.connected) 1f else 0.5f)) + }, + trailingIcon = if (otherState.micActive) ({ PackIcon("ACTIVATE_MICROPHONE", "Microphone") }) else null, + colors = if (other == server) MenuDefaults.itemColors(textColor = MaterialTheme.colorScheme.primary) + else MenuDefaults.itemColors(), + ) + } + } + HorizontalDivider() + DropdownMenuItem( + text = { Text("Connect…") }, + onClick = { open = false; onConnectOther() }, + leadingIcon = { PackIcon("CONNECT", null) }, + ) + DropdownMenuItem( + text = { Text("Disconnect from current") }, + onClick = { open = false; server.disconnect() }, + leadingIcon = { PackIcon("DISCONNECT", null) }, + ) + } + } +} + diff --git a/ts3-client/core/src/main/java/com/ts3client/session/Sessions.java b/ts3-client/core/src/main/java/com/ts3client/session/Sessions.java index c649140..745c7ef 100644 --- a/ts3-client/core/src/main/java/com/ts3client/session/Sessions.java +++ b/ts3-client/core/src/main/java/com/ts3client/session/Sessions.java @@ -89,9 +89,15 @@ public final class Sessions { assignMicrophoneIfFree(); } - /** A disconnected session cannot capture; the microphone moves on if it had it. */ + /** + * A disconnected session cannot capture; the microphone moves on if it had it. The session + * lets go of it too, or a reconnect would capture alongside whoever holds it by then. + */ public void disconnected(ServerSession session) { - if (microphoneOwner == session) microphoneOwner = null; + if (microphoneOwner == session) { + microphoneOwner = null; + handover.execute(() -> session.setMicrophoneActive(false)); + } assignMicrophoneIfFree(); }