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