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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -12,179 +12,201 @@ import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
|
||||
/**
|
||||
* Keeps the connection on the network Android routes over.
|
||||
* Keeps the connections on the network Android routes over.
|
||||
*
|
||||
* A TS3 server knows its clients by address, so once the phone moves from Wi-Fi to mobile
|
||||
* data (or back) the old connection is dead weight: it can only time out. This notices the
|
||||
* data (or back) an old connection is dead weight: it can only time out. This notices the
|
||||
* move, says goodbye over the old network while that still works, and connects again over
|
||||
* the new one, back into the channel we were in. A server that still holds our old
|
||||
* connection may refuse the new one for a while as a clone, so a failed attempt is retried.
|
||||
*
|
||||
* The process's sockets are bound to the network a connection was made over. Otherwise the
|
||||
* old connection would silently follow the new default network, and its goodbye would come
|
||||
* from an address the server does not know.
|
||||
* from an address the server does not know. A socket keeps the network it was made on, so
|
||||
* each connection remembers its own.
|
||||
*/
|
||||
class NetworkFollower(
|
||||
context: Context,
|
||||
private val session: ServerSession,
|
||||
private val onChange: () -> Unit,
|
||||
private val system: (String) -> Unit,
|
||||
) {
|
||||
class NetworkFollower(context: Context) {
|
||||
private val connectivity = context.getSystemService(ConnectivityManager::class.java)
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val follows = CopyOnWriteArrayList<Follow>()
|
||||
|
||||
/** The system's default network; null while there is none. */
|
||||
@Volatile
|
||||
private var network: Network? = null
|
||||
|
||||
/** The network the connection was made over. */
|
||||
@Volatile
|
||||
private var bound: Network? = null
|
||||
|
||||
/** The user wants to be on the server: from their connect until they, or the server, end it. */
|
||||
@Volatile
|
||||
private var wanted = false
|
||||
|
||||
private var settle: Job? = null
|
||||
@Volatile
|
||||
private var recovery: Job? = null
|
||||
@Volatile
|
||||
private var attempt: CompletableDeferred<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 {
|
||||
connectivity.registerDefaultNetworkCallback(object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(available: Network) {
|
||||
network = available
|
||||
reconsider()
|
||||
follows.forEach(Follow::reconsider)
|
||||
}
|
||||
|
||||
override fun onLost(lost: Network) {
|
||||
if (network == lost) network = null
|
||||
reconsider()
|
||||
follows.forEach(Follow::reconsider)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Before a connect the user asked for, which goes over the network there is now. */
|
||||
fun connecting() {
|
||||
recovery?.cancel()
|
||||
wanted = true
|
||||
bind(connectivity.activeNetwork)
|
||||
}
|
||||
|
||||
/** The user leaves the server. */
|
||||
fun leaving() {
|
||||
wanted = false
|
||||
recovery?.cancel()
|
||||
unbind()
|
||||
}
|
||||
|
||||
fun onConnected() {
|
||||
attempt?.complete(true)
|
||||
}
|
||||
|
||||
fun onDisconnected() {
|
||||
val pending = attempt
|
||||
when {
|
||||
pending != null -> pending.complete(false)
|
||||
isRecovering -> {}
|
||||
// A socket on a network that went away fails at once, before we hear of the change.
|
||||
wanted && networkGone() -> synchronized(this) {
|
||||
if (!isRecovering) recovery = recover()
|
||||
}
|
||||
// The server let us go (a kick, a shutdown, a failed first connect): leave it at that.
|
||||
else -> {
|
||||
wanted = false
|
||||
unbind()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Waits for the network to settle: a handover often shows several changes in a row. */
|
||||
private fun reconsider() {
|
||||
synchronized(this) {
|
||||
settle?.cancel()
|
||||
settle = scope.launch {
|
||||
delay(SETTLE_MS)
|
||||
synchronized(this@NetworkFollower) {
|
||||
if (wanted && bound != network && !isRecovering) recovery = recover()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun recover() = scope.launch {
|
||||
try {
|
||||
val back = withTimeoutOrNull(GIVE_UP_MS) {
|
||||
system(if (connectivity.activeNetwork == null) "Network lost." else "Network changed, reconnecting…")
|
||||
if (session.isBusy) session.connection().disconnectBlocking("Network changed")
|
||||
var retry = RETRY_MS
|
||||
while (true) {
|
||||
val current = awaitNetwork()
|
||||
bind(current)
|
||||
show("Reconnecting…")
|
||||
val done = CompletableDeferred<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()
|
||||
}
|
||||
/** Follows [session] until [Follow.forget]. */
|
||||
fun follow(session: ServerSession, onChange: () -> Unit, system: (String) -> Unit): Follow =
|
||||
Follow(session, onChange, system).also(follows::add)
|
||||
|
||||
/** Binds new sockets to [to], or lets them go by the default network once no connection wants one. */
|
||||
private fun bind(to: Network?) {
|
||||
connectivity.bindProcessToNetwork(to)
|
||||
bound = to
|
||||
connectivity.bindProcessToNetwork(to ?: follows.firstNotNullOfOrNull { f -> f.bound.takeIf { f.wanted } })
|
||||
}
|
||||
|
||||
private fun unbind() = bind(null)
|
||||
inner class Follow internal constructor(
|
||||
private val session: ServerSession,
|
||||
private val onChange: () -> Unit,
|
||||
private val system: (String) -> Unit,
|
||||
) {
|
||||
/** The network the connection was made over. */
|
||||
@Volatile
|
||||
internal var bound: Network? = null
|
||||
|
||||
/** The user wants to be on the server: from their connect until they, or the server, end it. */
|
||||
@Volatile
|
||||
internal var wanted = false
|
||||
|
||||
private var settle: Job? = null
|
||||
@Volatile
|
||||
private var recovery: Job? = null
|
||||
@Volatile
|
||||
private var attempt: CompletableDeferred<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 {
|
||||
const val SETTLE_MS = 1_500L
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
_contacts.value = contactCopies()
|
||||
_servers.value.forEach(ServerController::contactsChanged)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,7 +157,7 @@ class SessionController(private val context: Context) {
|
||||
|
||||
// ---- connection ----
|
||||
|
||||
/** Connects to `host[:port]`; an empty [identityId] means the default identity. */
|
||||
/** Connects to `host[:port]`, as another server; an empty [identityId] means the default identity. */
|
||||
fun connect(address: String, nickname: String, password: String = "", identityId: String = "") {
|
||||
val (host, port) = parseAddress(address)
|
||||
settings.lastAddress = address
|
||||
@@ -196,24 +173,69 @@ class SessionController(private val context: Context) {
|
||||
|
||||
private fun start(host: String, port: Int, nickname: String, password: String, identityId: String,
|
||||
channel: String, channelPassword: String) {
|
||||
// Capture starts once connected; without the permission yet, the UI hands it over later.
|
||||
if (hasMicPermission()) session.setMicrophoneActive(true)
|
||||
val server = ServerController(nextId.incrementAndGet(), this)
|
||||
sessions.add(server.session)
|
||||
_servers.update { it + server }
|
||||
_current.value = server
|
||||
_lastEnd.value = null
|
||||
context.startForegroundService(Intent(context, ConnectionService::class.java))
|
||||
network.connecting()
|
||||
session.connect(host, port, nickname, password, identityId, channel, channelPassword)
|
||||
server.start(host, port, nickname, password, identityId, channel, channelPassword)
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
network.leaving()
|
||||
session.disconnect()
|
||||
fun select(server: ServerController) {
|
||||
if (server in _servers.value) _current.value = server
|
||||
}
|
||||
|
||||
/** Leaves the server before returning, for when the app goes away with the connection. */
|
||||
/** Leaves every server before returning, for when the app goes away with the connections. */
|
||||
fun leave() {
|
||||
network.leaving()
|
||||
session.shutdown()
|
||||
_servers.value.forEach(ServerController::leave)
|
||||
contactStore.flush()
|
||||
}
|
||||
|
||||
fun server(id: Int): ServerController? = _servers.value.firstOrNull { it.id == id }
|
||||
|
||||
/** A server's state changed; one that is done with goes from the list. */
|
||||
internal fun serverChanged(server: ServerController) {
|
||||
if (server.micOwner) updateLevels()
|
||||
if (server.ended) main.post { remove(server) }
|
||||
}
|
||||
|
||||
private fun remove(server: ServerController) {
|
||||
if (server !in _servers.value || !server.ended) return
|
||||
sessions.remove(server.session)
|
||||
val rest = _servers.value - server
|
||||
_servers.value = rest
|
||||
if (_current.value == server) _current.value = rest.lastOrNull()
|
||||
server.dispose()
|
||||
val reason = server.state.value.status
|
||||
_lastEnd.value = if (server.left) null else reason
|
||||
if (!server.left && rest.isNotEmpty()) error("${server.state.value.title}: $reason")
|
||||
}
|
||||
|
||||
internal fun connected(server: ServerController) {
|
||||
// The first connect generates an identity when there was none.
|
||||
identities.refresh()
|
||||
main.post {
|
||||
// Without the permission yet, the UI hands the microphone over once it is granted.
|
||||
if (hasMicPermission()) sessions.connected(server.session)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun disconnected(server: ServerController) {
|
||||
main.post { sessions.disconnected(server.session) }
|
||||
}
|
||||
|
||||
internal fun error(message: String) {
|
||||
_events.tryEmit(SessionEvent.Error(message))
|
||||
}
|
||||
|
||||
internal fun poked(from: String, message: String) {
|
||||
_events.tryEmit(SessionEvent.Poke(from, message))
|
||||
if (!foreground) notifier.poke(from, BBCode.toPlain(message))
|
||||
}
|
||||
|
||||
internal fun talking(transmitting: Boolean) = levels.talking(transmitting)
|
||||
|
||||
// ---- bookmarks ----
|
||||
|
||||
fun addBookmark(label: String, address: String, nickname: String, password: String, identityId: String) {
|
||||
@@ -254,12 +276,6 @@ class SessionController(private val context: Context) {
|
||||
/** A copy of the contact to edit and hand back to [saveContact]. */
|
||||
fun contact(uniqueId: String): Contact? = contactStore.find(uniqueId)?.copy()
|
||||
|
||||
/** Files a client on the server as a contact; a new entry takes the type's defaults. */
|
||||
fun addContact(clientId: Int, type: ContactType) {
|
||||
val client = conn.model.getClient(clientId) ?: return
|
||||
contactStore.add(client.uniqueId, client.nickname, type, settings.contactDefaults)
|
||||
}
|
||||
|
||||
fun saveContact(contact: Contact) = contactStore.put(contact)
|
||||
|
||||
fun removeContact(uniqueId: String) = contactStore.remove(uniqueId)
|
||||
@@ -319,20 +335,22 @@ class SessionController(private val context: Context) {
|
||||
fun hasMicPermission() =
|
||||
context.checkSelfPermission(Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED
|
||||
|
||||
/** Hands the microphone to the connection; needs RECORD_AUDIO granted. */
|
||||
fun setMicrophoneActive(active: Boolean) {
|
||||
session.setMicrophoneActive(active)
|
||||
refresh()
|
||||
/** Moves the microphone to [server]; needs RECORD_AUDIO granted. */
|
||||
fun giveMicrophoneTo(server: ServerController) {
|
||||
if (server.isConnected) sessions.giveMicrophoneTo(server.session)
|
||||
}
|
||||
|
||||
fun setMicMuted(muted: Boolean) = session.setMicMuted(muted)
|
||||
/** Opens the microphone of the server holding it while push-to-talk is held. */
|
||||
fun setPushToTalk(pressed: Boolean) = sessions.setPushToTalk(pressed)
|
||||
|
||||
fun setDeafened(deafened: Boolean) = session.setDeafened(deafened)
|
||||
|
||||
fun setAway(away: Boolean) = session.setAway(away, null)
|
||||
|
||||
fun setPushToTalk(pressed: Boolean) {
|
||||
if (session.isConnected) conn.microphone?.setPushToTalk(pressed)
|
||||
private fun microphoneMoved() {
|
||||
val owner = sessions.microphoneOwner()
|
||||
for (server in _servers.value) {
|
||||
server.micOwner = server.session == owner
|
||||
server.refresh()
|
||||
}
|
||||
_micOwner.value = _servers.value.firstOrNull { it.micOwner }
|
||||
updateLevels()
|
||||
}
|
||||
|
||||
private val overlayPrefs = context.getSharedPreferences("overlay", Context.MODE_PRIVATE)
|
||||
@@ -345,22 +363,30 @@ class SessionController(private val context: Context) {
|
||||
_pttOverlay.value = enabled
|
||||
}
|
||||
|
||||
/** Switches between push to talk and voice activation; returns the new mode. */
|
||||
fun toggleInputMode(): Settings.InputMode {
|
||||
settings.inputMode = if (settings.inputMode == Settings.InputMode.PUSH_TO_TALK)
|
||||
Settings.InputMode.VOICE_ACTIVATION else Settings.InputMode.PUSH_TO_TALK
|
||||
applySettings()
|
||||
return settings.inputMode
|
||||
}
|
||||
|
||||
/** Saves the settings and puts the voice and device ones into effect at once. */
|
||||
fun applySettings() {
|
||||
settings.save()
|
||||
soundPlayer.setOutputDevice(settings.outputDevice)
|
||||
if (session.isConnected) {
|
||||
conn.playback?.setMasterVolume(settings.outputVolume)
|
||||
conn.playback?.setOutputDevice(settings.outputDevice)
|
||||
}
|
||||
try {
|
||||
if (session.isConnected) conn.microphone?.apply(settings)
|
||||
for (server in _servers.value) {
|
||||
server.playback?.setMasterVolume(settings.outputVolume)
|
||||
server.playback?.setOutputDevice(settings.outputDevice)
|
||||
server.microphone?.apply(settings)
|
||||
server.refresh()
|
||||
server.scheduleTree(structure = false)
|
||||
}
|
||||
levels.apply()
|
||||
} catch (e: RuntimeException) {
|
||||
_events.tryEmit(SessionEvent.Error(e.message ?: "Microphone unavailable"))
|
||||
error(e.message ?: "Microphone unavailable")
|
||||
}
|
||||
refresh()
|
||||
scheduleTree(structure = false)
|
||||
}
|
||||
|
||||
/** Microphone preset and playback usage; the open lines move over at once. */
|
||||
@@ -377,206 +403,25 @@ class SessionController(private val context: Context) {
|
||||
}
|
||||
|
||||
private fun updateLevels() {
|
||||
val source = if (session.isConnected && conn.isMicrophoneActive) conn.microphone else null
|
||||
levels.update(watchingLevel, source, session.isBusy || network.isRecovering, hasMicPermission())
|
||||
val owner = _micOwner.value
|
||||
levels.update(watchingLevel, owner?.microphone, owner?.isBusy == true, hasMicPermission())
|
||||
}
|
||||
|
||||
// ---- channels and clients ----
|
||||
// ---- notifications ----
|
||||
|
||||
fun joinChannel(channelId: Int, password: String? = null) {
|
||||
if (session.isConnected) conn.joinChannel(channelId, password)
|
||||
}
|
||||
|
||||
fun setSubscribed(channelId: Int, subscribed: Boolean) {
|
||||
if (session.isConnected) conn.setChannelsSubscribed(listOf(channelId), subscribed)
|
||||
}
|
||||
|
||||
fun toggleCollapsed(channelId: Int) =
|
||||
_collapsed.update { if (channelId in it) it - channelId else it + channelId }
|
||||
|
||||
fun poke(clientId: Int, message: String) {
|
||||
if (session.isConnected) conn.poke(clientId, message)
|
||||
}
|
||||
|
||||
/** The client's playback volume change in dB; 0 when unchanged. */
|
||||
fun clientVolume(clientId: Int): Double =
|
||||
conn.model.getClient(clientId)?.let { conn.getClientVolumeModifier(it) } ?: 0.0
|
||||
|
||||
fun setClientVolume(clientId: Int, decibels: Double) {
|
||||
conn.model.getClient(clientId)?.let { conn.setClientVolumeModifier(it, decibels) }
|
||||
}
|
||||
|
||||
// ---- chat ----
|
||||
|
||||
/** Opens the private chat with a client, loading what was said before. */
|
||||
fun openPrivateChat(clientId: Int): ChatKey.Private {
|
||||
val key = ChatKey.Private(clientId)
|
||||
val client = conn.model.getClient(clientId)
|
||||
if (client != null) {
|
||||
_chats.update { chats ->
|
||||
chats.update(key, conn.displayName(client)) { it.copy(uniqueId = client.uniqueId) }
|
||||
}
|
||||
conn.loadPrivateChatHistory(clientId, client.uniqueId)
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
fun closePrivateChat(key: ChatKey.Private) = _chats.update { it.close(key) }
|
||||
|
||||
fun send(key: ChatKey, text: String) {
|
||||
when (key) {
|
||||
ChatKey.Server -> session.sendChat(ChatScope.SERVER, 0, text)
|
||||
ChatKey.Channel -> session.sendChat(ChatScope.CHANNEL, 0, text)
|
||||
is ChatKey.Private -> session.sendChat(ChatScope.PRIVATE, key.clientId, text)
|
||||
}
|
||||
}
|
||||
|
||||
fun markRead(key: ChatKey) {
|
||||
if (_chats.value[key]?.unread == true) _chats.update { chats -> chats.update(key) { it.copy(unread = false) } }
|
||||
}
|
||||
|
||||
fun requestChat(clientId: Int) {
|
||||
_chatRequest.value = ChatKey.Private(clientId)
|
||||
}
|
||||
|
||||
fun chatRequestHandled() {
|
||||
_chatRequest.value = null
|
||||
/** Shows a private chat a notification was tapped for. */
|
||||
fun requestChat(serverId: Int, clientId: Int) {
|
||||
val server = server(serverId) ?: return
|
||||
_current.value = server
|
||||
server.requestChat(clientId)
|
||||
}
|
||||
|
||||
/** Answers a private message from its notification. */
|
||||
fun reply(clientId: Int, text: String) {
|
||||
if (session.isConnected) send(ChatKey.Private(clientId), text) else notifier.clear(clientId)
|
||||
}
|
||||
|
||||
private fun append(key: ChatKey, title: String, html: String) {
|
||||
if (html.isEmpty()) return
|
||||
val line = ChatLine(lineIds.incrementAndGet(), html)
|
||||
_chats.update { chats -> chats.update(key, title) { it.append(line, read = viewing == key) } }
|
||||
}
|
||||
|
||||
private fun setHistory(key: ChatKey, title: String, entries: List<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"
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
private fun VoiceBar(
|
||||
state: SessionState,
|
||||
talking: Boolean,
|
||||
vm: SessionController,
|
||||
server: ServerController,
|
||||
onEnableMicrophone: () -> Unit,
|
||||
showChat: Boolean,
|
||||
unread: Boolean,
|
||||
onChat: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val ptt = state.connected && state.micActive && state.inputMode == Settings.InputMode.PUSH_TO_TALK
|
||||
val mic = @Composable {
|
||||
BarButton(
|
||||
onClick = { server.setMicMuted(!state.micMuted) },
|
||||
onLongClick = {
|
||||
val mode = vm.toggleInputMode()
|
||||
val text = if (mode == Settings.InputMode.PUSH_TO_TALK) "Switched to push-to-talk mode."
|
||||
else "Switched to voice activation mode."
|
||||
Toast.makeText(context, text, Toast.LENGTH_SHORT).show()
|
||||
},
|
||||
enabled = state.connected,
|
||||
) {
|
||||
if (state.micMuted) PackIcon("INPUT_MUTED", "Unmute microphone")
|
||||
else PackIcon("CAPTURE", "Mute microphone")
|
||||
}
|
||||
}
|
||||
val deafen = @Composable {
|
||||
IconToggleButton(state.deafened, { server.setDeafened(it) }, enabled = state.connected) {
|
||||
if (state.deafened) PackIcon("OUTPUT_MUTED", "Undeafen")
|
||||
else PackIcon("PLAYBACK", "Deafen")
|
||||
}
|
||||
}
|
||||
// Without the talk button, what the microphone needs next: the permission, or to come to this server.
|
||||
val middle = @Composable {
|
||||
when {
|
||||
ptt -> PushToTalk(talking, vm::setPushToTalk, Modifier.fillMaxWidth())
|
||||
!state.micPermission -> FilledTonalButton(onClick = onEnableMicrophone) {
|
||||
PackIcon("CAPTURE", null)
|
||||
Text("Use microphone", Modifier.padding(start = 8.dp), maxLines = 1)
|
||||
}
|
||||
state.connected && !state.micActive ->
|
||||
IconButton(onClick = onEnableMicrophone) { PackIcon("ACTIVATE_MICROPHONE", "Speak on this server") }
|
||||
}
|
||||
}
|
||||
val away = @Composable {
|
||||
IconToggleButton(state.away, { server.setAway(it) }, enabled = state.connected) {
|
||||
PackIcon("AWAY", if (state.away) "Back" else "Away", Modifier.alpha(if (state.away) 1f else 0.5f))
|
||||
}
|
||||
}
|
||||
val chat = @Composable {
|
||||
IconToggleButton(showChat, { onChat() }) {
|
||||
BadgedBox(badge = { if (unread && !showChat) Badge() }) {
|
||||
PackIcon(listOf("MESSAGES", "CHANNEL_CHAT"), if (showChat) "Channels" else "Chat",
|
||||
Modifier.alpha(if (showChat) 1f else 0.7f))
|
||||
}
|
||||
}
|
||||
}
|
||||
Surface(tonalElevation = 3.dp) {
|
||||
Column(Modifier.fillMaxWidth().navigationBarsPadding().padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
if (state.connected && state.micActive && state.inputMode == Settings.InputMode.PUSH_TO_TALK) {
|
||||
PushToTalk(talking, vm::setPushToTalk)
|
||||
}
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
verticalAlignment = Alignment.CenterVertically) {
|
||||
if (!state.micActive) {
|
||||
FilledTonalButton(onClick = onEnableMicrophone) {
|
||||
PackIcon("CAPTURE", null)
|
||||
Text("Use microphone", Modifier.padding(start = 8.dp))
|
||||
}
|
||||
} else {
|
||||
IconToggleButton(state.micMuted, { vm.setMicMuted(it) }, enabled = state.connected) {
|
||||
if (state.micMuted) PackIcon("INPUT_MUTED", "Unmute microphone")
|
||||
else PackIcon("CAPTURE", "Mute microphone")
|
||||
}
|
||||
}
|
||||
IconToggleButton(state.deafened, { vm.setDeafened(it) }, enabled = state.connected) {
|
||||
if (state.deafened) PackIcon("OUTPUT_MUTED", "Undeafen")
|
||||
else PackIcon("PLAYBACK", "Deafen")
|
||||
}
|
||||
IconToggleButton(state.away, { vm.setAway(it) }, enabled = state.connected) {
|
||||
PackIcon("AWAY", if (state.away) "Back" else "Away", Modifier.alpha(if (state.away) 1f else 0.5f))
|
||||
}
|
||||
}
|
||||
// The same places whatever shows: equal sides keep the talk button's space centred.
|
||||
Row(Modifier.fillMaxWidth().navigationBarsPadding().padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically) {
|
||||
Row(Modifier.weight(1f), horizontalArrangement = Arrangement.SpaceEvenly) { mic(); deafen() }
|
||||
Box(Modifier.weight(1.3f).height(48.dp), contentAlignment = Alignment.CenterStart) { middle() }
|
||||
Row(Modifier.weight(1f), horizontalArrangement = Arrangement.SpaceEvenly) { away(); chat() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** An icon button that also answers a long press. */
|
||||
@Composable
|
||||
private fun PushToTalk(talking: Boolean, onPressed: (Boolean) -> Unit) {
|
||||
private fun BarButton(onClick: () -> Unit, onLongClick: () -> Unit, enabled: Boolean, content: @Composable () -> Unit) {
|
||||
Box(
|
||||
Modifier
|
||||
.minimumInteractiveComponentSize()
|
||||
.size(40.dp)
|
||||
.clip(CircleShape)
|
||||
.combinedClickable(enabled = enabled, role = Role.Button, onLongClick = onLongClick, onClick = onClick)
|
||||
.alpha(if (enabled) 1f else 0.38f),
|
||||
contentAlignment = Alignment.Center,
|
||||
) { content() }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PushToTalk(talking: Boolean, onPressed: (Boolean) -> Unit, modifier: Modifier) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
Surface(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(64.dp)
|
||||
modifier
|
||||
.height(48.dp)
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(onPress = {
|
||||
onPressed(true)
|
||||
@@ -267,7 +325,7 @@ private fun PushToTalk(talking: Boolean, onPressed: (Boolean) -> Unit) {
|
||||
}
|
||||
})
|
||||
},
|
||||
shape = RoundedCornerShape(32.dp),
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
color = if (talking) StatusColors.talking else colors.primaryContainer,
|
||||
contentColor = if (talking) Color.White else colors.onPrimaryContainer,
|
||||
) {
|
||||
@@ -276,3 +334,52 @@ private fun PushToTalk(talking: Boolean, onPressed: (Boolean) -> Unit) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The server on screen, opening onto the others and connecting to another. */
|
||||
@Composable
|
||||
private fun ServerSwitcher(vm: SessionController, server: ServerController, state: SessionState, onConnectOther: () -> Unit) {
|
||||
val servers by vm.servers.collectAsStateWithLifecycle()
|
||||
var open by remember { mutableStateOf(false) }
|
||||
Box(Modifier.fillMaxWidth()) {
|
||||
Row(Modifier.fillMaxWidth().heightIn(min = 48.dp).clickable { open = true },
|
||||
verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(state.title, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
if (!state.connected) {
|
||||
Text(state.status, style = MaterialTheme.typography.bodySmall, maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
Icon(Icons.Filled.ArrowDropDown, "Servers")
|
||||
}
|
||||
DropdownMenu(open, onDismissRequest = { open = false }) {
|
||||
for (other in servers) {
|
||||
key(other.id) {
|
||||
val otherState by other.state.collectAsStateWithLifecycle()
|
||||
DropdownMenuItem(
|
||||
text = { Text(otherState.title, maxLines = 1, overflow = TextOverflow.Ellipsis) },
|
||||
onClick = { open = false; vm.select(other) },
|
||||
leadingIcon = {
|
||||
PackIcon("SERVER_GREEN", null, Modifier.alpha(if (otherState.connected) 1f else 0.5f))
|
||||
},
|
||||
trailingIcon = if (otherState.micActive) ({ PackIcon("ACTIVATE_MICROPHONE", "Microphone") }) else null,
|
||||
colors = if (other == server) MenuDefaults.itemColors(textColor = MaterialTheme.colorScheme.primary)
|
||||
else MenuDefaults.itemColors(),
|
||||
)
|
||||
}
|
||||
}
|
||||
HorizontalDivider()
|
||||
DropdownMenuItem(
|
||||
text = { Text("Connect…") },
|
||||
onClick = { open = false; onConnectOther() },
|
||||
leadingIcon = { PackIcon("CONNECT", null) },
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Disconnect from current") },
|
||||
onClick = { open = false; server.disconnect() },
|
||||
leadingIcon = { PackIcon("DISCONNECT", null) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user