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

View File

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

View File

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

View File

@@ -12,40 +12,66 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import java.util.concurrent.CopyOnWriteArrayList
/**
* Keeps the connection on the network Android routes over.
* Keeps the connections on the network Android routes over.
*
* A TS3 server knows its clients by address, so once the phone moves from Wi-Fi to mobile
* data (or back) the old connection is dead weight: it can only time out. This notices the
* data (or back) an old connection is dead weight: it can only time out. This notices the
* move, says goodbye over the old network while that still works, and connects again over
* the new one, back into the channel we were in. A server that still holds our old
* connection may refuse the new one for a while as a clone, so a failed attempt is retried.
*
* The process's sockets are bound to the network a connection was made over. Otherwise the
* old connection would silently follow the new default network, and its goodbye would come
* from an address the server does not know.
* from an address the server does not know. A socket keeps the network it was made on, so
* each connection remembers its own.
*/
class NetworkFollower(
context: Context,
private val session: ServerSession,
private val onChange: () -> Unit,
private val system: (String) -> Unit,
) {
class NetworkFollower(context: Context) {
private val connectivity = context.getSystemService(ConnectivityManager::class.java)
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val follows = CopyOnWriteArrayList<Follow>()
/** The system's default network; null while there is none. */
@Volatile
private var network: Network? = null
init {
connectivity.registerDefaultNetworkCallback(object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(available: Network) {
network = available
follows.forEach(Follow::reconsider)
}
override fun onLost(lost: Network) {
if (network == lost) network = null
follows.forEach(Follow::reconsider)
}
})
}
/** Follows [session] until [Follow.forget]. */
fun follow(session: ServerSession, onChange: () -> Unit, system: (String) -> Unit): Follow =
Follow(session, onChange, system).also(follows::add)
/** Binds new sockets to [to], or lets them go by the default network once no connection wants one. */
private fun bind(to: Network?) {
connectivity.bindProcessToNetwork(to ?: follows.firstNotNullOfOrNull { f -> f.bound.takeIf { f.wanted } })
}
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
private var bound: Network? = null
internal 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
internal var wanted = false
private var settle: Job? = null
@Volatile
@@ -60,25 +86,11 @@ class NetworkFollower(
val isRecovering: Boolean get() = recovery?.isActive == true
init {
connectivity.registerDefaultNetworkCallback(object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(available: Network) {
network = available
reconsider()
}
override fun onLost(lost: Network) {
if (network == lost) network = null
reconsider()
}
})
}
/** Before a connect the user asked for, which goes over the network there is now. */
fun connecting() {
recovery?.cancel()
wanted = true
bind(connectivity.activeNetwork)
bindHere(connectivity.activeNetwork)
}
/** The user leaves the server. */
@@ -88,6 +100,12 @@ class NetworkFollower(
unbind()
}
/** The session is thrown away. */
fun forget() {
leaving()
follows.remove(this)
}
fun onConnected() {
attempt?.complete(true)
}
@@ -110,12 +128,12 @@ class NetworkFollower(
}
/** Waits for the network to settle: a handover often shows several changes in a row. */
private fun reconsider() {
internal fun reconsider() {
synchronized(this) {
settle?.cancel()
settle = scope.launch {
delay(SETTLE_MS)
synchronized(this@NetworkFollower) {
synchronized(this@Follow) {
if (wanted && bound != network && !isRecovering) recovery = recover()
}
}
@@ -130,7 +148,7 @@ class NetworkFollower(
var retry = RETRY_MS
while (true) {
val current = awaitNetwork()
bind(current)
bindHere(current)
show("Reconnecting…")
val done = CompletableDeferred<Boolean>()
attempt = done
@@ -179,12 +197,16 @@ class NetworkFollower(
onChange()
}
private fun bind(to: Network?) {
connectivity.bindProcessToNetwork(to)
private fun bindHere(to: Network?) {
bound = to
bind(to)
}
private fun unbind() = bind(null)
private fun unbind() {
bound = null
bind(null)
}
}
private companion object {
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. */
class ReplyReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val serverId = intent.getIntExtra(MessageNotifier.EXTRA_SERVER, -1)
val clientId = intent.getIntExtra(MessageNotifier.EXTRA_CHAT, -1)
val text = RemoteInput.getResultsFromIntent(intent)?.getCharSequence(MessageNotifier.KEY_REPLY)?.toString()
if (clientId < 0 || text.isNullOrBlank()) return
(context.applicationContext as Ts3Application).sessions.reply(clientId, text)
if (serverId < 0 || clientId < 0 || text.isNullOrBlank()) return
(context.applicationContext as Ts3Application).sessions.reply(serverId, clientId, text)
}
}

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.pm.PackageManager
import android.net.Uri
import android.os.Handler
import android.os.Looper
import com.ts3client.android.audio.AndroidAudioBackend
import com.ts3client.android.audio.AudioRoute
import com.ts3client.audio.AudioDevice
@@ -14,40 +16,33 @@ import com.ts3client.config.IdentityStore
import com.ts3client.config.Settings
import com.ts3client.contacts.Contact
import com.ts3client.contacts.ContactStore
import com.ts3client.contacts.ContactType
import com.ts3client.myts.MyTeamSpeak
import com.ts3client.myts.MyTeamSpeakLogin
import com.ts3client.net.ChannelNode
import com.ts3client.net.ConnectionListener.ChatScope
import com.ts3client.session.ServerSession
import com.ts3client.session.Sessions
import com.ts3client.sound.SoundNotifier
import com.ts3client.sound.SoundPack
import com.ts3client.teamspeak.SyncItem
import com.ts3client.teamspeak.TeamSpeakImporter
import com.ts3client.teamspeak.TeamSpeakSettingsDb
import com.ts3client.text.BBCode
import com.ts3client.text.ChatHtml
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong
import java.util.concurrent.atomic.AtomicInteger
/** What the app shows of the connection and the local client on it. */
/** What the app shows of a connection and the local client on it. */
data class SessionState(
val title: String = "",
val status: String = "Not connected",
val connected: Boolean = false,
val busy: Boolean = false,
/** Whether this server holds the microphone, which only one server can at a time. */
val micActive: Boolean = false,
val micPermission: Boolean = false,
val micMuted: Boolean = false,
val deafened: Boolean = false,
val away: Boolean = false,
@@ -63,38 +58,46 @@ sealed interface SessionEvent {
}
/**
* The app's one [ServerSession], outliving any activity: [ConnectionService] keeps the
* process in the foreground while it is connected. Session callbacks arrive on connection
* threads and are turned into immutable snapshots the UI collects.
* The servers the app is on and what they share, outliving any activity: [ConnectionService]
* keeps the process in the foreground while any is connected. Only one server captures with
* the microphone at a time; [Sessions] hands it over.
*/
class SessionController(private val context: Context) {
val settings: Settings = Settings.load()
val icons = IconTheme(context, settings)
private val bookmarkStore = Bookmarks.load()
private val identityStore = IdentityStore.load(settings)
internal val identityStore = IdentityStore.load(settings)
val identities = IdentityManager(context, identityStore, settings, bookmarkStore) {
_bookmarks.value = bookmarkStore.all().toList()
}
private val audio = AndroidAudioBackend(context)
internal val audio = AndroidAudioBackend(context)
private val soundPlayer = SpeakingSoundPlayer(context, audio.createSoundPlayer(settings))
private val sounds = SoundNotifier(settings).also { it.setPlayer(soundPlayer) }
private val notifier = MessageNotifier(context)
private val contactStore = ContactStore.load()
private val session = ServerSession(
settings, identityStore, audio, sounds, contactStore, Listener()
)
private val conn get() = session.connection()
private val network = NetworkFollower(context, session, ::refresh, ::system)
internal val sounds = SoundNotifier(settings).also { it.setPlayer(soundPlayer) }
internal val notifier = MessageNotifier(context)
internal val contactStore = ContactStore.load()
internal val network = NetworkFollower(context)
private val _state = MutableStateFlow(snapshotState())
val state: StateFlow<SessionState> = _state
private val main = Handler(Looper.getMainLooper())
private val nextId = AtomicInteger()
/** Touched on the main thread only, as [Sessions] wants. */
private val sessions = Sessions { main.post(::microphoneMoved) }
private val _tree = MutableStateFlow(ServerTree())
val tree: StateFlow<ServerTree> = _tree
private val _servers = MutableStateFlow(emptyList<ServerController>())
/** 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())
val chats: StateFlow<Chats> = _chats
private val _current = MutableStateFlow<ServerController?>(null)
/** 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. */
private val _contacts = MutableStateFlow(contactCopies())
@@ -103,22 +106,9 @@ class SessionController(private val context: Context) {
private val _bookmarks = MutableStateFlow(bookmarkStore.all().toList())
val bookmarks: StateFlow<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)
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)
val onScreen: StateFlow<Boolean> = _onScreen
@@ -128,20 +118,10 @@ class SessionController(private val context: Context) {
set(value) {
_onScreen.value = value
if (value) notifier.clearPokes()
// The process may be let go of any time once out of sight.
else contactStore.flush()
}
/** A private chat a notification asked to show, until the UI took it up. */
private val _chatRequest = MutableStateFlow<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)
val micLevel: StateFlow<MicLevel> = levels.level
@Volatile
@@ -166,11 +146,8 @@ class SessionController(private val context: Context) {
}
// Mutations report on whichever thread made them, a connection thread included.
contactStore.addListener {
treeScope.launch {
_contacts.value = contactCopies()
conn.applyContacts()
}
scheduleTree(structure = false)
_servers.value.forEach(ServerController::contactsChanged)
}
}
@@ -180,7 +157,7 @@ class SessionController(private val context: Context) {
// ---- connection ----
/** Connects to `host[:port]`; an empty [identityId] means the default identity. */
/** Connects to `host[:port]`, as another server; an empty [identityId] means the default identity. */
fun connect(address: String, nickname: String, password: String = "", identityId: String = "") {
val (host, port) = parseAddress(address)
settings.lastAddress = address
@@ -196,24 +173,69 @@ class SessionController(private val context: Context) {
private fun start(host: String, port: Int, nickname: String, password: String, identityId: String,
channel: String, channelPassword: String) {
// Capture starts once connected; without the permission yet, the UI hands it over later.
if (hasMicPermission()) session.setMicrophoneActive(true)
val server = ServerController(nextId.incrementAndGet(), this)
sessions.add(server.session)
_servers.update { it + server }
_current.value = server
_lastEnd.value = null
context.startForegroundService(Intent(context, ConnectionService::class.java))
network.connecting()
session.connect(host, port, nickname, password, identityId, channel, channelPassword)
server.start(host, port, nickname, password, identityId, channel, channelPassword)
}
fun disconnect() {
network.leaving()
session.disconnect()
fun select(server: ServerController) {
if (server in _servers.value) _current.value = server
}
/** Leaves the server before returning, for when the app goes away with the connection. */
/** Leaves every server before returning, for when the app goes away with the connections. */
fun leave() {
network.leaving()
session.shutdown()
_servers.value.forEach(ServerController::leave)
contactStore.flush()
}
fun server(id: Int): ServerController? = _servers.value.firstOrNull { it.id == id }
/** A server's state changed; one that is done with goes from the list. */
internal fun serverChanged(server: ServerController) {
if (server.micOwner) updateLevels()
if (server.ended) main.post { remove(server) }
}
private fun remove(server: ServerController) {
if (server !in _servers.value || !server.ended) return
sessions.remove(server.session)
val rest = _servers.value - server
_servers.value = rest
if (_current.value == server) _current.value = rest.lastOrNull()
server.dispose()
val reason = server.state.value.status
_lastEnd.value = if (server.left) null else reason
if (!server.left && rest.isNotEmpty()) error("${server.state.value.title}: $reason")
}
internal fun connected(server: ServerController) {
// The first connect generates an identity when there was none.
identities.refresh()
main.post {
// Without the permission yet, the UI hands the microphone over once it is granted.
if (hasMicPermission()) sessions.connected(server.session)
}
}
internal fun disconnected(server: ServerController) {
main.post { sessions.disconnected(server.session) }
}
internal fun error(message: String) {
_events.tryEmit(SessionEvent.Error(message))
}
internal fun poked(from: String, message: String) {
_events.tryEmit(SessionEvent.Poke(from, message))
if (!foreground) notifier.poke(from, BBCode.toPlain(message))
}
internal fun talking(transmitting: Boolean) = levels.talking(transmitting)
// ---- bookmarks ----
fun addBookmark(label: String, address: String, nickname: String, password: String, identityId: String) {
@@ -254,12 +276,6 @@ class SessionController(private val context: Context) {
/** A copy of the contact to edit and hand back to [saveContact]. */
fun contact(uniqueId: String): Contact? = contactStore.find(uniqueId)?.copy()
/** Files a client on the server as a contact; a new entry takes the type's defaults. */
fun addContact(clientId: Int, type: ContactType) {
val client = conn.model.getClient(clientId) ?: return
contactStore.add(client.uniqueId, client.nickname, type, settings.contactDefaults)
}
fun saveContact(contact: Contact) = contactStore.put(contact)
fun removeContact(uniqueId: String) = contactStore.remove(uniqueId)
@@ -319,20 +335,22 @@ class SessionController(private val context: Context) {
fun hasMicPermission() =
context.checkSelfPermission(Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED
/** Hands the microphone to the connection; needs RECORD_AUDIO granted. */
fun setMicrophoneActive(active: Boolean) {
session.setMicrophoneActive(active)
refresh()
/** Moves the microphone to [server]; needs RECORD_AUDIO granted. */
fun giveMicrophoneTo(server: ServerController) {
if (server.isConnected) sessions.giveMicrophoneTo(server.session)
}
fun setMicMuted(muted: Boolean) = session.setMicMuted(muted)
/** Opens the microphone of the server holding it while push-to-talk is held. */
fun setPushToTalk(pressed: Boolean) = sessions.setPushToTalk(pressed)
fun setDeafened(deafened: Boolean) = session.setDeafened(deafened)
fun setAway(away: Boolean) = session.setAway(away, null)
fun setPushToTalk(pressed: Boolean) {
if (session.isConnected) conn.microphone?.setPushToTalk(pressed)
private fun microphoneMoved() {
val owner = sessions.microphoneOwner()
for (server in _servers.value) {
server.micOwner = server.session == owner
server.refresh()
}
_micOwner.value = _servers.value.firstOrNull { it.micOwner }
updateLevels()
}
private val overlayPrefs = context.getSharedPreferences("overlay", Context.MODE_PRIVATE)
@@ -345,22 +363,30 @@ class SessionController(private val context: Context) {
_pttOverlay.value = enabled
}
/** Switches between push to talk and voice activation; returns the new mode. */
fun toggleInputMode(): Settings.InputMode {
settings.inputMode = if (settings.inputMode == Settings.InputMode.PUSH_TO_TALK)
Settings.InputMode.VOICE_ACTIVATION else Settings.InputMode.PUSH_TO_TALK
applySettings()
return settings.inputMode
}
/** Saves the settings and puts the voice and device ones into effect at once. */
fun applySettings() {
settings.save()
soundPlayer.setOutputDevice(settings.outputDevice)
if (session.isConnected) {
conn.playback?.setMasterVolume(settings.outputVolume)
conn.playback?.setOutputDevice(settings.outputDevice)
}
try {
if (session.isConnected) conn.microphone?.apply(settings)
for (server in _servers.value) {
server.playback?.setMasterVolume(settings.outputVolume)
server.playback?.setOutputDevice(settings.outputDevice)
server.microphone?.apply(settings)
server.refresh()
server.scheduleTree(structure = false)
}
levels.apply()
} catch (e: RuntimeException) {
_events.tryEmit(SessionEvent.Error(e.message ?: "Microphone unavailable"))
error(e.message ?: "Microphone unavailable")
}
refresh()
scheduleTree(structure = false)
}
/** Microphone preset and playback usage; the open lines move over at once. */
@@ -377,206 +403,25 @@ class SessionController(private val context: Context) {
}
private fun updateLevels() {
val source = if (session.isConnected && conn.isMicrophoneActive) conn.microphone else null
levels.update(watchingLevel, source, session.isBusy || network.isRecovering, hasMicPermission())
val owner = _micOwner.value
levels.update(watchingLevel, owner?.microphone, owner?.isBusy == true, hasMicPermission())
}
// ---- channels and clients ----
// ---- notifications ----
fun joinChannel(channelId: Int, password: String? = null) {
if (session.isConnected) conn.joinChannel(channelId, password)
}
fun setSubscribed(channelId: Int, subscribed: Boolean) {
if (session.isConnected) conn.setChannelsSubscribed(listOf(channelId), subscribed)
}
fun toggleCollapsed(channelId: Int) =
_collapsed.update { if (channelId in it) it - channelId else it + channelId }
fun poke(clientId: Int, message: String) {
if (session.isConnected) conn.poke(clientId, message)
}
/** The client's playback volume change in dB; 0 when unchanged. */
fun clientVolume(clientId: Int): Double =
conn.model.getClient(clientId)?.let { conn.getClientVolumeModifier(it) } ?: 0.0
fun setClientVolume(clientId: Int, decibels: Double) {
conn.model.getClient(clientId)?.let { conn.setClientVolumeModifier(it, decibels) }
}
// ---- chat ----
/** Opens the private chat with a client, loading what was said before. */
fun openPrivateChat(clientId: Int): ChatKey.Private {
val key = ChatKey.Private(clientId)
val client = conn.model.getClient(clientId)
if (client != null) {
_chats.update { chats ->
chats.update(key, conn.displayName(client)) { it.copy(uniqueId = client.uniqueId) }
}
conn.loadPrivateChatHistory(clientId, client.uniqueId)
}
return key
}
fun closePrivateChat(key: ChatKey.Private) = _chats.update { it.close(key) }
fun send(key: ChatKey, text: String) {
when (key) {
ChatKey.Server -> session.sendChat(ChatScope.SERVER, 0, text)
ChatKey.Channel -> session.sendChat(ChatScope.CHANNEL, 0, text)
is ChatKey.Private -> session.sendChat(ChatScope.PRIVATE, key.clientId, text)
}
}
fun markRead(key: ChatKey) {
if (_chats.value[key]?.unread == true) _chats.update { chats -> chats.update(key) { it.copy(unread = false) } }
}
fun requestChat(clientId: Int) {
_chatRequest.value = ChatKey.Private(clientId)
}
fun chatRequestHandled() {
_chatRequest.value = null
/** Shows a private chat a notification was tapped for. */
fun requestChat(serverId: Int, clientId: Int) {
val server = server(serverId) ?: return
_current.value = server
server.requestChat(clientId)
}
/** Answers a private message from its notification. */
fun reply(clientId: Int, text: String) {
if (session.isConnected) send(ChatKey.Private(clientId), text) else notifier.clear(clientId)
}
private fun append(key: ChatKey, title: String, html: String) {
if (html.isEmpty()) return
val line = ChatLine(lineIds.incrementAndGet(), html)
_chats.update { chats -> chats.update(key, title) { it.append(line, read = viewing == key) } }
}
private fun setHistory(key: ChatKey, title: String, entries: List<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)
fun reply(serverId: Int, clientId: Int, text: String) {
server(serverId)?.reply(clientId, text) ?: notifier.clear(serverId, clientId)
}
private companion object {
const val TREE_FRAME_MS = 30L
const val DEFAULT_PORT = 9987
const val OVERLAY_ENABLED = "enabled"

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,9 @@
package com.ts3client.android.ui
import android.widget.Toast
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -8,29 +12,38 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconToggleButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.PrimaryTabRow
import androidx.compose.material3.MenuDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.minimumInteractiveComponentSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.Saver
@@ -39,14 +52,18 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.ts3client.android.ChatKey
import com.ts3client.android.ServerController
import com.ts3client.android.SessionController
import com.ts3client.android.SessionState
import com.ts3client.config.Settings
@@ -75,35 +92,37 @@ private val ChatKeySaver = Saver<ChatKey, String>(
@Composable
fun ServerScreen(
vm: SessionController,
server: ServerController,
snackbar: SnackbarHostState,
onSettings: () -> Unit,
/** Opens the contacts, on the given client's page when one is named. */
onContacts: (String?) -> Unit,
onEnableMicrophone: () -> Unit,
onConnectOther: () -> Unit,
) {
val state by vm.state.collectAsStateWithLifecycle()
val tree by vm.tree.collectAsStateWithLifecycle()
val chats by vm.chats.collectAsStateWithLifecycle()
val collapsed by vm.collapsed.collectAsStateWithLifecycle()
val state by server.state.collectAsStateWithLifecycle()
val tree by server.tree.collectAsStateWithLifecycle()
val chats by server.chats.collectAsStateWithLifecycle()
val collapsed by server.collapsed.collectAsStateWithLifecycle()
var tab by rememberSaveable { mutableIntStateOf(TAB_CHANNELS) }
var showChat by rememberSaveable { mutableStateOf(false) }
var chatKey by rememberSaveable(stateSaver = ChatKeySaver) { mutableStateOf<ChatKey>(ChatKey.Channel) }
val conversation = chats[chatKey] ?: chats.channel
var clientSheet 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) {
val key = chatRequest ?: return@LaunchedEffect
chatKey = if (chats[key] != null) key else vm.openPrivateChat(key.clientId)
tab = TAB_CHAT
vm.chatRequestHandled()
chatKey = if (chats[key] != null) key else server.openPrivateChat(key.clientId)
showChat = true
server.chatRequestHandled()
}
val viewing = if (tab == TAB_CHAT) conversation.key else null
val viewing = if (showChat) conversation.key else null
LifecycleResumeEffect(viewing) {
vm.viewing = viewing
onPauseOrDispose { vm.viewing = null }
server.viewing = viewing
onPauseOrDispose { server.viewing = null }
}
val uriHandler = LocalUriHandler.current
@@ -116,46 +135,34 @@ fun ServerScreen(
}
}
BackHandler(showChat) { showChat = false }
Scaffold(
topBar = {
TopAppBar(
title = {
Column {
Text(state.title, maxLines = 1, overflow = TextOverflow.Ellipsis)
if (!state.connected) {
Text(state.status, style = MaterialTheme.typography.bodySmall, maxLines = 1,
overflow = TextOverflow.Ellipsis)
}
}
},
title = { ServerSwitcher(vm, server, state, onConnectOther) },
expandedHeight = 48.dp,
actions = {
IconButton(onClick = { onContacts(null) }) { PackIcon("CONTACT", "Contacts") }
IconButton(onClick = onSettings) { PackIcon("SETTINGS", "Settings") }
IconButton(onClick = vm::disconnect) { PackIcon("DISCONNECT", "Disconnect") }
},
)
},
bottomBar = {
val self = tree.client(state.selfClientId)
val talking = self?.status == ClientStatus.TALKING || self?.status == ClientStatus.COMMANDER_TALKING
VoiceBar(state, talking, vm, onEnableMicrophone)
VoiceBar(state, talking, vm, server, onEnableMicrophone, showChat, chats.anyUnread) { showChat = !showChat }
},
snackbarHost = { SnackbarHost(snackbar) },
) { padding ->
Column(Modifier.padding(padding).consumeWindowInsets(padding).imePadding()) {
PrimaryTabRow(selectedTabIndex = tab) {
Tab(tab == TAB_CHANNELS, onClick = { tab = TAB_CHANNELS }, text = { Text("Channels") })
Tab(tab == TAB_CHAT, onClick = { tab = TAB_CHAT }, text = {
BadgedBox(badge = { if (chats.anyUnread && tab != TAB_CHAT) Badge() }) { Text("Chat") }
})
}
if (tab == TAB_CHANNELS) {
if (!showChat) {
ChannelTree(
serverName = state.title,
tree = tree,
collapsed = collapsed,
currentChannel = state.channelId,
onToggle = { vm.toggleCollapsed(it.id) },
onToggle = { server.toggleCollapsed(it.id) },
onChannel = { if (it.spacer == null) channelSheet = it.id },
onClient = { clientSheet = it.id },
modifier = Modifier.weight(1f),
@@ -168,9 +175,9 @@ fun ServerScreen(
onSelect = { chatKey = it },
onClose = { key ->
if (chatKey == key) chatKey = ChatKey.Channel
vm.closePrivateChat(key)
server.closePrivateChat(key)
},
onSend = { vm.send(conversation.key, it) },
onSend = { server.send(conversation.key, it) },
onLink = onLink,
modifier = Modifier.weight(1f),
)
@@ -184,17 +191,17 @@ fun ServerScreen(
ClientSheet(
client = client,
away = state.away,
initialVolume = remember(id) { vm.clientVolume(id) },
initialVolume = remember(id) { server.clientVolume(id) },
onDismiss = { clientSheet = null },
onMessage = {
clientSheet = null
chatKey = vm.openPrivateChat(id)
tab = TAB_CHAT
chatKey = server.openPrivateChat(id)
showChat = true
},
onPoke = { vm.poke(id, it); clientSheet = null },
onVolume = { vm.setClientVolume(id, it) },
onAway = { vm.setAway(it); clientSheet = null },
onAddContact = { vm.addContact(id, it) },
onPoke = { server.poke(id, it); clientSheet = null },
onVolume = { server.setClientVolume(id, it) },
onAway = { server.setAway(it); clientSheet = null },
onAddContact = { server.addContact(id, it) },
onEditContact = { clientSheet = null; onContacts(client.uniqueId) },
onRemoveContact = { vm.removeContact(client.uniqueId) },
)
@@ -206,57 +213,108 @@ fun ServerScreen(
channel = channel,
current = id == state.channelId,
onDismiss = { channelSheet = null },
onJoin = { vm.joinChannel(id, it); channelSheet = null },
onSubscribe = { vm.setSubscribed(id, it); channelSheet = null },
onJoin = { server.joinChannel(id, it); channelSheet = null },
onSubscribe = { server.setSubscribed(id, it); channelSheet = null },
)
}
}
}
private const val TAB_CHANNELS = 0
private const val TAB_CHAT = 1
/** Mute, deafen and away, and the push-to-talk button when that is how the microphone is used. */
/**
* Mute, deafen, away and the chat, around the push-to-talk button when that is how the
* microphone is used. Holding the mute button switches between push to talk and voice activation.
*/
@Composable
private fun VoiceBar(state: SessionState, talking: Boolean, vm: SessionController, onEnableMicrophone: () -> Unit) {
Surface(tonalElevation = 3.dp) {
Column(Modifier.fillMaxWidth().navigationBarsPadding().padding(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)) {
if (state.connected && state.micActive && state.inputMode == Settings.InputMode.PUSH_TO_TALK) {
PushToTalk(talking, vm::setPushToTalk)
}
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically) {
if (!state.micActive) {
FilledTonalButton(onClick = onEnableMicrophone) {
PackIcon("CAPTURE", null)
Text("Use microphone", Modifier.padding(start = 8.dp))
}
} else {
IconToggleButton(state.micMuted, { vm.setMicMuted(it) }, enabled = state.connected) {
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")
}
}
IconToggleButton(state.deafened, { vm.setDeafened(it) }, enabled = state.connected) {
val deafen = @Composable {
IconToggleButton(state.deafened, { server.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) {
}
// 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) {
// The same places whatever shows: equal sides keep the talk button's space centred.
Row(Modifier.fillMaxWidth().navigationBarsPadding().padding(horizontal = 8.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically) {
Row(Modifier.weight(1f), horizontalArrangement = Arrangement.SpaceEvenly) { mic(); deafen() }
Box(Modifier.weight(1.3f).height(48.dp), contentAlignment = Alignment.CenterStart) { middle() }
Row(Modifier.weight(1f), horizontalArrangement = Arrangement.SpaceEvenly) { away(); chat() }
}
}
}
/** An icon button that also answers a long press. */
@Composable
private fun PushToTalk(talking: Boolean, onPressed: (Boolean) -> Unit) {
private fun BarButton(onClick: () -> Unit, onLongClick: () -> Unit, enabled: Boolean, content: @Composable () -> Unit) {
Box(
Modifier
.minimumInteractiveComponentSize()
.size(40.dp)
.clip(CircleShape)
.combinedClickable(enabled = enabled, role = Role.Button, onLongClick = onLongClick, onClick = onClick)
.alpha(if (enabled) 1f else 0.38f),
contentAlignment = Alignment.Center,
) { content() }
}
@Composable
private fun PushToTalk(talking: Boolean, onPressed: (Boolean) -> Unit, modifier: Modifier) {
val colors = MaterialTheme.colorScheme
Surface(
Modifier
.fillMaxWidth()
.height(64.dp)
modifier
.height(48.dp)
.pointerInput(Unit) {
detectTapGestures(onPress = {
onPressed(true)
@@ -267,7 +325,7 @@ private fun PushToTalk(talking: Boolean, onPressed: (Boolean) -> Unit) {
}
})
},
shape = RoundedCornerShape(32.dp),
shape = RoundedCornerShape(24.dp),
color = if (talking) StatusColors.talking else colors.primaryContainer,
contentColor = if (talking) Color.White else colors.onPrimaryContainer,
) {
@@ -276,3 +334,52 @@ private fun PushToTalk(talking: Boolean, onPressed: (Boolean) -> Unit) {
}
}
}
/** The server on screen, opening onto the others and connecting to another. */
@Composable
private fun ServerSwitcher(vm: SessionController, server: ServerController, state: SessionState, onConnectOther: () -> Unit) {
val servers by vm.servers.collectAsStateWithLifecycle()
var open by remember { mutableStateOf(false) }
Box(Modifier.fillMaxWidth()) {
Row(Modifier.fillMaxWidth().heightIn(min = 48.dp).clickable { open = true },
verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(state.title, maxLines = 1, overflow = TextOverflow.Ellipsis)
if (!state.connected) {
Text(state.status, style = MaterialTheme.typography.bodySmall, maxLines = 1,
overflow = TextOverflow.Ellipsis)
}
}
Icon(Icons.Filled.ArrowDropDown, "Servers")
}
DropdownMenu(open, onDismissRequest = { open = false }) {
for (other in servers) {
key(other.id) {
val otherState by other.state.collectAsStateWithLifecycle()
DropdownMenuItem(
text = { Text(otherState.title, maxLines = 1, overflow = TextOverflow.Ellipsis) },
onClick = { open = false; vm.select(other) },
leadingIcon = {
PackIcon("SERVER_GREEN", null, Modifier.alpha(if (otherState.connected) 1f else 0.5f))
},
trailingIcon = if (otherState.micActive) ({ PackIcon("ACTIVATE_MICROPHONE", "Microphone") }) else null,
colors = if (other == server) MenuDefaults.itemColors(textColor = MaterialTheme.colorScheme.primary)
else MenuDefaults.itemColors(),
)
}
}
HorizontalDivider()
DropdownMenuItem(
text = { Text("Connect…") },
onClick = { open = false; onConnectOther() },
leadingIcon = { PackIcon("CONNECT", null) },
)
DropdownMenuItem(
text = { Text("Disconnect from current") },
onClick = { open = false; server.disconnect() },
leadingIcon = { PackIcon("DISCONNECT", null) },
)
}
}
}

View File

@@ -89,9 +89,15 @@ public final class Sessions {
assignMicrophoneIfFree();
}
/** A disconnected session cannot capture; the microphone moves on if it had it. */
/**
* A disconnected session cannot capture; the microphone moves on if it had it. The session
* lets go of it too, or a reconnect would capture alongside whoever holds it by then.
*/
public void disconnected(ServerSession session) {
if (microphoneOwner == session) microphoneOwner = null;
if (microphoneOwner == session) {
microphoneOwner = null;
handover.execute(() -> session.setMicrophoneActive(false));
}
assignMicrophoneIfFree();
}