Connect to several servers at once on Android, in a denser TeamSpeak-like layout

The server name in the top bar opens the servers the app is on, "Connect…" for
another one and "Disconnect from current". Only one server captures with the
microphone at a time, handed over by core's Sessions as on the desktop; a
server without it shows a button to bring it there. Push-to-talk, the floating
button and the level meter follow the server holding it.

Each server has its own controller (session, tree, chats, network recovery);
the app-wide one keeps what they share. Private message notifications carry
their server, as client ids repeat across servers.

The layout follows TeamSpeak's own: 26 dp tree rows with smaller icons and
text, a lower top bar, and no tabs — the chat is a button in the bottom bar,
whose buttons keep fixed places around the push-to-talk button's space.
Holding the mute button switches between push-to-talk and voice activation.

A server that drops the connection now lets go of the microphone too, so a
reconnect cannot capture alongside the server that took it over meanwhile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 16:08:36 +00:00
parent 9f2e5087ce
commit 9b1aa45611
12 changed files with 941 additions and 575 deletions

View File

@@ -13,18 +13,22 @@ import android.os.PowerManager
import androidx.lifecycle.LifecycleService import androidx.lifecycle.LifecycleService
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import com.ts3client.config.Settings import com.ts3client.config.Settings
import com.ts3client.net.ClientStatus import com.ts3client.net.ClientStatus
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
/** /**
* Keeps the process in the foreground while connected, so the connection and its audio * 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. * survive the app leaving the screen, and offers the essentials in its notification.
*/ */
@OptIn(ExperimentalCoroutinesApi::class)
class ConnectionService : LifecycleService() { class ConnectionService : LifecycleService() {
private lateinit var sessions: SessionController private lateinit var sessions: SessionController
@@ -47,11 +51,14 @@ class ConnectionService : LifecycleService() {
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "ts3j:connection") .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "ts3j:connection")
.apply { setReferenceCounted(false); acquire() } .apply { setReferenceCounted(false); acquire() }
show(sessions.state.value) show(sessions.current.value?.state?.value ?: SessionState(), capturing = false)
lifecycleScope.launch { lifecycleScope.launch {
sessions.state.collectLatest { state -> val currentState = sessions.current.flatMapLatest { it?.state ?: flowOf(null) }
if (state.busy) { // The server count shows in the notification too.
show(state) combine(currentState, sessions.micOwner, sessions.servers) { state, owner, _ -> state to (owner != null) }
.collectLatest { (state, capturing) ->
if (state != null) {
show(state, capturing)
} else { } else {
// A reconnect leaves the server briefly; only stop once it stays gone. // A reconnect leaves the server briefly; only stop once it stays gone.
delay(STOP_DELAY_MS) delay(STOP_DELAY_MS)
@@ -62,7 +69,11 @@ class ConnectionService : LifecycleService() {
overlay = PttOverlay(this, sessions.icons, sessions::setPushToTalk) overlay = PttOverlay(this, sessions.icons, sessions::setPushToTalk)
lifecycleScope.launch { 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 && val wanted = enabled && !onScreen && state.connected && state.micActive &&
state.inputMode == Settings.InputMode.PUSH_TO_TALK state.inputMode == Settings.InputMode.PUSH_TO_TALK
val status = tree.client(state.selfClientId)?.status val status = tree.client(state.selfClientId)?.status
@@ -80,11 +91,12 @@ class ConnectionService : LifecycleService() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId) 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) { when (intent?.action) {
ACTION_TOGGLE_MUTE -> sessions.setMicMuted(!state.micMuted) ACTION_TOGGLE_MUTE -> server.setMicMuted(!state.micMuted)
ACTION_TOGGLE_DEAFEN -> sessions.setDeafened(!state.deafened) ACTION_TOGGLE_DEAFEN -> server.setDeafened(!state.deafened)
ACTION_DISCONNECT -> sessions.disconnect() ACTION_DISCONNECT -> server.disconnect()
} }
return START_NOT_STICKY return START_NOT_STICKY
} }
@@ -109,9 +121,9 @@ class ConnectionService : LifecycleService() {
} }
/** Posts the notification, re-declaring what the service uses when that changed. */ /** 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 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) val notification = notification(state)
if (types != foregroundTypes) { if (types != foregroundTypes) {
startForeground(NOTIFICATION_ID, notification, types) startForeground(NOTIFICATION_ID, notification, types)
@@ -128,9 +140,10 @@ class ConnectionService : LifecycleService() {
val open = PendingIntent.getActivity( val open = PendingIntent.getActivity(
this, 0, Intent(this, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE this, 0, Intent(this, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE
) )
val others = sessions.servers.value.size - 1
return Notification.Builder(this, CHANNEL) return Notification.Builder(this, CHANNEL)
.setSmallIcon(android.R.drawable.ic_btn_speak_now) .setSmallIcon(android.R.drawable.ic_btn_speak_now)
.setContentTitle(state.title) .setContentTitle(if (others > 0) "${state.title} +$others" else state.title)
.setContentText(state.status) .setContentText(state.status)
.setContentIntent(open) .setContentIntent(open)
.setOngoing(true) .setOngoing(true)

View File

@@ -38,7 +38,8 @@ class MainActivity : ComponentActivity() {
/** A private message notification opens its conversation. */ /** A private message notification opens its conversation. */
private fun openRequestedChat(intent: Intent) { private fun openRequestedChat(intent: Intent) {
val serverId = intent.getIntExtra(MessageNotifier.EXTRA_SERVER, -1)
val clientId = intent.getIntExtra(MessageNotifier.EXTRA_CHAT, -1) val clientId = intent.getIntExtra(MessageNotifier.EXTRA_CHAT, -1)
if (clientId >= 0) sessions.requestChat(clientId) if (serverId >= 0 && clientId >= 0) sessions.requestChat(serverId, clientId)
} }
} }

View File

@@ -19,7 +19,7 @@ class MessageNotifier(private val context: Context) {
private val manager = context.getSystemService(NotificationManager::class.java) private val manager = context.getSystemService(NotificationManager::class.java)
private val me = Person.Builder().setName("You").build() 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<Int, Conversation>() private val conversations = HashMap<Int, Conversation>()
private class Conversation(val peer: Person, val messages: MutableList<Notification.MessagingStyle.Message>) private class Conversation(val peer: Person, val messages: MutableList<Notification.MessagingStyle.Message>)
@@ -43,7 +43,7 @@ class MessageNotifier(private val context: Context) {
.setContentTitle("$from poked you") .setContentTitle("$from poked you")
.setContentText(message.ifBlank { null }) .setContentText(message.ifBlank { null })
.setCategory(Notification.CATEGORY_MESSAGE) .setCategory(Notification.CATEGORY_MESSAGE)
.setContentIntent(open(null)) .setContentIntent(open(null, null))
.setAutoCancel(true) .setAutoCancel(true)
.build() .build()
manager.notify(POKE_TAG, from.hashCode(), notification) 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. */ /** A message the peer sent, which starts the conversation's notification if it had none. */
@Synchronized @Synchronized
fun received(clientId: Int, from: String, text: String) { fun received(serverId: Int, clientId: Int, from: String, text: String) {
val conversation = conversations[clientId]?.takeIf { it.peer.name == from } val id = id(serverId, clientId)
?: Conversation(Person.Builder().setName(from).setKey("client:$clientId").build(), ArrayList()) val conversation = conversations[id]?.takeIf { it.peer.name == from }
conversations[clientId] = conversation ?: Conversation(Person.Builder().setName(from).setKey("client:$serverId:$clientId").build(), ArrayList())
add(clientId, conversation, text, conversation.peer, alert = true) 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. */ /** Our own reply, shown in a conversation already notified of; answers the reply action too. */
@Synchronized @Synchronized
fun sent(clientId: Int, text: String) { fun sent(serverId: Int, clientId: Int, text: String) {
val conversation = conversations[clientId] ?: return val conversation = conversations[id(serverId, clientId)] ?: return
add(clientId, conversation, text, me, alert = false) 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) conversation.messages += Notification.MessagingStyle.Message(text, System.currentTimeMillis(), sender)
if (conversation.messages.size > MAX_MESSAGES) conversation.messages.removeAt(0) if (conversation.messages.size > MAX_MESSAGES) conversation.messages.removeAt(0)
val style = Notification.MessagingStyle(me) val style = Notification.MessagingStyle(me)
@@ -80,38 +81,42 @@ class MessageNotifier(private val context: Context) {
.setSmallIcon(android.R.drawable.stat_notify_chat) .setSmallIcon(android.R.drawable.stat_notify_chat)
.setStyle(style) .setStyle(style)
.setCategory(Notification.CATEGORY_MESSAGE) .setCategory(Notification.CATEGORY_MESSAGE)
.setContentIntent(open(clientId)) .setContentIntent(open(serverId, clientId))
.setOnlyAlertOnce(!alert) .setOnlyAlertOnce(!alert)
.setAutoCancel(true) .setAutoCancel(true)
.addAction(replyAction(clientId)) .addAction(replyAction(serverId, clientId))
.build() .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. */ /** The conversation came on screen, or ended with the connection. */
@Synchronized @Synchronized
fun clear(clientId: Int) { fun clear(serverId: Int, clientId: Int) {
conversations.remove(clientId) val id = id(serverId, clientId)
manager.cancel(MESSAGE_TAG, clientId) conversations.remove(id)
manager.cancel(MESSAGE_TAG, id)
} }
/** The server's conversations, as its connection ended. */
@Synchronized @Synchronized
fun clearAll() { fun clearServer(serverId: Int) {
conversations.keys.toList().forEach(::clear) conversations.keys.filter { it ushr 16 == serverId }.forEach { clear(serverId, it and 0xFFFF) }
clearPokes()
} }
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) val intent = Intent(context, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
if (clientId != null) intent.putExtra(EXTRA_CHAT, clientId) if (serverId != null && clientId != null) {
return PendingIntent.getActivity(context, clientId ?: -1, intent, intent.putExtra(EXTRA_SERVER, serverId).putExtra(EXTRA_CHAT, clientId)
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT) }
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 { private fun replyAction(serverId: Int, clientId: Int): Notification.Action {
val intent = Intent(context, ReplyReceiver::class.java).putExtra(EXTRA_CHAT, clientId) val intent = Intent(context, ReplyReceiver::class.java)
.putExtra(EXTRA_SERVER, serverId).putExtra(EXTRA_CHAT, clientId)
// Mutable: the system fills the typed reply into it. // 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) PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
return Notification.Action.Builder(null, "Reply", pending) return Notification.Action.Builder(null, "Reply", pending)
.addRemoteInput(RemoteInput.Builder(KEY_REPLY).setLabel("Message").build()) .addRemoteInput(RemoteInput.Builder(KEY_REPLY).setLabel("Message").build())
@@ -119,8 +124,12 @@ class MessageNotifier(private val context: Context) {
.build() .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 { 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 EXTRA_CHAT = "com.ts3client.android.CHAT"
const val KEY_REPLY = "reply" const val KEY_REPLY = "reply"
private const val MESSAGES = "messages" private const val MESSAGES = "messages"

View File

@@ -12,179 +12,201 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull 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 * 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 * 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 * 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. * 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 * 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 * 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( class NetworkFollower(context: Context) {
context: Context,
private val session: ServerSession,
private val onChange: () -> Unit,
private val system: (String) -> Unit,
) {
private val connectivity = context.getSystemService(ConnectivityManager::class.java) private val connectivity = context.getSystemService(ConnectivityManager::class.java)
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val follows = CopyOnWriteArrayList<Follow>()
/** The system's default network; null while there is none. */ /** The system's default network; null while there is none. */
@Volatile @Volatile
private var network: Network? = null 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<Boolean>? = 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 { init {
connectivity.registerDefaultNetworkCallback(object : ConnectivityManager.NetworkCallback() { connectivity.registerDefaultNetworkCallback(object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(available: Network) { override fun onAvailable(available: Network) {
network = available network = available
reconsider() follows.forEach(Follow::reconsider)
} }
override fun onLost(lost: Network) { override fun onLost(lost: Network) {
if (network == lost) network = null 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. */ /** Follows [session] until [Follow.forget]. */
fun connecting() { fun follow(session: ServerSession, onChange: () -> Unit, system: (String) -> Unit): Follow =
recovery?.cancel() Follow(session, onChange, system).also(follows::add)
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<Boolean>()
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()
}
/** Binds new sockets to [to], or lets them go by the default network once no connection wants one. */
private fun bind(to: Network?) { private fun bind(to: Network?) {
connectivity.bindProcessToNetwork(to) connectivity.bindProcessToNetwork(to ?: follows.firstNotNullOfOrNull { f -> f.bound.takeIf { f.wanted } })
bound = to
} }
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<Boolean>? = 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<Boolean>()
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 { private companion object {
const val SETTLE_MS = 1_500L const val SETTLE_MS = 1_500L

View File

@@ -8,9 +8,10 @@ import android.content.Intent
/** Sends what the user typed into a private message notification. */ /** Sends what the user typed into a private message notification. */
class ReplyReceiver : BroadcastReceiver() { class ReplyReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
val serverId = intent.getIntExtra(MessageNotifier.EXTRA_SERVER, -1)
val clientId = intent.getIntExtra(MessageNotifier.EXTRA_CHAT, -1) val clientId = intent.getIntExtra(MessageNotifier.EXTRA_CHAT, -1)
val text = RemoteInput.getResultsFromIntent(intent)?.getCharSequence(MessageNotifier.KEY_REPLY)?.toString() val text = RemoteInput.getResultsFromIntent(intent)?.getCharSequence(MessageNotifier.KEY_REPLY)?.toString()
if (clientId < 0 || text.isNullOrBlank()) return if (serverId < 0 || clientId < 0 || text.isNullOrBlank()) return
(context.applicationContext as Ts3Application).sessions.reply(clientId, text) (context.applicationContext as Ts3Application).sessions.reply(serverId, clientId, text)
} }
} }

View File

@@ -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<SessionState> = _state
private val _tree = MutableStateFlow(ServerTree())
val tree: StateFlow<ServerTree> = _tree
private val _chats = MutableStateFlow(Chats())
val chats: StateFlow<Chats> = _chats
/** Channels the user folded away in the tree. */
private val _collapsed = MutableStateFlow(emptySet<Int>())
val collapsed: StateFlow<Set<Int>> = _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<ChatKey.Private?>(null)
val chatRequest: StateFlow<ChatKey.Private?> = _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<ChannelNode> = 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<String>) {
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<String>) {
if (scope != ChatScope.PRIVATE) setHistory(key(scope, 0), "", htmlEntries)
}
override fun onPrivateChatHistory(clientId: Int, nickname: String, htmlEntries: List<String>) =
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
}
}

View File

@@ -5,6 +5,8 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.net.Uri import android.net.Uri
import android.os.Handler
import android.os.Looper
import com.ts3client.android.audio.AndroidAudioBackend import com.ts3client.android.audio.AndroidAudioBackend
import com.ts3client.android.audio.AudioRoute import com.ts3client.android.audio.AudioRoute
import com.ts3client.audio.AudioDevice import com.ts3client.audio.AudioDevice
@@ -14,40 +16,33 @@ import com.ts3client.config.IdentityStore
import com.ts3client.config.Settings import com.ts3client.config.Settings
import com.ts3client.contacts.Contact import com.ts3client.contacts.Contact
import com.ts3client.contacts.ContactStore import com.ts3client.contacts.ContactStore
import com.ts3client.contacts.ContactType
import com.ts3client.myts.MyTeamSpeak import com.ts3client.myts.MyTeamSpeak
import com.ts3client.myts.MyTeamSpeakLogin import com.ts3client.myts.MyTeamSpeakLogin
import com.ts3client.net.ChannelNode import com.ts3client.session.Sessions
import com.ts3client.net.ConnectionListener.ChatScope
import com.ts3client.session.ServerSession
import com.ts3client.sound.SoundNotifier import com.ts3client.sound.SoundNotifier
import com.ts3client.sound.SoundPack import com.ts3client.sound.SoundPack
import com.ts3client.teamspeak.SyncItem import com.ts3client.teamspeak.SyncItem
import com.ts3client.teamspeak.TeamSpeakImporter import com.ts3client.teamspeak.TeamSpeakImporter
import com.ts3client.teamspeak.TeamSpeakSettingsDb import com.ts3client.teamspeak.TeamSpeakSettingsDb
import com.ts3client.text.BBCode import com.ts3client.text.BBCode
import com.ts3client.text.ChatHtml
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicLong
/** 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( data class SessionState(
val title: String = "", val title: String = "",
val status: String = "Not connected", val status: String = "Not connected",
val connected: Boolean = false, val connected: Boolean = false,
val busy: 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 micActive: Boolean = false,
val micPermission: Boolean = false,
val micMuted: Boolean = false, val micMuted: Boolean = false,
val deafened: Boolean = false, val deafened: Boolean = false,
val away: Boolean = false, val away: Boolean = false,
@@ -63,38 +58,46 @@ sealed interface SessionEvent {
} }
/** /**
* The app's one [ServerSession], outliving any activity: [ConnectionService] keeps the * The servers the app is on and what they share, outliving any activity: [ConnectionService]
* process in the foreground while it is connected. Session callbacks arrive on connection * keeps the process in the foreground while any is connected. Only one server captures with
* threads and are turned into immutable snapshots the UI collects. * the microphone at a time; [Sessions] hands it over.
*/ */
class SessionController(private val context: Context) { class SessionController(private val context: Context) {
val settings: Settings = Settings.load() val settings: Settings = Settings.load()
val icons = IconTheme(context, settings) val icons = IconTheme(context, settings)
private val bookmarkStore = Bookmarks.load() private val bookmarkStore = Bookmarks.load()
private val identityStore = IdentityStore.load(settings) internal val identityStore = IdentityStore.load(settings)
val identities = IdentityManager(context, identityStore, settings, bookmarkStore) { val identities = IdentityManager(context, identityStore, settings, bookmarkStore) {
_bookmarks.value = bookmarkStore.all().toList() _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 soundPlayer = SpeakingSoundPlayer(context, audio.createSoundPlayer(settings))
private val sounds = SoundNotifier(settings).also { it.setPlayer(soundPlayer) } internal val sounds = SoundNotifier(settings).also { it.setPlayer(soundPlayer) }
private val notifier = MessageNotifier(context) internal val notifier = MessageNotifier(context)
private val contactStore = ContactStore.load() internal val contactStore = ContactStore.load()
private val session = ServerSession( internal val network = NetworkFollower(context)
settings, identityStore, audio, sounds, contactStore, Listener()
)
private val conn get() = session.connection()
private val network = NetworkFollower(context, session, ::refresh, ::system)
private val _state = MutableStateFlow(snapshotState()) private val main = Handler(Looper.getMainLooper())
val state: StateFlow<SessionState> = _state 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()) private val _servers = MutableStateFlow(emptyList<ServerController>())
val tree: StateFlow<ServerTree> = _tree /** Every server the app is on or getting on, in the order they were connected. */
val servers: StateFlow<List<ServerController>> = _servers
private val _chats = MutableStateFlow(Chats()) private val _current = MutableStateFlow<ServerController?>(null)
val chats: StateFlow<Chats> = _chats /** The server on screen; null when there is none. */
val current: StateFlow<ServerController?> = _current
private val _micOwner = MutableStateFlow<ServerController?>(null)
/** The server capturing with the microphone; null when none is. */
val micOwner: StateFlow<ServerController?> = _micOwner
/** Why the last connection that ended did, for the connect form to show; null when it was left. */
private val _lastEnd = MutableStateFlow<String?>(null)
val lastEnd: StateFlow<String?> = _lastEnd
/** Copies of the contacts, as the store's own entries change in place. */ /** Copies of the contacts, as the store's own entries change in place. */
private val _contacts = MutableStateFlow(contactCopies()) private val _contacts = MutableStateFlow(contactCopies())
@@ -103,22 +106,9 @@ class SessionController(private val context: Context) {
private val _bookmarks = MutableStateFlow(bookmarkStore.all().toList()) private val _bookmarks = MutableStateFlow(bookmarkStore.all().toList())
val bookmarks: StateFlow<List<Bookmark>> = _bookmarks val bookmarks: StateFlow<List<Bookmark>> = _bookmarks
/** Channels the user folded away in the tree. */
private val _collapsed = MutableStateFlow(emptySet<Int>())
val collapsed: StateFlow<Set<Int>> = _collapsed
private val _events = MutableSharedFlow<SessionEvent>(extraBufferCapacity = 16) private val _events = MutableSharedFlow<SessionEvent>(extraBufferCapacity = 16)
val events: SharedFlow<SessionEvent> = _events val events: SharedFlow<SessionEvent> = _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) private val _onScreen = MutableStateFlow(false)
val onScreen: StateFlow<Boolean> = _onScreen val onScreen: StateFlow<Boolean> = _onScreen
@@ -128,20 +118,10 @@ class SessionController(private val context: Context) {
set(value) { set(value) {
_onScreen.value = value _onScreen.value = value
if (value) notifier.clearPokes() 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<ChatKey.Private?>(null)
val chatRequest: StateFlow<ChatKey.Private?> = _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<ChannelNode> = emptyList()
private val levels = LevelMonitor(settings, audio) private val levels = LevelMonitor(settings, audio)
val micLevel: StateFlow<MicLevel> = levels.level val micLevel: StateFlow<MicLevel> = levels.level
@Volatile @Volatile
@@ -166,11 +146,8 @@ class SessionController(private val context: Context) {
} }
// Mutations report on whichever thread made them, a connection thread included. // Mutations report on whichever thread made them, a connection thread included.
contactStore.addListener { contactStore.addListener {
treeScope.launch { _contacts.value = contactCopies()
_contacts.value = contactCopies() _servers.value.forEach(ServerController::contactsChanged)
conn.applyContacts()
}
scheduleTree(structure = false)
} }
} }
@@ -180,7 +157,7 @@ class SessionController(private val context: Context) {
// ---- connection ---- // ---- 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 = "") { fun connect(address: String, nickname: String, password: String = "", identityId: String = "") {
val (host, port) = parseAddress(address) val (host, port) = parseAddress(address)
settings.lastAddress = 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, private fun start(host: String, port: Int, nickname: String, password: String, identityId: String,
channel: String, channelPassword: String) { channel: String, channelPassword: String) {
// Capture starts once connected; without the permission yet, the UI hands it over later. val server = ServerController(nextId.incrementAndGet(), this)
if (hasMicPermission()) session.setMicrophoneActive(true) sessions.add(server.session)
_servers.update { it + server }
_current.value = server
_lastEnd.value = null
context.startForegroundService(Intent(context, ConnectionService::class.java)) context.startForegroundService(Intent(context, ConnectionService::class.java))
network.connecting() server.start(host, port, nickname, password, identityId, channel, channelPassword)
session.connect(host, port, nickname, password, identityId, channel, channelPassword)
} }
fun disconnect() { fun select(server: ServerController) {
network.leaving() if (server in _servers.value) _current.value = server
session.disconnect()
} }
/** 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() { fun leave() {
network.leaving() _servers.value.forEach(ServerController::leave)
session.shutdown() 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 ---- // ---- bookmarks ----
fun addBookmark(label: String, address: String, nickname: String, password: String, identityId: String) { 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]. */ /** A copy of the contact to edit and hand back to [saveContact]. */
fun contact(uniqueId: String): Contact? = contactStore.find(uniqueId)?.copy() 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 saveContact(contact: Contact) = contactStore.put(contact)
fun removeContact(uniqueId: String) = contactStore.remove(uniqueId) fun removeContact(uniqueId: String) = contactStore.remove(uniqueId)
@@ -319,20 +335,22 @@ class SessionController(private val context: Context) {
fun hasMicPermission() = fun hasMicPermission() =
context.checkSelfPermission(Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED context.checkSelfPermission(Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED
/** Hands the microphone to the connection; needs RECORD_AUDIO granted. */ /** Moves the microphone to [server]; needs RECORD_AUDIO granted. */
fun setMicrophoneActive(active: Boolean) { fun giveMicrophoneTo(server: ServerController) {
session.setMicrophoneActive(active) if (server.isConnected) sessions.giveMicrophoneTo(server.session)
refresh()
} }
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) private fun microphoneMoved() {
val owner = sessions.microphoneOwner()
fun setAway(away: Boolean) = session.setAway(away, null) for (server in _servers.value) {
server.micOwner = server.session == owner
fun setPushToTalk(pressed: Boolean) { server.refresh()
if (session.isConnected) conn.microphone?.setPushToTalk(pressed) }
_micOwner.value = _servers.value.firstOrNull { it.micOwner }
updateLevels()
} }
private val overlayPrefs = context.getSharedPreferences("overlay", Context.MODE_PRIVATE) private val overlayPrefs = context.getSharedPreferences("overlay", Context.MODE_PRIVATE)
@@ -345,22 +363,30 @@ class SessionController(private val context: Context) {
_pttOverlay.value = enabled _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. */ /** Saves the settings and puts the voice and device ones into effect at once. */
fun applySettings() { fun applySettings() {
settings.save() settings.save()
soundPlayer.setOutputDevice(settings.outputDevice) soundPlayer.setOutputDevice(settings.outputDevice)
if (session.isConnected) {
conn.playback?.setMasterVolume(settings.outputVolume)
conn.playback?.setOutputDevice(settings.outputDevice)
}
try { 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() levels.apply()
} catch (e: RuntimeException) { } 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. */ /** 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() { private fun updateLevels() {
val source = if (session.isConnected && conn.isMicrophoneActive) conn.microphone else null val owner = _micOwner.value
levels.update(watchingLevel, source, session.isBusy || network.isRecovering, hasMicPermission()) levels.update(watchingLevel, owner?.microphone, owner?.isBusy == true, hasMicPermission())
} }
// ---- channels and clients ---- // ---- notifications ----
fun joinChannel(channelId: Int, password: String? = null) { /** Shows a private chat a notification was tapped for. */
if (session.isConnected) conn.joinChannel(channelId, password) fun requestChat(serverId: Int, clientId: Int) {
} val server = server(serverId) ?: return
_current.value = server
fun setSubscribed(channelId: Int, subscribed: Boolean) { server.requestChat(clientId)
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
} }
/** Answers a private message from its notification. */ /** Answers a private message from its notification. */
fun reply(clientId: Int, text: String) { fun reply(serverId: Int, clientId: Int, text: String) {
if (session.isConnected) send(ChatKey.Private(clientId), text) else notifier.clear(clientId) server(serverId)?.reply(clientId, text) ?: notifier.clear(serverId, 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<String>) {
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<String>) {
if (scope != ChatScope.PRIVATE) setHistory(key(scope, 0), "", htmlEntries)
}
override fun onPrivateChatHistory(clientId: Int, nickname: String, htmlEntries: List<String>) =
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)
} }
private companion object { private companion object {
const val TREE_FRAME_MS = 30L
const val DEFAULT_PORT = 9987 const val DEFAULT_PORT = 9987
const val OVERLAY_ENABLED = "enabled" const val OVERLAY_ENABLED = "enabled"

View File

@@ -15,6 +15,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.saveable.rememberSaveableStateHolder
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -23,7 +24,10 @@ import com.ts3client.android.SessionEvent
@Composable @Composable
fun App(vm: SessionController) { 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() } val snackbar = remember { SnackbarHostState() }
var settingsOpen by rememberSaveable { mutableStateOf(false) } var settingsOpen by rememberSaveable { mutableStateOf(false) }
var contactsOpen by rememberSaveable { mutableStateOf(false) } var contactsOpen by rememberSaveable { mutableStateOf(false) }
@@ -43,7 +47,7 @@ fun App(vm: SessionController) {
val context = LocalContext.current val context = LocalContext.current
val permissions = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { granted -> 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) = fun missing(vararg names: String) =
names.filter { context.checkSelfPermission(it) != PackageManager.PERMISSION_GRANTED } 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. // The connection runs without either permission, just silent or without its notification.
val connectAsking: (() -> Unit) -> Unit = { connect -> val connectAsking: (() -> Unit) -> Unit = { connect ->
connect() connect()
connectOpen = false
val ask = missing(Manifest.permission.RECORD_AUDIO, Manifest.permission.POST_NOTIFICATIONS) val ask = missing(Manifest.permission.RECORD_AUDIO, Manifest.permission.POST_NOTIFICATIONS)
if (ask.isNotEmpty()) permissions.launch(ask.toTypedArray()) if (ask.isNotEmpty()) permissions.launch(ask.toTypedArray())
} }
val enableMicrophone = { val enableMicrophone: () -> Unit = {
if (missing(Manifest.permission.RECORD_AUDIO).isEmpty()) vm.setMicrophoneActive(true) if (missing(Manifest.permission.RECORD_AUDIO).isEmpty()) vm.current.value?.let(vm::giveMicrophoneTo)
else permissions.launch(arrayOf(Manifest.permission.RECORD_AUDIO)) else permissions.launch(arrayOf(Manifest.permission.RECORD_AUDIO))
} }
@@ -66,6 +71,7 @@ fun App(vm: SessionController) {
} }
CompositionLocalProvider(LocalIconTheme provides vm.icons) { CompositionLocalProvider(LocalIconTheme provides vm.icons) {
val server = current
when { when {
settingsOpen -> SettingsScreen(vm, onBack = { settingsOpen = false }) settingsOpen -> SettingsScreen(vm, onBack = { settingsOpen = false })
contactsOpen -> ContactsScreen(vm, editingContact, onEdit = { uniqueId -> contactsOpen -> ContactsScreen(vm, editingContact, onEdit = { uniqueId ->
@@ -73,9 +79,14 @@ fun App(vm: SessionController) {
contactDirect = false contactDirect = false
editingContact = uniqueId editingContact = uniqueId
}, onBack = { contactsOpen = false }) }, onBack = { contactsOpen = false })
state.busy || state.connected -> server != null && !connectOpen -> {
ServerScreen(vm, snackbar, { settingsOpen = true }, openContacts, enableMicrophone) serverStates.SaveableStateProvider(server.id) {
else -> ConnectScreen(vm, snackbar, { settingsOpen = true }, { openContacts(null) }, connectAsking) 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)
} }
} }

View File

@@ -8,7 +8,9 @@ import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth 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.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width 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.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProvideTextStyle
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -77,7 +80,7 @@ fun ChannelTree(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val rows = remember(tree, collapsed, currentChannel) { flatten(tree, collapsed, currentChannel) } val rows = remember(tree, collapsed, currentChannel) { flatten(tree, collapsed, currentChannel) }
LazyColumn(modifier.fillMaxSize()) { LazyColumn(modifier.fillMaxSize(), contentPadding = PaddingValues(vertical = 4.dp)) {
item(key = "server") { item(key = "server") {
RowLayout(depth = 0, onClick = null) { RowLayout(depth = 0, onClick = null) {
PackIcon("SERVER_GREEN", null, size = ICON) PackIcon("SERVER_GREEN", null, size = ICON)
@@ -93,20 +96,23 @@ fun ChannelTree(
} }
} }
private val ICON = 20.dp private val ICON = 16.dp
private val INDENT = 16.dp private val INDENT = 14.dp
private val ROW = 26.dp
@Composable @Composable
private fun RowLayout(depth: Int, onClick: (() -> Unit)?, content: @Composable RowScope.() -> Unit) { private fun RowLayout(depth: Int, onClick: (() -> Unit)?, content: @Composable RowScope.() -> Unit) {
Row( Row(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.heightIn(min = 40.dp) .height(ROW)
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier) .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, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(4.dp),
) { content() } ) {
ProvideTextStyle(MaterialTheme.typography.bodyMedium, content = { content() })
}
} }
@Composable @Composable
@@ -114,10 +120,14 @@ private fun ChannelRow(row: TreeRow.Channel, onToggle: (ChannelItem) -> Unit, on
val channel = row.channel val channel = row.channel
val spacer = channel.spacer val spacer = channel.spacer
RowLayout(row.depth, onClick = { onChannel(channel) }) { 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) { if (row.expandable) {
Icon(Icons.Filled.KeyboardArrowDown, if (row.expanded) "Collapse" else "Expand", 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) { if (spacer != null) {
@@ -127,7 +137,6 @@ private fun ChannelRow(row: TreeRow.Channel, onToggle: (ChannelItem) -> Unit, on
Text( Text(
channel.name, channel.name,
Modifier.weight(1f), Modifier.weight(1f),
fontWeight = FontWeight.SemiBold,
color = if (row.current) MaterialTheme.colorScheme.primary else Color.Unspecified, color = if (row.current) MaterialTheme.colorScheme.primary else Color.Unspecified,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, 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) { private fun ClientRow(row: TreeRow.Client, onClient: (ClientItem) -> Unit) {
val client = row.client val client = row.client
RowLayout(row.depth, onClick = { onClient(client) }) { RowLayout(row.depth, onClick = { onClient(client) }) {
Spacer(Modifier.width(ICON)) Spacer(Modifier.width(ICON + 4.dp))
StatusIcon(client.status) StatusIcon(client.status)
Text( Text(
client.name, client.name,

View File

@@ -1,5 +1,6 @@
package com.ts3client.android.ui package com.ts3client.android.ui
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column 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.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
@@ -59,8 +61,10 @@ fun ConnectScreen(
onSettings: () -> Unit, onSettings: () -> Unit,
onContacts: () -> Unit, onContacts: () -> Unit,
onConnect: (() -> Unit) -> 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 bookmarks by vm.bookmarks.collectAsStateWithLifecycle()
val identities by vm.identities.all.collectAsStateWithLifecycle() val identities by vm.identities.all.collectAsStateWithLifecycle()
var address by rememberSaveable { mutableStateOf(vm.lastAddress) } var address by rememberSaveable { mutableStateOf(vm.lastAddress) }
@@ -70,10 +74,15 @@ fun ConnectScreen(
var naming by rememberSaveable { mutableStateOf(false) } var naming by rememberSaveable { mutableStateOf(false) }
var choosingFor by remember { mutableStateOf<Bookmark?>(null) } var choosingFor by remember { mutableStateOf<Bookmark?>(null) }
if (onBack != null) BackHandler(onBack = onBack)
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text("TeamSpeak") }, title = { Text("TeamSpeak") },
navigationIcon = {
if (onBack != null) IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") }
},
actions = { actions = {
IconButton(onClick = onContacts) { PackIcon("CONTACT", "Contacts") } IconButton(onClick = onContacts) { PackIcon("CONTACT", "Contacts") }
IconButton(onClick = onSettings) { PackIcon("SETTINGS", "Settings") } IconButton(onClick = onSettings) { PackIcon("SETTINGS", "Settings") }
@@ -101,8 +110,8 @@ fun ConnectScreen(
identityId = it identityId = it
nickname = vm.nicknameFor(it) nickname = vm.nicknameFor(it)
} }
if (state.status != "Not connected") { lastEnd?.let {
Text(state.status, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyMedium) Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyMedium)
} }
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button( Button(

View File

@@ -1,5 +1,9 @@
package com.ts3client.android.ui 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.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box 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.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding 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.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.Badge
import androidx.compose.material3.BadgedBox import androidx.compose.material3.BadgedBox
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.IconToggleButton import androidx.compose.material3.IconToggleButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.PrimaryTabRow import androidx.compose.material3.MenuDefaults
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Tab
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.minimumInteractiveComponentSize
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.Saver
@@ -39,14 +52,18 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.ts3client.android.ChatKey import com.ts3client.android.ChatKey
import com.ts3client.android.ServerController
import com.ts3client.android.SessionController import com.ts3client.android.SessionController
import com.ts3client.android.SessionState import com.ts3client.android.SessionState
import com.ts3client.config.Settings import com.ts3client.config.Settings
@@ -75,35 +92,37 @@ private val ChatKeySaver = Saver<ChatKey, String>(
@Composable @Composable
fun ServerScreen( fun ServerScreen(
vm: SessionController, vm: SessionController,
server: ServerController,
snackbar: SnackbarHostState, snackbar: SnackbarHostState,
onSettings: () -> Unit, onSettings: () -> Unit,
/** Opens the contacts, on the given client's page when one is named. */ /** Opens the contacts, on the given client's page when one is named. */
onContacts: (String?) -> Unit, onContacts: (String?) -> Unit,
onEnableMicrophone: () -> Unit, onEnableMicrophone: () -> Unit,
onConnectOther: () -> Unit,
) { ) {
val state by vm.state.collectAsStateWithLifecycle() val state by server.state.collectAsStateWithLifecycle()
val tree by vm.tree.collectAsStateWithLifecycle() val tree by server.tree.collectAsStateWithLifecycle()
val chats by vm.chats.collectAsStateWithLifecycle() val chats by server.chats.collectAsStateWithLifecycle()
val collapsed by vm.collapsed.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>(ChatKey.Channel) } var chatKey by rememberSaveable(stateSaver = ChatKeySaver) { mutableStateOf<ChatKey>(ChatKey.Channel) }
val conversation = chats[chatKey] ?: chats.channel val conversation = chats[chatKey] ?: chats.channel
var clientSheet by remember { mutableStateOf<Int?>(null) } var clientSheet by remember { mutableStateOf<Int?>(null) }
var channelSheet by remember { mutableStateOf<Int?>(null) } var channelSheet by remember { mutableStateOf<Int?>(null) }
val chatRequest by vm.chatRequest.collectAsStateWithLifecycle() val chatRequest by server.chatRequest.collectAsStateWithLifecycle()
LaunchedEffect(chatRequest) { LaunchedEffect(chatRequest) {
val key = chatRequest ?: return@LaunchedEffect val key = chatRequest ?: return@LaunchedEffect
chatKey = if (chats[key] != null) key else vm.openPrivateChat(key.clientId) chatKey = if (chats[key] != null) key else server.openPrivateChat(key.clientId)
tab = TAB_CHAT showChat = true
vm.chatRequestHandled() server.chatRequestHandled()
} }
val viewing = if (tab == TAB_CHAT) conversation.key else null val viewing = if (showChat) conversation.key else null
LifecycleResumeEffect(viewing) { LifecycleResumeEffect(viewing) {
vm.viewing = viewing server.viewing = viewing
onPauseOrDispose { vm.viewing = null } onPauseOrDispose { server.viewing = null }
} }
val uriHandler = LocalUriHandler.current val uriHandler = LocalUriHandler.current
@@ -116,46 +135,34 @@ fun ServerScreen(
} }
} }
BackHandler(showChat) { showChat = false }
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { title = { ServerSwitcher(vm, server, state, onConnectOther) },
Column { expandedHeight = 48.dp,
Text(state.title, maxLines = 1, overflow = TextOverflow.Ellipsis)
if (!state.connected) {
Text(state.status, style = MaterialTheme.typography.bodySmall, maxLines = 1,
overflow = TextOverflow.Ellipsis)
}
}
},
actions = { actions = {
IconButton(onClick = { onContacts(null) }) { PackIcon("CONTACT", "Contacts") } IconButton(onClick = { onContacts(null) }) { PackIcon("CONTACT", "Contacts") }
IconButton(onClick = onSettings) { PackIcon("SETTINGS", "Settings") } IconButton(onClick = onSettings) { PackIcon("SETTINGS", "Settings") }
IconButton(onClick = vm::disconnect) { PackIcon("DISCONNECT", "Disconnect") }
}, },
) )
}, },
bottomBar = { bottomBar = {
val self = tree.client(state.selfClientId) val self = tree.client(state.selfClientId)
val talking = self?.status == ClientStatus.TALKING || self?.status == ClientStatus.COMMANDER_TALKING 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) }, snackbarHost = { SnackbarHost(snackbar) },
) { padding -> ) { padding ->
Column(Modifier.padding(padding).consumeWindowInsets(padding).imePadding()) { Column(Modifier.padding(padding).consumeWindowInsets(padding).imePadding()) {
PrimaryTabRow(selectedTabIndex = tab) { if (!showChat) {
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) {
ChannelTree( ChannelTree(
serverName = state.title, serverName = state.title,
tree = tree, tree = tree,
collapsed = collapsed, collapsed = collapsed,
currentChannel = state.channelId, currentChannel = state.channelId,
onToggle = { vm.toggleCollapsed(it.id) }, onToggle = { server.toggleCollapsed(it.id) },
onChannel = { if (it.spacer == null) channelSheet = it.id }, onChannel = { if (it.spacer == null) channelSheet = it.id },
onClient = { clientSheet = it.id }, onClient = { clientSheet = it.id },
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
@@ -168,9 +175,9 @@ fun ServerScreen(
onSelect = { chatKey = it }, onSelect = { chatKey = it },
onClose = { key -> onClose = { key ->
if (chatKey == key) chatKey = ChatKey.Channel 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, onLink = onLink,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
@@ -184,17 +191,17 @@ fun ServerScreen(
ClientSheet( ClientSheet(
client = client, client = client,
away = state.away, away = state.away,
initialVolume = remember(id) { vm.clientVolume(id) }, initialVolume = remember(id) { server.clientVolume(id) },
onDismiss = { clientSheet = null }, onDismiss = { clientSheet = null },
onMessage = { onMessage = {
clientSheet = null clientSheet = null
chatKey = vm.openPrivateChat(id) chatKey = server.openPrivateChat(id)
tab = TAB_CHAT showChat = true
}, },
onPoke = { vm.poke(id, it); clientSheet = null }, onPoke = { server.poke(id, it); clientSheet = null },
onVolume = { vm.setClientVolume(id, it) }, onVolume = { server.setClientVolume(id, it) },
onAway = { vm.setAway(it); clientSheet = null }, onAway = { server.setAway(it); clientSheet = null },
onAddContact = { vm.addContact(id, it) }, onAddContact = { server.addContact(id, it) },
onEditContact = { clientSheet = null; onContacts(client.uniqueId) }, onEditContact = { clientSheet = null; onContacts(client.uniqueId) },
onRemoveContact = { vm.removeContact(client.uniqueId) }, onRemoveContact = { vm.removeContact(client.uniqueId) },
) )
@@ -206,57 +213,108 @@ fun ServerScreen(
channel = channel, channel = channel,
current = id == state.channelId, current = id == state.channelId,
onDismiss = { channelSheet = null }, onDismiss = { channelSheet = null },
onJoin = { vm.joinChannel(id, it); channelSheet = null }, onJoin = { server.joinChannel(id, it); channelSheet = null },
onSubscribe = { vm.setSubscribed(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, 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.
/** Mute, deafen and away, and the push-to-talk button when that is how the microphone is used. */ */
@Composable @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) { Surface(tonalElevation = 3.dp) {
Column(Modifier.fillMaxWidth().navigationBarsPadding().padding(horizontal = 16.dp, vertical = 8.dp), // The same places whatever shows: equal sides keep the talk button's space centred.
verticalArrangement = Arrangement.spacedBy(8.dp)) { Row(Modifier.fillMaxWidth().navigationBarsPadding().padding(horizontal = 8.dp, vertical = 4.dp),
if (state.connected && state.micActive && state.inputMode == Settings.InputMode.PUSH_TO_TALK) { horizontalArrangement = Arrangement.spacedBy(4.dp),
PushToTalk(talking, vm::setPushToTalk) verticalAlignment = Alignment.CenterVertically) {
} Row(Modifier.weight(1f), horizontalArrangement = Arrangement.SpaceEvenly) { mic(); deafen() }
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly, Box(Modifier.weight(1.3f).height(48.dp), contentAlignment = Alignment.CenterStart) { middle() }
verticalAlignment = Alignment.CenterVertically) { Row(Modifier.weight(1f), horizontalArrangement = Arrangement.SpaceEvenly) { away(); chat() }
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))
}
}
} }
} }
} }
/** An icon button that also answers a long press. */
@Composable @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 val colors = MaterialTheme.colorScheme
Surface( Surface(
Modifier modifier
.fillMaxWidth() .height(48.dp)
.height(64.dp)
.pointerInput(Unit) { .pointerInput(Unit) {
detectTapGestures(onPress = { detectTapGestures(onPress = {
onPressed(true) 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, color = if (talking) StatusColors.talking else colors.primaryContainer,
contentColor = if (talking) Color.White else colors.onPrimaryContainer, 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) },
)
}
}
}

View File

@@ -89,9 +89,15 @@ public final class Sessions {
assignMicrophoneIfFree(); 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) { public void disconnected(ServerSession session) {
if (microphoneOwner == session) microphoneOwner = null; if (microphoneOwner == session) {
microphoneOwner = null;
handover.execute(() -> session.setMicrophoneActive(false));
}
assignMicrophoneIfFree(); assignMicrophoneIfFree();
} }