Give the Android app its real UI
- Connect screen with an address or a bookmark, which can be added and removed there. - Server screen: the channel tree with each client's status, folding channels, joining (asking for a password where needed) and subscribing from a channel's sheet; private message, poke and volume from a client's sheet. - Chat: the server log, the channel and private conversations with their history, unread marks, BBCode and clickable client, channel and web links. Chat lines are the HTML the desktop shows, turned into styled text. - Voice bar: mute, deafen, away, and a hold-to-talk button in push-to-talk mode. The microphone permission is asked for on connect. - Settings: nickname, transmission mode, voice activation, noise suppression, gain control and playback volume, in effect at once. The session's state reaches Compose as immutable snapshots: the tree at most every 30 ms, rebuilt only when channels or clients changed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -50,6 +50,7 @@ dependencies {
|
|||||||
implementation(platform(libs.androidx.compose.bom))
|
implementation(platform(libs.androidx.compose.bom))
|
||||||
implementation(libs.androidx.compose.ui)
|
implementation(libs.androidx.compose.ui)
|
||||||
implementation(libs.androidx.compose.material3)
|
implementation(libs.androidx.compose.material3)
|
||||||
|
implementation(libs.androidx.compose.material.icons)
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||||
implementation(libs.androidx.lifecycle.service)
|
implementation(libs.androidx.lifecycle.service)
|
||||||
|
|||||||
66
android/app/src/main/java/com/ts3client/android/Chats.kt
Normal file
66
android/app/src/main/java/com/ts3client/android/Chats.kt
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
package com.ts3client.android
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
|
||||||
|
/** Which conversation a chat line belongs to. */
|
||||||
|
sealed interface ChatKey {
|
||||||
|
data object Server : ChatKey
|
||||||
|
data object Channel : ChatKey
|
||||||
|
data class Private(val clientId: Int) : ChatKey
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One entry of a chat, as HTML in the form [com.ts3client.text.ChatHtml] writes. */
|
||||||
|
@Immutable
|
||||||
|
data class ChatLine(val id: Long, val html: String)
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class Conversation(
|
||||||
|
val key: ChatKey,
|
||||||
|
val title: String,
|
||||||
|
/** The peer's unique id in a private chat, once known. */
|
||||||
|
val uniqueId: String = "",
|
||||||
|
val lines: List<ChatLine> = emptyList(),
|
||||||
|
val unread: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Every conversation of the session: the server's, the channel's, then the private ones. */
|
||||||
|
@Immutable
|
||||||
|
data class Chats(
|
||||||
|
val server: Conversation = Conversation(ChatKey.Server, "Server"),
|
||||||
|
val channel: Conversation = Conversation(ChatKey.Channel, "Channel"),
|
||||||
|
val private: List<Conversation> = emptyList(),
|
||||||
|
) {
|
||||||
|
val all: List<Conversation> get() = listOf(server, channel) + private
|
||||||
|
|
||||||
|
val anyUnread: Boolean get() = all.any { it.unread }
|
||||||
|
|
||||||
|
operator fun get(key: ChatKey): Conversation? = when (key) {
|
||||||
|
ChatKey.Server -> server
|
||||||
|
ChatKey.Channel -> channel
|
||||||
|
is ChatKey.Private -> private.firstOrNull { it.key == key }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Applies [change] to a conversation, opening a private one titled [title] if needed. */
|
||||||
|
fun update(key: ChatKey, title: String = "", change: (Conversation) -> Conversation): Chats = when (key) {
|
||||||
|
ChatKey.Server -> copy(server = change(server))
|
||||||
|
ChatKey.Channel -> copy(channel = change(channel))
|
||||||
|
is ChatKey.Private -> {
|
||||||
|
val existing = private.indexOfFirst { it.key == key }
|
||||||
|
if (existing < 0) {
|
||||||
|
copy(private = private + change(Conversation(key, title)))
|
||||||
|
} else {
|
||||||
|
copy(private = private.toMutableList().also { it[existing] = change(it[existing]) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun close(key: ChatKey.Private): Chats = copy(private = private.filter { it.key != key })
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** As many lines as a log history loads; older ones leave the conversation. */
|
||||||
|
const val MAX_LINES = 1000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Conversation.append(line: ChatLine, read: Boolean): Conversation =
|
||||||
|
copy(lines = (lines + line).takeLast(Chats.MAX_LINES), unread = unread || !read)
|
||||||
@@ -1,126 +1,19 @@
|
|||||||
package com.ts3client.android
|
package com.ts3client.android
|
||||||
|
|
||||||
import android.Manifest
|
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import com.ts3client.android.ui.App
|
||||||
import androidx.compose.foundation.layout.Column
|
import com.ts3client.android.ui.Ts3Theme
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.safeDrawingPadding
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.items
|
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.FilterChip
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.OutlinedTextField
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.darkColorScheme
|
|
||||||
import androidx.compose.material3.lightColorScheme
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
enableEdgeToEdge()
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
val sessions = (application as Ts3Application).sessions
|
||||||
setContent {
|
setContent {
|
||||||
MaterialTheme(colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme()) {
|
Ts3Theme { App(sessions) }
|
||||||
Surface(Modifier.fillMaxSize()) {
|
|
||||||
ConnectionScreen((application as Ts3Application).sessions)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A bare connection screen for trying the port out; the real UI replaces it. */
|
|
||||||
@Composable
|
|
||||||
private fun ConnectionScreen(vm: SessionController) {
|
|
||||||
val state by vm.state.collectAsStateWithLifecycle()
|
|
||||||
var address by remember { mutableStateOf(vm.lastAddress) }
|
|
||||||
var nickname by remember { mutableStateOf(vm.nickname) }
|
|
||||||
var message by remember { mutableStateOf("") }
|
|
||||||
|
|
||||||
val context = LocalContext.current
|
|
||||||
val micPermission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
|
||||||
if (granted) vm.setMicrophoneActive(true)
|
|
||||||
}
|
|
||||||
// Without it the connection still runs, just with no notification to control it from.
|
|
||||||
val notificationPermission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {}
|
|
||||||
|
|
||||||
Column(
|
|
||||||
Modifier.safeDrawingPadding().padding(12.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
) {
|
|
||||||
Text("${state.title} — ${state.status}", style = MaterialTheme.typography.titleMedium)
|
|
||||||
if (state.connected) Text("${state.channels} channels, ${state.clients} clients")
|
|
||||||
|
|
||||||
OutlinedTextField(address, { address = it }, Modifier.fillMaxWidth(), label = { Text("Server") },
|
|
||||||
singleLine = true, enabled = !state.busy)
|
|
||||||
OutlinedTextField(nickname, { nickname = it }, Modifier.fillMaxWidth(), label = { Text("Nickname") },
|
|
||||||
singleLine = true, enabled = !state.busy)
|
|
||||||
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
if (state.busy) {
|
|
||||||
Button(onClick = vm::disconnect) { Text("Disconnect") }
|
|
||||||
} else {
|
|
||||||
Button(onClick = {
|
|
||||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS)
|
|
||||||
!= PackageManager.PERMISSION_GRANTED) {
|
|
||||||
notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
|
|
||||||
}
|
|
||||||
vm.connect(address, nickname)
|
|
||||||
}, enabled = address.isNotBlank()) { Text("Connect") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
FilterChip(state.micActive, onClick = {
|
|
||||||
if (state.micActive) {
|
|
||||||
vm.setMicrophoneActive(false)
|
|
||||||
} else if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO)
|
|
||||||
== PackageManager.PERMISSION_GRANTED) {
|
|
||||||
vm.setMicrophoneActive(true)
|
|
||||||
} else {
|
|
||||||
micPermission.launch(Manifest.permission.RECORD_AUDIO)
|
|
||||||
}
|
|
||||||
}, label = { Text("Microphone") })
|
|
||||||
FilterChip(state.micMuted, onClick = { vm.setMicMuted(!state.micMuted) }, label = { Text("Mute") })
|
|
||||||
FilterChip(state.deafened, onClick = { vm.setDeafened(!state.deafened) }, label = { Text("Deafen") })
|
|
||||||
}
|
|
||||||
|
|
||||||
val listState = rememberLazyListState()
|
|
||||||
LaunchedEffect(state.log.size) {
|
|
||||||
if (state.log.isNotEmpty()) listState.animateScrollToItem(state.log.size - 1)
|
|
||||||
}
|
|
||||||
LazyColumn(Modifier.weight(1f).fillMaxWidth(), state = listState) {
|
|
||||||
items(state.log) { Text(it, style = MaterialTheme.typography.bodySmall) }
|
|
||||||
}
|
|
||||||
|
|
||||||
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
|
|
||||||
OutlinedTextField(message, { message = it }, Modifier.weight(1f), label = { Text("Channel chat") },
|
|
||||||
singleLine = true, enabled = state.connected)
|
|
||||||
Button(onClick = { vm.sendChannelMessage(message); message = "" },
|
|
||||||
enabled = state.connected && message.isNotBlank(), modifier = Modifier.padding(start = 8.dp)) {
|
|
||||||
Text("Send")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package com.ts3client.android
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import com.ts3client.contacts.ContactStore
|
||||||
|
import com.ts3client.contacts.ContactType
|
||||||
|
import com.ts3client.net.ChannelNode
|
||||||
|
import com.ts3client.net.ClientEntry
|
||||||
|
import com.ts3client.net.ClientStatus
|
||||||
|
import com.ts3client.net.Spacers
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A copy of the channel tree taken at one moment. The model's own nodes change in place
|
||||||
|
* on connection threads, which Compose cannot observe; these never change.
|
||||||
|
*/
|
||||||
|
@Immutable
|
||||||
|
data class ServerTree(val channels: List<ChannelItem> = emptyList()) {
|
||||||
|
|
||||||
|
fun channel(id: Int): ChannelItem? = find(channels) { it.id == id }
|
||||||
|
|
||||||
|
fun client(id: Int): ClientItem? {
|
||||||
|
var found: ClientItem? = null
|
||||||
|
find(channels) { c -> found = c.clients.firstOrNull { it.id == id }; found != null }
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun find(items: List<ChannelItem>, test: (ChannelItem) -> Boolean): ChannelItem? {
|
||||||
|
for (c in items) {
|
||||||
|
if (test(c)) return c
|
||||||
|
find(c.children, test)?.let { return it }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun of(roots: List<ChannelNode>, contacts: ContactStore, colorContacts: Boolean): ServerTree =
|
||||||
|
ServerTree(roots.map { channel(it, contacts, colorContacts) })
|
||||||
|
|
||||||
|
private fun channel(c: ChannelNode, contacts: ContactStore, colorContacts: Boolean): ChannelItem {
|
||||||
|
val clients = c.clients
|
||||||
|
return ChannelItem(
|
||||||
|
id = c.id,
|
||||||
|
name = c.name ?: "",
|
||||||
|
spacer = Spacers.parse(c.name),
|
||||||
|
locked = c.hasPassword,
|
||||||
|
full = c.maxClients >= 0 && clients.size >= c.maxClients,
|
||||||
|
subscribed = c.subscribed,
|
||||||
|
clients = clients.map { client(it, contacts, colorContacts) },
|
||||||
|
children = c.children.map { channel(it, contacts, colorContacts) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun client(c: ClientEntry, contacts: ContactStore, colorContacts: Boolean): ClientItem {
|
||||||
|
val contact = contacts.find(c.uniqueId)
|
||||||
|
val away = c.away && c.awayMessage.isNotEmpty() && contact?.ignoreAwayMessage != true
|
||||||
|
return ClientItem(
|
||||||
|
id = c.id,
|
||||||
|
uniqueId = c.uniqueId,
|
||||||
|
nickname = c.nickname ?: "",
|
||||||
|
name = contact?.displayName(c.nickname) ?: c.nickname ?: "",
|
||||||
|
awayMessage = if (away) c.awayMessage else "",
|
||||||
|
status = ClientStatus.of(c),
|
||||||
|
self = c.self,
|
||||||
|
contact = if (colorContacts) contact?.type ?: ContactType.NEUTRAL else ContactType.NEUTRAL,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class ChannelItem(
|
||||||
|
val id: Int,
|
||||||
|
val name: String,
|
||||||
|
/** Set for a spacer: a cosmetic separator rather than a channel to join. */
|
||||||
|
val spacer: Spacers.Spacer?,
|
||||||
|
val locked: Boolean,
|
||||||
|
val full: Boolean,
|
||||||
|
val subscribed: Boolean,
|
||||||
|
val clients: List<ClientItem>,
|
||||||
|
val children: List<ChannelItem>,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class ClientItem(
|
||||||
|
val id: Int,
|
||||||
|
val uniqueId: String,
|
||||||
|
val nickname: String,
|
||||||
|
/** The name shown: the contact's own name for them where there is one. */
|
||||||
|
val name: String,
|
||||||
|
val awayMessage: String,
|
||||||
|
val status: ClientStatus,
|
||||||
|
val self: Boolean,
|
||||||
|
val contact: ContactType,
|
||||||
|
)
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
package com.ts3client.android
|
package com.ts3client.android
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
import android.net.ConnectivityManager
|
import android.net.ConnectivityManager
|
||||||
import android.net.Network
|
import android.net.Network
|
||||||
import android.text.Html
|
|
||||||
import com.ts3client.android.audio.AndroidAudioBackend
|
import com.ts3client.android.audio.AndroidAudioBackend
|
||||||
|
import com.ts3client.config.Bookmark
|
||||||
|
import com.ts3client.config.Bookmarks
|
||||||
import com.ts3client.config.IdentityStore
|
import com.ts3client.config.IdentityStore
|
||||||
import com.ts3client.config.Settings
|
import com.ts3client.config.Settings
|
||||||
import com.ts3client.contacts.ContactStore
|
import com.ts3client.contacts.ContactStore
|
||||||
@@ -13,11 +16,21 @@ import com.ts3client.net.ChannelNode
|
|||||||
import com.ts3client.net.ConnectionListener.ChatScope
|
import com.ts3client.net.ConnectionListener.ChatScope
|
||||||
import com.ts3client.session.ServerSession
|
import com.ts3client.session.ServerSession
|
||||||
import com.ts3client.sound.SoundNotifier
|
import com.ts3client.sound.SoundNotifier
|
||||||
|
import com.ts3client.text.ChatHtml
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
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 java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
import java.util.concurrent.atomic.AtomicLong
|
||||||
|
|
||||||
/** What the app shows of the connection. */
|
/** What the app shows of the 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",
|
||||||
@@ -26,30 +39,68 @@ data class SessionState(
|
|||||||
val micActive: Boolean = false,
|
val micActive: Boolean = false,
|
||||||
val micMuted: Boolean = false,
|
val micMuted: Boolean = false,
|
||||||
val deafened: Boolean = false,
|
val deafened: Boolean = false,
|
||||||
val channels: Int = 0,
|
val away: Boolean = false,
|
||||||
val clients: Int = 0,
|
val selfClientId: Int = 0,
|
||||||
val log: List<String> = emptyList(),
|
val channelId: Int = 0,
|
||||||
|
val inputMode: Settings.InputMode = Settings.InputMode.VOICE_ACTIVATION,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** Something that happened once, for the UI to tell the user about. */
|
||||||
|
sealed interface SessionEvent {
|
||||||
|
data class Poke(val from: String, val message: String) : SessionEvent
|
||||||
|
data class Error(val message: String) : SessionEvent
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The app's one [ServerSession], outliving any activity: [ConnectionService] keeps the
|
* The app's one [ServerSession], outliving any activity: [ConnectionService] keeps the
|
||||||
* process in the foreground while it is connected. Session callbacks arrive on connection
|
* process in the foreground while it is connected. Session callbacks arrive on connection
|
||||||
* threads; [MutableStateFlow] takes updates from any of them.
|
* threads and are turned into immutable snapshots the UI collects.
|
||||||
*/
|
*/
|
||||||
class SessionController(private val context: Context) {
|
class SessionController(private val context: Context) {
|
||||||
|
|
||||||
private val settings = Settings.load()
|
val settings: Settings = Settings.load()
|
||||||
|
private val bookmarkStore = Bookmarks.load()
|
||||||
private val audio = AndroidAudioBackend(context)
|
private val audio = AndroidAudioBackend(context)
|
||||||
private val sounds = SoundNotifier(settings).also { it.setPlayer(audio.createSoundPlayer(settings)) }
|
private val sounds = SoundNotifier(settings).also { it.setPlayer(audio.createSoundPlayer(settings)) }
|
||||||
private val session = ServerSession(
|
private val session = ServerSession(
|
||||||
settings, IdentityStore.load(settings), audio, sounds, ContactStore.load(), Listener()
|
settings, IdentityStore.load(settings), audio, sounds, ContactStore.load(), Listener()
|
||||||
)
|
)
|
||||||
|
private val conn get() = session.connection()
|
||||||
|
|
||||||
private val _state = MutableStateFlow(SessionState(title = session.title()))
|
private val _state = MutableStateFlow(snapshotState())
|
||||||
val state: StateFlow<SessionState> = _state
|
val state: StateFlow<SessionState> = _state
|
||||||
|
|
||||||
val nickname: String get() = settings.nickname
|
private val _tree = MutableStateFlow(ServerTree())
|
||||||
val lastAddress: String get() = settings.lastAddress
|
val tree: StateFlow<ServerTree> = _tree
|
||||||
|
|
||||||
|
private val _chats = MutableStateFlow(Chats())
|
||||||
|
val chats: StateFlow<Chats> = _chats
|
||||||
|
|
||||||
|
private val _bookmarks = MutableStateFlow(bookmarkStore.all().toList())
|
||||||
|
val bookmarks: StateFlow<List<Bookmark>> = _bookmarks
|
||||||
|
|
||||||
|
/** Channels the user folded away in the tree. */
|
||||||
|
private val _collapsed = MutableStateFlow(emptySet<Int>())
|
||||||
|
val collapsed: StateFlow<Set<Int>> = _collapsed
|
||||||
|
|
||||||
|
private val _events = MutableSharedFlow<SessionEvent>(extraBufferCapacity = 16)
|
||||||
|
val events: SharedFlow<SessionEvent> = _events
|
||||||
|
|
||||||
|
/** The conversation on screen, whose new lines are read as they come. */
|
||||||
|
@Volatile
|
||||||
|
var viewing: ChatKey? = null
|
||||||
|
set(value) {
|
||||||
|
field = value
|
||||||
|
if (value != null) markRead(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
/** The network the connection runs over, to notice when the system moves to another. */
|
/** The network the connection runs over, to notice when the system moves to another. */
|
||||||
@Volatile
|
@Volatile
|
||||||
@@ -62,26 +113,69 @@ class SessionController(private val context: Context) {
|
|||||||
val previous = network
|
val previous = network
|
||||||
network = available
|
network = available
|
||||||
// The socket is bound to the old network's address and would only time out.
|
// The socket is bound to the old network's address and would only time out.
|
||||||
if (previous != null && previous != available && session.isBusy()) {
|
if (previous != null && previous != available && session.isBusy) {
|
||||||
log("Network changed, reconnecting…")
|
system("Network changed, reconnecting…")
|
||||||
session.reconnect()
|
session.reconnect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fun connect(address: String, nickname: String) {
|
val nickname: String get() = settings.nickname
|
||||||
val host = address.substringBefore(':').trim()
|
val lastAddress: String get() = settings.lastAddress
|
||||||
val port = address.substringAfter(':', "9987").trim().toIntOrNull() ?: 9987
|
|
||||||
|
// ---- connection ----
|
||||||
|
|
||||||
|
/** Connects to `host[:port]`. */
|
||||||
|
fun connect(address: String, nickname: String, password: String = "") {
|
||||||
|
val (host, port) = parseAddress(address)
|
||||||
settings.lastAddress = address
|
settings.lastAddress = address
|
||||||
settings.nickname = nickname
|
settings.nickname = nickname
|
||||||
settings.save()
|
settings.save()
|
||||||
|
start(host, port, nickname, password, "", "", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun connect(bookmark: Bookmark) {
|
||||||
|
val nickname = bookmark.nickname?.takeIf { it.isNotBlank() } ?: settings.nickname
|
||||||
|
start(bookmark.address, bookmark.port, nickname, bookmark.password, bookmark.identityId,
|
||||||
|
bookmark.channel, bookmark.channelPassword)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun start(host: String, port: Int, nickname: String, password: String, identityId: String,
|
||||||
|
channel: String, channelPassword: String) {
|
||||||
|
// Capture starts once connected; without the permission yet, the UI hands it over later.
|
||||||
|
if (hasMicPermission()) session.setMicrophoneActive(true)
|
||||||
context.startForegroundService(Intent(context, ConnectionService::class.java))
|
context.startForegroundService(Intent(context, ConnectionService::class.java))
|
||||||
session.connect(host, port, nickname, "", "", "", "")
|
session.connect(host, port, nickname, password, identityId, channel, channelPassword)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun disconnect() = session.disconnect()
|
fun disconnect() = session.disconnect()
|
||||||
|
|
||||||
|
// ---- bookmarks ----
|
||||||
|
|
||||||
|
fun addBookmark(label: String, address: String, nickname: String, password: String) {
|
||||||
|
val (host, port) = parseAddress(address)
|
||||||
|
bookmarkStore.add(Bookmark(label.ifBlank { address }, host, port, nickname, password))
|
||||||
|
saveBookmarks()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeBookmark(bookmark: Bookmark) {
|
||||||
|
val index = bookmarkStore.all().indexOf(bookmark)
|
||||||
|
if (index < 0) return
|
||||||
|
bookmarkStore.remove(index)
|
||||||
|
saveBookmarks()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveBookmarks() {
|
||||||
|
bookmarkStore.save()
|
||||||
|
_bookmarks.value = bookmarkStore.all().toList()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the local client ----
|
||||||
|
|
||||||
|
fun hasMicPermission() =
|
||||||
|
context.checkSelfPermission(Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED
|
||||||
|
|
||||||
/** Hands the microphone to the connection; needs RECORD_AUDIO granted. */
|
/** Hands the microphone to the connection; needs RECORD_AUDIO granted. */
|
||||||
fun setMicrophoneActive(active: Boolean) {
|
fun setMicrophoneActive(active: Boolean) {
|
||||||
session.setMicrophoneActive(active)
|
session.setMicrophoneActive(active)
|
||||||
@@ -92,51 +186,196 @@ class SessionController(private val context: Context) {
|
|||||||
|
|
||||||
fun setDeafened(deafened: Boolean) = session.setDeafened(deafened)
|
fun setDeafened(deafened: Boolean) = session.setDeafened(deafened)
|
||||||
|
|
||||||
fun sendChannelMessage(text: String) = session.sendChat(ChatScope.CHANNEL, 0, text)
|
fun setAway(away: Boolean) = session.setAway(away, null)
|
||||||
|
|
||||||
private fun refresh() {
|
fun setPushToTalk(pressed: Boolean) {
|
||||||
val model = session.connection().model
|
if (session.isConnected) conn.microphone?.setPushToTalk(pressed)
|
||||||
_state.update {
|
}
|
||||||
it.copy(
|
|
||||||
|
/** Saves the settings and puts the voice ones into effect at once. */
|
||||||
|
fun applySettings() {
|
||||||
|
settings.save()
|
||||||
|
if (session.isConnected) {
|
||||||
|
conn.microphone?.apply(settings)
|
||||||
|
conn.playback?.setMasterVolume(settings.outputVolume)
|
||||||
|
}
|
||||||
|
refresh()
|
||||||
|
scheduleTree(structure = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 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) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 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) } }
|
||||||
|
}
|
||||||
|
|
||||||
|
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(),
|
title = session.title(),
|
||||||
status = session.status(),
|
status = session.status(),
|
||||||
connected = session.isConnected(),
|
connected = session.isConnected,
|
||||||
busy = session.isBusy(),
|
busy = session.isBusy,
|
||||||
micActive = session.connection().isMicrophoneActive,
|
micActive = conn.isMicrophoneActive,
|
||||||
micMuted = session.isMicMuted(),
|
micMuted = session.isMicMuted,
|
||||||
deafened = session.isDeafened(),
|
deafened = session.isDeafened,
|
||||||
channels = model.buildTree().sumOf { countChannels(it) },
|
away = session.isAway,
|
||||||
clients = model.allClients().size,
|
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()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun countChannels(node: ChannelNode): Int = 1 + node.children.sumOf { countChannels(it) }
|
/** Takes a new tree snapshot soon; [structure] when channels or clients came, went or moved. */
|
||||||
|
private fun scheduleTree(structure: Boolean) {
|
||||||
private fun log(line: String) {
|
if (structure) structureChanged.set(true)
|
||||||
_state.update { it.copy(log = (it.log + line).takeLast(500)) }
|
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 {
|
private inner class Listener : ServerSession.Listener {
|
||||||
override fun onSessionChanged() = refresh()
|
override fun onSessionChanged() = refresh()
|
||||||
override fun onSystemMessage(text: String) = log(text)
|
override fun onSystemMessage(text: String) = system(text)
|
||||||
override fun onOwnChat(scope: ChatScope, peerClientId: Int, peerName: String, text: String) =
|
|
||||||
log("<you> $text")
|
|
||||||
|
|
||||||
override fun onStatus(status: String) {}
|
override fun onOwnChat(scope: ChatScope, peerClientId: Int, peerName: String, text: String) {
|
||||||
override fun onConnected() = refresh()
|
val html = ChatHtml.message(conn.selfClientId, conn.selfUniqueId, "${settings.nickname} (you)", text)
|
||||||
override fun onDisconnected(reason: String?) = refresh()
|
append(key(scope, peerClientId), peerName, html)
|
||||||
override fun onModelChanged() = refresh()
|
|
||||||
override fun onInfoUpdated() {}
|
|
||||||
override fun onIconsUpdated() {}
|
|
||||||
override fun onChat(scope: ChatScope, fromClientId: Int, fromUniqueId: String?, fromName: String, message: String) =
|
|
||||||
log("<$fromName> $message")
|
|
||||||
|
|
||||||
override fun onTalkStateChanged(clientId: Int, talking: Boolean) {}
|
|
||||||
override fun onError(message: String) {}
|
|
||||||
override fun onPoke(fromName: String, message: String) {}
|
|
||||||
override fun onServerLog(message: String) = log(plain(message))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun plain(html: String) = Html.fromHtml(html, Html.FROM_HTML_MODE_COMPACT).toString().trim()
|
override fun onStatus(status: String) {}
|
||||||
|
|
||||||
|
override fun onConnected() = refresh()
|
||||||
|
|
||||||
|
override fun onDisconnected(reason: String?) {
|
||||||
|
_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))
|
||||||
|
|
||||||
|
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) = 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))
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
const val DEFAULT_PORT = 9987
|
||||||
|
|
||||||
|
fun parseAddress(address: String): Pair<String, Int> {
|
||||||
|
val host = address.substringBefore(':').trim()
|
||||||
|
val port = address.substringAfter(':', "").trim().toIntOrNull() ?: DEFAULT_PORT
|
||||||
|
return host to port
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
71
android/app/src/main/java/com/ts3client/android/ui/App.kt
Normal file
71
android/app/src/main/java/com/ts3client/android/ui/App.kt
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
package com.ts3client.android.ui
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.ts3client.android.SessionController
|
||||||
|
import com.ts3client.android.SessionEvent
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun App(vm: SessionController) {
|
||||||
|
val state by vm.state.collectAsStateWithLifecycle()
|
||||||
|
val snackbar = remember { SnackbarHostState() }
|
||||||
|
var settingsOpen by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var poke by remember { mutableStateOf<SessionEvent.Poke?>(null) }
|
||||||
|
|
||||||
|
LaunchedEffect(vm) {
|
||||||
|
vm.events.collect { event ->
|
||||||
|
when (event) {
|
||||||
|
is SessionEvent.Poke -> poke = event
|
||||||
|
is SessionEvent.Error -> snackbar.showSnackbar(event.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val context = LocalContext.current
|
||||||
|
val permissions = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { granted ->
|
||||||
|
if (granted[Manifest.permission.RECORD_AUDIO] == true) vm.setMicrophoneActive(true)
|
||||||
|
}
|
||||||
|
fun missing(vararg names: String) =
|
||||||
|
names.filter { context.checkSelfPermission(it) != PackageManager.PERMISSION_GRANTED }
|
||||||
|
|
||||||
|
// The connection runs without either permission, just silent or without its notification.
|
||||||
|
val connectAsking: (() -> Unit) -> Unit = { connect ->
|
||||||
|
connect()
|
||||||
|
val ask = missing(Manifest.permission.RECORD_AUDIO, Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
if (ask.isNotEmpty()) permissions.launch(ask.toTypedArray())
|
||||||
|
}
|
||||||
|
val enableMicrophone = {
|
||||||
|
if (missing(Manifest.permission.RECORD_AUDIO).isEmpty()) vm.setMicrophoneActive(true)
|
||||||
|
else permissions.launch(arrayOf(Manifest.permission.RECORD_AUDIO))
|
||||||
|
}
|
||||||
|
|
||||||
|
when {
|
||||||
|
settingsOpen -> SettingsScreen(vm, onBack = { settingsOpen = false })
|
||||||
|
state.busy || state.connected -> ServerScreen(vm, snackbar, { settingsOpen = true }, enableMicrophone)
|
||||||
|
else -> ConnectScreen(vm, snackbar, { settingsOpen = true }, connectAsking)
|
||||||
|
}
|
||||||
|
|
||||||
|
poke?.let { p ->
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { poke = null },
|
||||||
|
title = { Text("${p.from} poked you") },
|
||||||
|
text = if (p.message.isNotBlank()) ({ Text(p.message) }) else null,
|
||||||
|
confirmButton = { TextButton(onClick = { poke = null }) { Text("OK") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
package com.ts3client.android.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.RowScope
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.outlined.Chat
|
||||||
|
import androidx.compose.material.icons.filled.Bedtime
|
||||||
|
import androidx.compose.material.icons.filled.Block
|
||||||
|
import androidx.compose.material.icons.filled.Campaign
|
||||||
|
import androidx.compose.material.icons.filled.Circle
|
||||||
|
import androidx.compose.material.icons.filled.Dns
|
||||||
|
import androidx.compose.material.icons.filled.HeadsetOff
|
||||||
|
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||||
|
import androidx.compose.material.icons.filled.Lock
|
||||||
|
import androidx.compose.material.icons.filled.MicOff
|
||||||
|
import androidx.compose.material.icons.filled.Terminal
|
||||||
|
import androidx.compose.material.icons.filled.VolumeOff
|
||||||
|
import androidx.compose.material.icons.outlined.Campaign
|
||||||
|
import androidx.compose.material.icons.outlined.Circle
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.LocalContentColor
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
|
import androidx.compose.ui.draw.rotate
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.ts3client.android.ChannelItem
|
||||||
|
import com.ts3client.android.ClientItem
|
||||||
|
import com.ts3client.android.ServerTree
|
||||||
|
import com.ts3client.contacts.ContactType
|
||||||
|
import com.ts3client.net.ClientStatus
|
||||||
|
|
||||||
|
/** One line of the tree as the list shows it. */
|
||||||
|
private sealed interface TreeRow {
|
||||||
|
val key: String
|
||||||
|
|
||||||
|
data class Channel(val channel: ChannelItem, val depth: Int, val expanded: Boolean, val current: Boolean) : TreeRow {
|
||||||
|
override val key get() = "c${channel.id}"
|
||||||
|
val expandable get() = channel.clients.isNotEmpty() || channel.children.isNotEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
data class Client(val client: ClientItem, val depth: Int) : TreeRow {
|
||||||
|
override val key get() = "u${client.id}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The tree's channels and clients in reading order, leaving out what sits in folded channels. */
|
||||||
|
private fun flatten(tree: ServerTree, collapsed: Set<Int>, currentChannel: Int): List<TreeRow> {
|
||||||
|
val rows = ArrayList<TreeRow>()
|
||||||
|
fun add(channel: ChannelItem, depth: Int) {
|
||||||
|
val expanded = channel.id !in collapsed
|
||||||
|
rows += TreeRow.Channel(channel, depth, expanded, channel.id == currentChannel)
|
||||||
|
if (!expanded) return
|
||||||
|
channel.clients.forEach { rows += TreeRow.Client(it, depth + 1) }
|
||||||
|
channel.children.forEach { add(it, depth + 1) }
|
||||||
|
}
|
||||||
|
tree.channels.forEach { add(it, 0) }
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ChannelTree(
|
||||||
|
serverName: String,
|
||||||
|
tree: ServerTree,
|
||||||
|
collapsed: Set<Int>,
|
||||||
|
currentChannel: Int,
|
||||||
|
onToggle: (ChannelItem) -> Unit,
|
||||||
|
onChannel: (ChannelItem) -> Unit,
|
||||||
|
onClient: (ClientItem) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val rows = remember(tree, collapsed, currentChannel) { flatten(tree, collapsed, currentChannel) }
|
||||||
|
LazyColumn(modifier.fillMaxSize()) {
|
||||||
|
item(key = "server") {
|
||||||
|
RowLayout(depth = 0, onClick = null) {
|
||||||
|
Icon(Icons.Filled.Dns, null, Modifier.size(ICON))
|
||||||
|
Text(serverName, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items(rows, key = { it.key }) { row ->
|
||||||
|
when (row) {
|
||||||
|
is TreeRow.Channel -> ChannelRow(row, onToggle, onChannel)
|
||||||
|
is TreeRow.Client -> ClientRow(row, onClient)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val ICON = 20.dp
|
||||||
|
private val INDENT = 16.dp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RowLayout(depth: Int, onClick: (() -> Unit)?, content: @Composable RowScope.() -> Unit) {
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(min = 40.dp)
|
||||||
|
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
|
||||||
|
.padding(start = 8.dp + INDENT * depth, end = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) { content() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ChannelRow(row: TreeRow.Channel, onToggle: (ChannelItem) -> Unit, onChannel: (ChannelItem) -> Unit) {
|
||||||
|
val channel = row.channel
|
||||||
|
val spacer = channel.spacer
|
||||||
|
RowLayout(row.depth, onClick = { onChannel(channel) }) {
|
||||||
|
Box(Modifier.size(ICON).then(if (row.expandable) Modifier.clickable { onToggle(channel) } else Modifier)) {
|
||||||
|
if (row.expandable) {
|
||||||
|
Icon(Icons.Filled.KeyboardArrowDown, if (row.expanded) "Collapse" else "Expand",
|
||||||
|
Modifier.rotate(if (row.expanded) 0f else -90f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (spacer != null) {
|
||||||
|
SpacerLabel(spacer.align(), spacer.caption().orEmpty(), Modifier.weight(1f))
|
||||||
|
} else {
|
||||||
|
val icon = when {
|
||||||
|
channel.locked -> Icons.Filled.Lock
|
||||||
|
channel.full -> Icons.Filled.Block
|
||||||
|
else -> Icons.AutoMirrored.Outlined.Chat
|
||||||
|
}
|
||||||
|
Icon(icon, null, Modifier.size(ICON).alpha(if (channel.subscribed) 1f else 0.5f),
|
||||||
|
tint = if (channel.full && !channel.locked) StatusColors.muted else LocalContentColor.current)
|
||||||
|
Text(
|
||||||
|
channel.name,
|
||||||
|
Modifier.weight(1f),
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = if (row.current) MaterialTheme.colorScheme.primary else Color.Unspecified,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A spacer's caption, placed as its tag asks; a repeated one of dashes or the like becomes a rule. */
|
||||||
|
@Composable
|
||||||
|
private fun SpacerLabel(align: Char, caption: String, modifier: Modifier) {
|
||||||
|
if (align == '*') {
|
||||||
|
if (caption.isNotEmpty() && caption.all { it in "-_=.~" }) {
|
||||||
|
HorizontalDivider(modifier)
|
||||||
|
} else {
|
||||||
|
Text(caption.repeat(200 / caption.length.coerceAtLeast(1)), modifier, maxLines = 1, overflow = TextOverflow.Clip)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val textAlign = when (align) {
|
||||||
|
'c' -> TextAlign.Center
|
||||||
|
'r' -> TextAlign.End
|
||||||
|
else -> TextAlign.Start
|
||||||
|
}
|
||||||
|
Text(caption, modifier, textAlign = textAlign, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ClientRow(row: TreeRow.Client, onClient: (ClientItem) -> Unit) {
|
||||||
|
val client = row.client
|
||||||
|
RowLayout(row.depth, onClick = { onClient(client) }) {
|
||||||
|
Spacer(Modifier.width(ICON))
|
||||||
|
StatusIcon(client.status)
|
||||||
|
Text(
|
||||||
|
client.name,
|
||||||
|
fontWeight = if (client.self) FontWeight.Bold else FontWeight.Normal,
|
||||||
|
color = when (client.contact) {
|
||||||
|
ContactType.FRIEND -> StatusColors.friend
|
||||||
|
ContactType.BLOCKED -> StatusColors.blocked
|
||||||
|
else -> Color.Unspecified
|
||||||
|
},
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
if (client.awayMessage.isNotEmpty()) {
|
||||||
|
Text("[${client.awayMessage}]", Modifier.weight(1f, fill = false),
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun StatusIcon(status: ClientStatus, modifier: Modifier = Modifier) {
|
||||||
|
val (icon, tint) = statusIcon(status)
|
||||||
|
Icon(icon, status.name, modifier.size(ICON), tint = if (tint == Color.Unspecified) LocalContentColor.current else tint)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun statusIcon(status: ClientStatus): Pair<ImageVector, Color> = when (status) {
|
||||||
|
ClientStatus.QUERY -> Icons.Filled.Terminal to Color.Unspecified
|
||||||
|
ClientStatus.SPEAKERS_DISABLED -> Icons.Filled.HeadsetOff to Color.Gray
|
||||||
|
ClientStatus.SPEAKERS_MUTED -> Icons.Filled.VolumeOff to StatusColors.muted
|
||||||
|
ClientStatus.MIC_DISABLED -> Icons.Filled.MicOff to Color.Gray
|
||||||
|
ClientStatus.MIC_MUTED -> Icons.Filled.MicOff to StatusColors.muted
|
||||||
|
ClientStatus.AWAY -> Icons.Filled.Bedtime to StatusColors.away
|
||||||
|
ClientStatus.COMMANDER_TALKING -> Icons.Filled.Campaign to StatusColors.talking
|
||||||
|
ClientStatus.COMMANDER -> Icons.Outlined.Campaign to Color.Unspecified
|
||||||
|
ClientStatus.TALKING -> Icons.Filled.Circle to StatusColors.talking
|
||||||
|
ClientStatus.IDLE -> Icons.Outlined.Circle to Color.Gray
|
||||||
|
}
|
||||||
137
android/app/src/main/java/com/ts3client/android/ui/ChatPane.kt
Normal file
137
android/app/src/main/java/com/ts3client/android/ui/ChatPane.kt
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
package com.ts3client.android.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.Send
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material3.Badge
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.input.key.Key
|
||||||
|
import androidx.compose.ui.input.key.KeyEventType
|
||||||
|
import androidx.compose.ui.input.key.isShiftPressed
|
||||||
|
import androidx.compose.ui.input.key.key
|
||||||
|
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||||
|
import androidx.compose.ui.input.key.type
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.ts3client.android.ChatKey
|
||||||
|
import com.ts3client.android.ChatLine
|
||||||
|
import com.ts3client.android.Chats
|
||||||
|
import com.ts3client.android.Conversation
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ChatPane(
|
||||||
|
chats: Chats,
|
||||||
|
selected: Conversation,
|
||||||
|
canSend: Boolean,
|
||||||
|
onSelect: (ChatKey) -> Unit,
|
||||||
|
onClose: (ChatKey.Private) -> Unit,
|
||||||
|
onSend: (String) -> Unit,
|
||||||
|
onLink: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Column(modifier) {
|
||||||
|
LazyRow(
|
||||||
|
contentPadding = PaddingValues(horizontal = 8.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
items(chats.all, key = { it.key.toString() }) { conversation ->
|
||||||
|
ConversationChip(conversation, conversation.key == selected.key, onSelect, onClose)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Lines(selected.lines, onLink, Modifier.weight(1f))
|
||||||
|
var draft by rememberSaveable(selected.key.toString()) { mutableStateOf("") }
|
||||||
|
val send = {
|
||||||
|
if (draft.isNotBlank()) {
|
||||||
|
onSend(draft.trim())
|
||||||
|
draft = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(Modifier.padding(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
OutlinedTextField(
|
||||||
|
draft, { draft = it },
|
||||||
|
Modifier.weight(1f).onPreviewKeyEvent { e ->
|
||||||
|
// A hardware keyboard's Enter sends, as on the desktop; Shift+Enter starts a new line.
|
||||||
|
val enter = e.key == Key.Enter && !e.isShiftPressed
|
||||||
|
if (enter && e.type == KeyEventType.KeyDown) send()
|
||||||
|
enter
|
||||||
|
},
|
||||||
|
placeholder = { Text("Message ${selected.title}", maxLines = 1, overflow = TextOverflow.Ellipsis) },
|
||||||
|
enabled = canSend,
|
||||||
|
maxLines = 4,
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
|
||||||
|
keyboardActions = KeyboardActions(onSend = { send() }),
|
||||||
|
)
|
||||||
|
IconButton(onClick = send, enabled = canSend && draft.isNotBlank()) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.Send, "Send")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ConversationChip(
|
||||||
|
conversation: Conversation,
|
||||||
|
selected: Boolean,
|
||||||
|
onSelect: (ChatKey) -> Unit,
|
||||||
|
onClose: (ChatKey.Private) -> Unit,
|
||||||
|
) {
|
||||||
|
val key = conversation.key
|
||||||
|
FilterChip(
|
||||||
|
selected = selected,
|
||||||
|
onClick = { onSelect(key) },
|
||||||
|
label = { Text(conversation.title, maxLines = 1, overflow = TextOverflow.Ellipsis) },
|
||||||
|
leadingIcon = if (conversation.unread) ({ Badge() }) else null,
|
||||||
|
trailingIcon = if (key is ChatKey.Private) ({
|
||||||
|
Icon(Icons.Filled.Close, "Close", Modifier.size(18.dp).clickable { onClose(key) })
|
||||||
|
}) else null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The conversation's lines, following new ones while the newest is in view. */
|
||||||
|
@Composable
|
||||||
|
private fun Lines(lines: List<ChatLine>, onLink: (String) -> Unit, modifier: Modifier) {
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
val linkColor = MaterialTheme.colorScheme.primary
|
||||||
|
val link by rememberUpdatedState(onLink)
|
||||||
|
LaunchedEffect(lines.lastOrNull()?.id) {
|
||||||
|
val info = listState.layoutInfo
|
||||||
|
val atEnd = info.visibleItemsInfo.lastOrNull()?.index?.let { it >= info.totalItemsCount - 2 } ?: true
|
||||||
|
if (atEnd && lines.isNotEmpty()) listState.scrollToItem(lines.size - 1)
|
||||||
|
}
|
||||||
|
LazyColumn(modifier.fillMaxWidth(), state = listState, contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp)) {
|
||||||
|
items(lines, key = { it.id }) { line ->
|
||||||
|
val text = remember(line.html, linkColor) { chatText(line.html, linkColor) { link(it) } }
|
||||||
|
if (text.isNotEmpty()) Text(text, Modifier.padding(vertical = 2.dp), style = MaterialTheme.typography.bodyMedium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package com.ts3client.android.ui
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.LinkAnnotation
|
||||||
|
import androidx.compose.ui.text.SpanStyle
|
||||||
|
import androidx.compose.ui.text.TextLinkStyles
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.fromHtml
|
||||||
|
import com.ts3client.text.ChatHtml
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The HTML of a chat line as styled text. Android's HTML parser knows no stylesheets, so
|
||||||
|
* the classes [ChatHtml] marks lines with become inline colours first.
|
||||||
|
*/
|
||||||
|
fun chatText(html: String, linkColor: Color, onLink: (String) -> Unit): AnnotatedString {
|
||||||
|
val styled = CLASS.replace(html) { m -> CLASS_COLORS[m.groupValues[1]]?.let { "style=\"color:$it\"" } ?: "" }
|
||||||
|
val text = AnnotatedString.fromHtml(
|
||||||
|
styled,
|
||||||
|
TextLinkStyles(SpanStyle(color = linkColor, fontWeight = FontWeight.SemiBold)),
|
||||||
|
) { link -> (link as? LinkAnnotation.Url)?.let { onLink(it.url) } }
|
||||||
|
// Block elements leave line breaks around the text.
|
||||||
|
val start = text.text.indexOfFirst { !it.isWhitespace() }.coerceAtLeast(0)
|
||||||
|
val end = text.text.indexOfLast { !it.isWhitespace() } + 1
|
||||||
|
return if (end <= start) AnnotatedString("") else text.subSequence(start, end)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val CLASS = Regex("class=\"([a-z]+)\"")
|
||||||
|
|
||||||
|
private val CLASS_COLORS = mapOf(
|
||||||
|
ChatHtml.MUTED_CLASS to "#8c8c8c",
|
||||||
|
ChatHtml.EVENT_CLASS to "#7a8fa6",
|
||||||
|
)
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
package com.ts3client.android.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.imePadding
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Bookmark
|
||||||
|
import androidx.compose.material.icons.filled.Delete
|
||||||
|
import androidx.compose.material.icons.filled.Settings
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.ts3client.android.SessionController
|
||||||
|
import com.ts3client.config.Bookmark
|
||||||
|
|
||||||
|
/** Where to connect to: an address typed in, or a bookmark. */
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ConnectScreen(
|
||||||
|
vm: SessionController,
|
||||||
|
snackbar: SnackbarHostState,
|
||||||
|
onSettings: () -> Unit,
|
||||||
|
onConnect: (() -> Unit) -> Unit,
|
||||||
|
) {
|
||||||
|
val state by vm.state.collectAsStateWithLifecycle()
|
||||||
|
val bookmarks by vm.bookmarks.collectAsStateWithLifecycle()
|
||||||
|
var address by rememberSaveable { mutableStateOf(vm.lastAddress) }
|
||||||
|
var nickname by rememberSaveable { mutableStateOf(vm.nickname) }
|
||||||
|
var password by rememberSaveable { mutableStateOf("") }
|
||||||
|
var naming by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("TeamSpeak") },
|
||||||
|
actions = { IconButton(onClick = onSettings) { Icon(Icons.Filled.Settings, "Settings") } },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
snackbarHost = { SnackbarHost(snackbar) },
|
||||||
|
) { padding ->
|
||||||
|
LazyColumn(
|
||||||
|
Modifier.padding(padding).imePadding(),
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
OutlinedTextField(address, { address = it }, Modifier.fillMaxWidth(), label = { Text("Server address") },
|
||||||
|
singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri))
|
||||||
|
OutlinedTextField(nickname, { nickname = it }, Modifier.fillMaxWidth(), label = { Text("Nickname") },
|
||||||
|
singleLine = true)
|
||||||
|
OutlinedTextField(password, { password = it }, Modifier.fillMaxWidth(),
|
||||||
|
label = { Text("Server password") }, singleLine = true,
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password))
|
||||||
|
if (state.status != "Not connected") {
|
||||||
|
Text(state.status, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
}
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Button(
|
||||||
|
onClick = { onConnect { vm.connect(address.trim(), nickname.trim(), password) } },
|
||||||
|
enabled = address.isNotBlank() && nickname.isNotBlank(),
|
||||||
|
) { Text("Connect") }
|
||||||
|
TextButton(onClick = { naming = true }, enabled = address.isNotBlank()) { Text("Add bookmark") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bookmarks.isNotEmpty()) {
|
||||||
|
item {
|
||||||
|
Text("Bookmarks", Modifier.padding(top = 16.dp), style = MaterialTheme.typography.titleSmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items(bookmarks) { bookmark -> BookmarkRow(bookmark, { onConnect { vm.connect(bookmark) } }, { vm.removeBookmark(bookmark) }) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (naming) {
|
||||||
|
TextPrompt(
|
||||||
|
title = "Add bookmark",
|
||||||
|
label = "Name",
|
||||||
|
confirm = "Add",
|
||||||
|
initial = address,
|
||||||
|
onDismiss = { naming = false },
|
||||||
|
onConfirm = {
|
||||||
|
naming = false
|
||||||
|
vm.addBookmark(it, address.trim(), nickname.trim(), password)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun BookmarkRow(bookmark: Bookmark, onConnect: () -> Unit, onRemove: () -> Unit) {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text(bookmark.displayName()) },
|
||||||
|
supportingContent = { Text("${bookmark.address}:${bookmark.port}") },
|
||||||
|
leadingContent = { Icon(Icons.Filled.Bookmark, null) },
|
||||||
|
trailingContent = { IconButton(onClick = onRemove) { Icon(Icons.Filled.Delete, "Remove bookmark") } },
|
||||||
|
modifier = Modifier.clickable(onClick = onConnect),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
package com.ts3client.android.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.imePadding
|
||||||
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.Logout
|
||||||
|
import androidx.compose.material.icons.filled.Bedtime
|
||||||
|
import androidx.compose.material.icons.filled.Headset
|
||||||
|
import androidx.compose.material.icons.filled.HeadsetOff
|
||||||
|
import androidx.compose.material.icons.filled.Mic
|
||||||
|
import androidx.compose.material.icons.filled.MicOff
|
||||||
|
import androidx.compose.material.icons.filled.Settings
|
||||||
|
import androidx.compose.material3.Badge
|
||||||
|
import androidx.compose.material3.BadgedBox
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FilledTonalButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.IconToggleButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.PrimaryTabRow
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Tab
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.Saver
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.platform.LocalUriHandler
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.ts3client.android.ChatKey
|
||||||
|
import com.ts3client.android.SessionController
|
||||||
|
import com.ts3client.android.SessionState
|
||||||
|
import com.ts3client.config.Settings
|
||||||
|
import com.ts3client.net.ClientStatus
|
||||||
|
import com.ts3client.text.TsLink
|
||||||
|
|
||||||
|
private val ChatKeySaver = Saver<ChatKey, String>(
|
||||||
|
save = { key ->
|
||||||
|
when (key) {
|
||||||
|
ChatKey.Server -> "server"
|
||||||
|
ChatKey.Channel -> "channel"
|
||||||
|
is ChatKey.Private -> "private:${key.clientId}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
restore = { saved ->
|
||||||
|
when {
|
||||||
|
saved == "server" -> ChatKey.Server
|
||||||
|
saved.startsWith("private:") -> ChatKey.Private(saved.substringAfter(':').toInt())
|
||||||
|
else -> ChatKey.Channel
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
/** A connection: the channel tree and the chat, with the voice controls underneath. */
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ServerScreen(
|
||||||
|
vm: SessionController,
|
||||||
|
snackbar: SnackbarHostState,
|
||||||
|
onSettings: () -> Unit,
|
||||||
|
onEnableMicrophone: () -> Unit,
|
||||||
|
) {
|
||||||
|
val state by vm.state.collectAsStateWithLifecycle()
|
||||||
|
val tree by vm.tree.collectAsStateWithLifecycle()
|
||||||
|
val chats by vm.chats.collectAsStateWithLifecycle()
|
||||||
|
val collapsed by vm.collapsed.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
var tab by rememberSaveable { mutableIntStateOf(TAB_CHANNELS) }
|
||||||
|
var chatKey by rememberSaveable(stateSaver = ChatKeySaver) { mutableStateOf<ChatKey>(ChatKey.Channel) }
|
||||||
|
val conversation = chats[chatKey] ?: chats.channel
|
||||||
|
var clientSheet by remember { mutableStateOf<Int?>(null) }
|
||||||
|
var channelSheet by remember { mutableStateOf<Int?>(null) }
|
||||||
|
|
||||||
|
val viewing = if (tab == TAB_CHAT) conversation.key else null
|
||||||
|
LifecycleResumeEffect(viewing) {
|
||||||
|
vm.viewing = viewing
|
||||||
|
onPauseOrDispose { vm.viewing = null }
|
||||||
|
}
|
||||||
|
|
||||||
|
val uriHandler = LocalUriHandler.current
|
||||||
|
val onLink: (String) -> Unit = { href ->
|
||||||
|
val ref = TsLink.parse(href)
|
||||||
|
when {
|
||||||
|
ref == null -> runCatching { uriHandler.openUri(href) }
|
||||||
|
ref.client -> clientSheet = ref.id
|
||||||
|
else -> channelSheet = ref.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = {
|
||||||
|
Column {
|
||||||
|
Text(state.title, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
if (!state.connected) {
|
||||||
|
Text(state.status, style = MaterialTheme.typography.bodySmall, maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = onSettings) { Icon(Icons.Filled.Settings, "Settings") }
|
||||||
|
IconButton(onClick = vm::disconnect) { Icon(Icons.AutoMirrored.Filled.Logout, "Disconnect") }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
bottomBar = {
|
||||||
|
val self = tree.client(state.selfClientId)
|
||||||
|
val talking = self?.status == ClientStatus.TALKING || self?.status == ClientStatus.COMMANDER_TALKING
|
||||||
|
VoiceBar(state, talking, vm, onEnableMicrophone)
|
||||||
|
},
|
||||||
|
snackbarHost = { SnackbarHost(snackbar) },
|
||||||
|
) { padding ->
|
||||||
|
Column(Modifier.padding(padding).consumeWindowInsets(padding).imePadding()) {
|
||||||
|
PrimaryTabRow(selectedTabIndex = tab) {
|
||||||
|
Tab(tab == TAB_CHANNELS, onClick = { tab = TAB_CHANNELS }, text = { Text("Channels") })
|
||||||
|
Tab(tab == TAB_CHAT, onClick = { tab = TAB_CHAT }, text = {
|
||||||
|
BadgedBox(badge = { if (chats.anyUnread && tab != TAB_CHAT) Badge() }) { Text("Chat") }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (tab == TAB_CHANNELS) {
|
||||||
|
ChannelTree(
|
||||||
|
serverName = state.title,
|
||||||
|
tree = tree,
|
||||||
|
collapsed = collapsed,
|
||||||
|
currentChannel = state.channelId,
|
||||||
|
onToggle = { vm.toggleCollapsed(it.id) },
|
||||||
|
onChannel = { if (it.spacer == null) channelSheet = it.id },
|
||||||
|
onClient = { clientSheet = it.id },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
ChatPane(
|
||||||
|
chats = chats,
|
||||||
|
selected = conversation,
|
||||||
|
canSend = state.connected,
|
||||||
|
onSelect = { chatKey = it },
|
||||||
|
onClose = { key ->
|
||||||
|
if (chatKey == key) chatKey = ChatKey.Channel
|
||||||
|
vm.closePrivateChat(key)
|
||||||
|
},
|
||||||
|
onSend = { vm.send(conversation.key, it) },
|
||||||
|
onLink = onLink,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A sheet goes with its client or channel when that leaves the tree.
|
||||||
|
clientSheet?.let { id ->
|
||||||
|
tree.client(id)?.let { client ->
|
||||||
|
ClientSheet(
|
||||||
|
client = client,
|
||||||
|
away = state.away,
|
||||||
|
initialVolume = remember(id) { vm.clientVolume(id) },
|
||||||
|
onDismiss = { clientSheet = null },
|
||||||
|
onMessage = {
|
||||||
|
clientSheet = null
|
||||||
|
chatKey = vm.openPrivateChat(id)
|
||||||
|
tab = TAB_CHAT
|
||||||
|
},
|
||||||
|
onPoke = { vm.poke(id, it); clientSheet = null },
|
||||||
|
onVolume = { vm.setClientVolume(id, it) },
|
||||||
|
onAway = { vm.setAway(it); clientSheet = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
channelSheet?.let { id ->
|
||||||
|
tree.channel(id)?.let { channel ->
|
||||||
|
ChannelSheet(
|
||||||
|
channel = channel,
|
||||||
|
current = id == state.channelId,
|
||||||
|
onDismiss = { channelSheet = null },
|
||||||
|
onJoin = { vm.joinChannel(id, it); channelSheet = null },
|
||||||
|
onSubscribe = { vm.setSubscribed(id, it); channelSheet = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val TAB_CHANNELS = 0
|
||||||
|
private const val TAB_CHAT = 1
|
||||||
|
|
||||||
|
/** Mute, deafen and away, and the push-to-talk button when that is how the microphone is used. */
|
||||||
|
@Composable
|
||||||
|
private fun VoiceBar(state: SessionState, talking: Boolean, vm: SessionController, onEnableMicrophone: () -> Unit) {
|
||||||
|
Surface(tonalElevation = 3.dp) {
|
||||||
|
Column(Modifier.fillMaxWidth().navigationBarsPadding().padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
if (state.connected && state.micActive && state.inputMode == Settings.InputMode.PUSH_TO_TALK) {
|
||||||
|
PushToTalk(talking, vm::setPushToTalk)
|
||||||
|
}
|
||||||
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly,
|
||||||
|
verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
if (!state.micActive) {
|
||||||
|
FilledTonalButton(onClick = onEnableMicrophone) {
|
||||||
|
Icon(Icons.Filled.Mic, null)
|
||||||
|
Text("Use microphone", Modifier.padding(start = 8.dp))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
IconToggleButton(state.micMuted, { vm.setMicMuted(it) }, enabled = state.connected) {
|
||||||
|
if (state.micMuted) Icon(Icons.Filled.MicOff, "Unmute microphone", tint = StatusColors.muted)
|
||||||
|
else Icon(Icons.Filled.Mic, "Mute microphone")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
IconToggleButton(state.deafened, { vm.setDeafened(it) }, enabled = state.connected) {
|
||||||
|
if (state.deafened) Icon(Icons.Filled.HeadsetOff, "Undeafen", tint = StatusColors.muted)
|
||||||
|
else Icon(Icons.Filled.Headset, "Deafen")
|
||||||
|
}
|
||||||
|
IconToggleButton(state.away, { vm.setAway(it) }, enabled = state.connected) {
|
||||||
|
Icon(Icons.Filled.Bedtime, if (state.away) "Back" else "Away",
|
||||||
|
tint = if (state.away) StatusColors.away else Color.Unspecified)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PushToTalk(talking: Boolean, onPressed: (Boolean) -> Unit) {
|
||||||
|
val colors = MaterialTheme.colorScheme
|
||||||
|
Surface(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(64.dp)
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
detectTapGestures(onPress = {
|
||||||
|
onPressed(true)
|
||||||
|
try {
|
||||||
|
awaitRelease()
|
||||||
|
} finally {
|
||||||
|
onPressed(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
shape = RoundedCornerShape(32.dp),
|
||||||
|
color = if (talking) StatusColors.talking else colors.primaryContainer,
|
||||||
|
contentColor = if (talking) Color.White else colors.onPrimaryContainer,
|
||||||
|
) {
|
||||||
|
Box(contentAlignment = Alignment.Center) {
|
||||||
|
Text(if (talking) "Talking…" else "Hold to talk", style = MaterialTheme.typography.titleMedium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
package com.ts3client.android.ui
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.RadioButton
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Slider
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.ts3client.android.SessionController
|
||||||
|
import com.ts3client.config.Settings
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
private val DENOISE_LEVELS = listOf("6 dB", "12 dB", "18 dB", "21 dB")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The settings that make sense on a phone. Every change is saved and put into
|
||||||
|
* effect at once, as there is no dialog to confirm.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun SettingsScreen(vm: SessionController, onBack: () -> Unit) {
|
||||||
|
val settings = vm.settings
|
||||||
|
fun change(edit: Settings.() -> Unit) {
|
||||||
|
settings.edit()
|
||||||
|
vm.applySettings()
|
||||||
|
}
|
||||||
|
var nickname by remember { mutableStateOf(settings.nickname) }
|
||||||
|
var inputMode by remember { mutableStateOf(settings.inputMode) }
|
||||||
|
var vadMode by remember { mutableStateOf(settings.vadMode) }
|
||||||
|
var denoise by remember { mutableStateOf(settings.denoise) }
|
||||||
|
var agc by remember { mutableStateOf(settings.agc) }
|
||||||
|
val leave = {
|
||||||
|
if (nickname.isNotBlank() && nickname.trim() != settings.nickname) change { this.nickname = nickname.trim() }
|
||||||
|
onBack()
|
||||||
|
}
|
||||||
|
BackHandler(onBack = leave)
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("Settings") },
|
||||||
|
navigationIcon = { IconButton(onClick = leave) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") } },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { padding ->
|
||||||
|
Column(Modifier.padding(padding).verticalScroll(rememberScrollState()).padding(vertical = 8.dp)) {
|
||||||
|
OutlinedTextField(nickname, { nickname = it }, Modifier.fillMaxWidth().padding(horizontal = 16.dp),
|
||||||
|
label = { Text("Nickname") }, singleLine = true)
|
||||||
|
|
||||||
|
Section("Transmission")
|
||||||
|
for ((mode, label) in INPUT_MODES) {
|
||||||
|
Choice(label, inputMode == mode) {
|
||||||
|
inputMode = mode
|
||||||
|
change { this.inputMode = mode }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inputMode == Settings.InputMode.VOICE_ACTIVATION) {
|
||||||
|
Section("Voice activation")
|
||||||
|
for ((mode, label) in VAD_MODES) {
|
||||||
|
Choice(label, vadMode == mode) {
|
||||||
|
vadMode = mode
|
||||||
|
change { this.vadMode = mode }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (vadMode != Settings.VadMode.AUTOMATIC) {
|
||||||
|
Level("Volume threshold", settings.vadThresholdDb.toFloat(), -50f..50f, { "${it.roundToInt()}" }) {
|
||||||
|
change { vadThresholdDb = it.roundToInt().toDouble() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (vadMode != Settings.VadMode.VOLUME_GATE) {
|
||||||
|
Level("Speech likelihood", settings.speechThreshold.toFloat(), 0f..1f, { "${(it * 100).roundToInt()} %" }) {
|
||||||
|
change { speechThreshold = it.toDouble() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Processing")
|
||||||
|
Toggle("Noise suppression", denoise) {
|
||||||
|
denoise = it
|
||||||
|
change { this.denoise = it }
|
||||||
|
}
|
||||||
|
if (denoise) {
|
||||||
|
Level("Suppression", settings.denoiserLevel.toFloat(), 0f..3f, { DENOISE_LEVELS[it.roundToInt()] }, steps = 2) {
|
||||||
|
change { denoiserLevel = it.roundToInt() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Toggle("Automatic gain control", agc) {
|
||||||
|
agc = it
|
||||||
|
change { this.agc = it }
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Playback")
|
||||||
|
Level("Volume", settings.outputVolume.toFloat(), 0f..2f, { "${(it * 100).roundToInt()} %" }) {
|
||||||
|
change { outputVolume = it.toDouble() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val INPUT_MODES = listOf(
|
||||||
|
Settings.InputMode.VOICE_ACTIVATION to "Voice activation",
|
||||||
|
Settings.InputMode.PUSH_TO_TALK to "Push to talk",
|
||||||
|
Settings.InputMode.CONTINUOUS to "Continuous",
|
||||||
|
)
|
||||||
|
|
||||||
|
private val VAD_MODES = listOf(
|
||||||
|
Settings.VadMode.AUTOMATIC to "Automatic",
|
||||||
|
Settings.VadMode.VOLUME_GATE to "Volume gate",
|
||||||
|
Settings.VadMode.HYBRID to "Hybrid",
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Section(title: String) {
|
||||||
|
Text(title, Modifier.padding(start = 16.dp, top = 24.dp, bottom = 4.dp),
|
||||||
|
style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Choice(label: String, selected: Boolean, onSelect: () -> Unit) {
|
||||||
|
Row(Modifier.fillMaxWidth().clickable(onClick = onSelect).padding(horizontal = 16.dp, vertical = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
RadioButton(selected, onClick = onSelect)
|
||||||
|
Text(label, Modifier.padding(start = 8.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Toggle(label: String, checked: Boolean, onChange: (Boolean) -> Unit) {
|
||||||
|
Row(Modifier.fillMaxWidth().clickable { onChange(!checked) }.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(label, Modifier.weight(1f))
|
||||||
|
Switch(checked, onChange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A slider that applies its value once let go of. */
|
||||||
|
@Composable
|
||||||
|
private fun Level(
|
||||||
|
label: String,
|
||||||
|
value: Float,
|
||||||
|
range: ClosedFloatingPointRange<Float>,
|
||||||
|
format: (Float) -> String,
|
||||||
|
steps: Int = 0,
|
||||||
|
onDone: (Float) -> Unit,
|
||||||
|
) {
|
||||||
|
var current by remember(value) { mutableFloatStateOf(value) }
|
||||||
|
Column(Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) {
|
||||||
|
Row {
|
||||||
|
Text(label, Modifier.weight(1f))
|
||||||
|
Text(format(current), color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
Slider(current, { current = it }, valueRange = range, steps = steps, onValueChangeFinished = { onDone(current) })
|
||||||
|
}
|
||||||
|
}
|
||||||
168
android/app/src/main/java/com/ts3client/android/ui/Sheets.kt
Normal file
168
android/app/src/main/java/com/ts3client/android/ui/Sheets.kt
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
package com.ts3client.android.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.Login
|
||||||
|
import androidx.compose.material.icons.automirrored.outlined.Chat
|
||||||
|
import androidx.compose.material.icons.filled.Bedtime
|
||||||
|
import androidx.compose.material.icons.filled.NotificationsActive
|
||||||
|
import androidx.compose.material.icons.filled.Visibility
|
||||||
|
import androidx.compose.material.icons.filled.VisibilityOff
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.ListItemDefaults
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Slider
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.text.input.VisualTransformation
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import com.ts3client.android.ChannelItem
|
||||||
|
import com.ts3client.android.ClientItem
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
/** What can be done with a client: the private chat, a poke, their volume. */
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ClientSheet(
|
||||||
|
client: ClientItem,
|
||||||
|
away: Boolean,
|
||||||
|
initialVolume: Double,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onMessage: () -> Unit,
|
||||||
|
onPoke: (String) -> Unit,
|
||||||
|
onVolume: (Double) -> Unit,
|
||||||
|
onAway: (Boolean) -> Unit,
|
||||||
|
) {
|
||||||
|
var poking by remember { mutableStateOf(false) }
|
||||||
|
ModalBottomSheet(onDismissRequest = onDismiss) {
|
||||||
|
Column(Modifier.navigationBarsPadding().padding(bottom = 16.dp)) {
|
||||||
|
Row(Modifier.padding(horizontal = 24.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
StatusIcon(client.status)
|
||||||
|
Column {
|
||||||
|
Text(client.name, style = MaterialTheme.typography.titleLarge)
|
||||||
|
if (client.name != client.nickname) Text(client.nickname, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
if (client.awayMessage.isNotEmpty()) Text(client.awayMessage, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (client.self) {
|
||||||
|
Action(Icons.Filled.Bedtime, if (away) "Back" else "Away") { onAway(!away) }
|
||||||
|
} else {
|
||||||
|
Action(Icons.AutoMirrored.Outlined.Chat, "Send message", onMessage)
|
||||||
|
Action(Icons.Filled.NotificationsActive, "Poke") { poking = true }
|
||||||
|
var volume by remember { mutableFloatStateOf(initialVolume.toFloat()) }
|
||||||
|
Text("Volume: ${if (volume > 0) "+" else ""}${volume.roundToInt()} dB",
|
||||||
|
Modifier.padding(start = 24.dp, top = 8.dp), style = MaterialTheme.typography.bodyMedium)
|
||||||
|
Slider(
|
||||||
|
volume, { volume = it.roundToInt().toFloat() },
|
||||||
|
Modifier.padding(horizontal = 24.dp),
|
||||||
|
valueRange = -30f..20f,
|
||||||
|
onValueChangeFinished = { onVolume(volume.toDouble()) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (poking) {
|
||||||
|
TextPrompt(
|
||||||
|
title = "Poke ${client.name}",
|
||||||
|
label = "Message",
|
||||||
|
confirm = "Poke",
|
||||||
|
onDismiss = { poking = false },
|
||||||
|
onConfirm = { poking = false; onPoke(it) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What can be done with a channel: join it, and whether to hear who is in it. */
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ChannelSheet(
|
||||||
|
channel: ChannelItem,
|
||||||
|
current: Boolean,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onJoin: (String?) -> Unit,
|
||||||
|
onSubscribe: (Boolean) -> Unit,
|
||||||
|
) {
|
||||||
|
var askPassword by remember { mutableStateOf(false) }
|
||||||
|
ModalBottomSheet(onDismissRequest = onDismiss) {
|
||||||
|
Column(Modifier.navigationBarsPadding().padding(bottom = 16.dp)) {
|
||||||
|
Text(channel.name, Modifier.padding(horizontal = 24.dp, vertical = 8.dp),
|
||||||
|
style = MaterialTheme.typography.titleLarge)
|
||||||
|
if (!current) {
|
||||||
|
Action(Icons.AutoMirrored.Filled.Login, "Join") {
|
||||||
|
if (channel.locked) askPassword = true else onJoin(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (channel.subscribed) {
|
||||||
|
Action(Icons.Filled.VisibilityOff, "Unsubscribe") { onSubscribe(false) }
|
||||||
|
} else {
|
||||||
|
Action(Icons.Filled.Visibility, "Subscribe") { onSubscribe(true) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (askPassword) {
|
||||||
|
TextPrompt(
|
||||||
|
title = "Join ${channel.name}",
|
||||||
|
label = "Channel password",
|
||||||
|
confirm = "Join",
|
||||||
|
password = true,
|
||||||
|
onDismiss = { askPassword = false },
|
||||||
|
onConfirm = { askPassword = false; onJoin(it) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Action(icon: androidx.compose.ui.graphics.vector.ImageVector, label: String, onClick: () -> Unit) {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text(label) },
|
||||||
|
leadingContent = { Icon(icon, null) },
|
||||||
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick).padding(horizontal = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Asks for one line of text. */
|
||||||
|
@Composable
|
||||||
|
fun TextPrompt(
|
||||||
|
title: String,
|
||||||
|
label: String,
|
||||||
|
confirm: String,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onConfirm: (String) -> Unit,
|
||||||
|
initial: String = "",
|
||||||
|
password: Boolean = false,
|
||||||
|
) {
|
||||||
|
var text by remember { mutableStateOf(initial) }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(title) },
|
||||||
|
text = {
|
||||||
|
OutlinedTextField(text, { text = it }, label = { Text(label) }, singleLine = true,
|
||||||
|
visualTransformation = if (password) PasswordVisualTransformation() else VisualTransformation.None)
|
||||||
|
},
|
||||||
|
confirmButton = { TextButton(onClick = { onConfirm(text) }) { Text(confirm) } },
|
||||||
|
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
26
android/app/src/main/java/com/ts3client/android/ui/Theme.kt
Normal file
26
android/app/src/main/java/com/ts3client/android/ui/Theme.kt
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
package com.ts3client.android.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.dynamicDarkColorScheme
|
||||||
|
import androidx.compose.material3.dynamicLightColorScheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
|
||||||
|
/** Colours that mean the same in either theme, as TeamSpeak uses them. */
|
||||||
|
object StatusColors {
|
||||||
|
val talking = Color(0xFF2E9BF0)
|
||||||
|
val muted = Color(0xFFD9443A)
|
||||||
|
val away = Color(0xFF9E8A2E)
|
||||||
|
val friend = Color(0xFF3A9D4A)
|
||||||
|
val blocked = Color(0xFFD9443A)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The system's own (wallpaper) colours, which every supported Android version offers. */
|
||||||
|
@Composable
|
||||||
|
fun Ts3Theme(content: @Composable () -> Unit) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val colors = if (isSystemInDarkTheme()) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||||
|
MaterialTheme(colorScheme = colors, content = content)
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ oboe = "1.11.0"
|
|||||||
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" }
|
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" }
|
||||||
androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" }
|
androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" }
|
||||||
androidx-compose-material3 = { module = "androidx.compose.material3:material3" }
|
androidx-compose-material3 = { module = "androidx.compose.material3:material3" }
|
||||||
|
androidx-compose-material-icons = { module = "androidx.compose.material:material-icons-extended" }
|
||||||
androidx-compose-ui = { module = "androidx.compose.ui:ui" }
|
androidx-compose-ui = { module = "androidx.compose.ui:ui" }
|
||||||
androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" }
|
androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" }
|
||||||
androidx-lifecycle-service = { module = "androidx.lifecycle:lifecycle-service", version.ref = "lifecycle" }
|
androidx-lifecycle-service = { module = "androidx.lifecycle:lifecycle-service", version.ref = "lifecycle" }
|
||||||
|
|||||||
Reference in New Issue
Block a user