diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index dc21fb4..4b05711 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -50,6 +50,7 @@ dependencies { implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.material.icons) implementation(libs.androidx.activity.compose) implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.androidx.lifecycle.service) diff --git a/android/app/src/main/java/com/ts3client/android/Chats.kt b/android/app/src/main/java/com/ts3client/android/Chats.kt new file mode 100644 index 0000000..182d810 --- /dev/null +++ b/android/app/src/main/java/com/ts3client/android/Chats.kt @@ -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 = 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 = emptyList(), +) { + val all: List 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) diff --git a/android/app/src/main/java/com/ts3client/android/MainActivity.kt b/android/app/src/main/java/com/ts3client/android/MainActivity.kt index bec6785..32980bb 100644 --- a/android/app/src/main/java/com/ts3client/android/MainActivity.kt +++ b/android/app/src/main/java/com/ts3client/android/MainActivity.kt @@ -1,126 +1,19 @@ package com.ts3client.android -import android.Manifest -import android.content.pm.PackageManager import android.os.Bundle import androidx.activity.ComponentActivity -import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.setContent -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -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 +import androidx.activity.enableEdgeToEdge +import com.ts3client.android.ui.App +import com.ts3client.android.ui.Ts3Theme class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() super.onCreate(savedInstanceState) + val sessions = (application as Ts3Application).sessions setContent { - MaterialTheme(colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme()) { - 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") - } + Ts3Theme { App(sessions) } } } } diff --git a/android/app/src/main/java/com/ts3client/android/ServerTree.kt b/android/app/src/main/java/com/ts3client/android/ServerTree.kt new file mode 100644 index 0000000..b26875b --- /dev/null +++ b/android/app/src/main/java/com/ts3client/android/ServerTree.kt @@ -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 = 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, 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, 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, + val children: List, +) + +@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, +) diff --git a/android/app/src/main/java/com/ts3client/android/SessionController.kt b/android/app/src/main/java/com/ts3client/android/SessionController.kt index a40c14a..f6e8718 100644 --- a/android/app/src/main/java/com/ts3client/android/SessionController.kt +++ b/android/app/src/main/java/com/ts3client/android/SessionController.kt @@ -1,11 +1,14 @@ package com.ts3client.android +import android.Manifest import android.content.Context import android.content.Intent +import android.content.pm.PackageManager import android.net.ConnectivityManager import android.net.Network -import android.text.Html 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.Settings import com.ts3client.contacts.ContactStore @@ -13,11 +16,21 @@ import com.ts3client.net.ChannelNode import com.ts3client.net.ConnectionListener.ChatScope import com.ts3client.session.ServerSession 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.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicLong -/** What the app shows of the connection. */ +/** What the app shows of the connection and the local client on it. */ data class SessionState( val title: String = "", val status: String = "Not connected", @@ -26,30 +39,68 @@ data class SessionState( val micActive: Boolean = false, val micMuted: Boolean = false, val deafened: Boolean = false, - val channels: Int = 0, - val clients: Int = 0, - val log: List = emptyList(), + val away: Boolean = false, + val selfClientId: Int = 0, + 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 * 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) { - private val settings = Settings.load() + val settings: Settings = Settings.load() + private val bookmarkStore = Bookmarks.load() private val audio = AndroidAudioBackend(context) private val sounds = SoundNotifier(settings).also { it.setPlayer(audio.createSoundPlayer(settings)) } private val session = ServerSession( 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 = _state - val nickname: String get() = settings.nickname - val lastAddress: String get() = settings.lastAddress + private val _tree = MutableStateFlow(ServerTree()) + val tree: StateFlow = _tree + + private val _chats = MutableStateFlow(Chats()) + val chats: StateFlow = _chats + + private val _bookmarks = MutableStateFlow(bookmarkStore.all().toList()) + val bookmarks: StateFlow> = _bookmarks + + /** Channels the user folded away in the tree. */ + private val _collapsed = MutableStateFlow(emptySet()) + val collapsed: StateFlow> = _collapsed + + private val _events = MutableSharedFlow(extraBufferCapacity = 16) + val events: SharedFlow = _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 = emptyList() /** The network the connection runs over, to notice when the system moves to another. */ @Volatile @@ -62,26 +113,69 @@ class SessionController(private val context: Context) { val previous = network network = available // The socket is bound to the old network's address and would only time out. - if (previous != null && previous != available && session.isBusy()) { - log("Network changed, reconnecting…") + if (previous != null && previous != available && session.isBusy) { + system("Network changed, reconnecting…") session.reconnect() } } }) } - fun connect(address: String, nickname: String) { - val host = address.substringBefore(':').trim() - val port = address.substringAfter(':', "9987").trim().toIntOrNull() ?: 9987 + val nickname: String get() = settings.nickname + val lastAddress: String get() = settings.lastAddress + + // ---- connection ---- + + /** Connects to `host[:port]`. */ + fun connect(address: String, nickname: String, password: String = "") { + val (host, port) = parseAddress(address) settings.lastAddress = address settings.nickname = nickname 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)) - session.connect(host, port, nickname, "", "", "", "") + session.connect(host, port, nickname, password, identityId, channel, channelPassword) } 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. */ fun setMicrophoneActive(active: Boolean) { session.setMicrophoneActive(active) @@ -92,51 +186,196 @@ class SessionController(private val context: Context) { 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() { - val model = session.connection().model - _state.update { - it.copy( - title = session.title(), - status = session.status(), - connected = session.isConnected(), - busy = session.isBusy(), - micActive = session.connection().isMicrophoneActive, - micMuted = session.isMicMuted(), - deafened = session.isDeafened(), - channels = model.buildTree().sumOf { countChannels(it) }, - clients = model.allClients().size, - ) + fun setPushToTalk(pressed: Boolean) { + if (session.isConnected) conn.microphone?.setPushToTalk(pressed) + } + + /** 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) } } - private fun countChannels(node: ChannelNode): Int = 1 + node.children.sumOf { countChannels(it) } + fun markRead(key: ChatKey) { + if (_chats.value[key]?.unread == true) _chats.update { chats -> chats.update(key) { it.copy(unread = false) } } + } - private fun log(line: String) { - _state.update { it.copy(log = (it.log + line).takeLast(500)) } + 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) { + val lines = entries.map(ChatHtml::logEntry).filter { it.isNotEmpty() } + .map { ChatLine(lineIds.incrementAndGet(), it) } + _chats.update { chats -> chats.update(key, title) { it.copy(lines = lines) } } + } + + private fun system(text: String) = append(ChatKey.Server, "", ChatHtml.system(text)) + + // ---- snapshots ---- + + private fun snapshotState(): SessionState { + val self = conn.model.getClient(conn.selfClientId) + return SessionState( + title = session.title(), + status = session.status(), + connected = session.isConnected, + busy = session.isBusy, + micActive = conn.isMicrophoneActive, + micMuted = session.isMicMuted, + deafened = session.isDeafened, + away = session.isAway, + selfClientId = if (session.isConnected) conn.selfClientId else 0, + channelId = if (session.isConnected) self?.channelId ?: 0 else 0, + inputMode = settings.inputMode, + ) + } + + private fun refresh() { + _state.value = snapshotState() + } + + /** Takes a new tree snapshot soon; [structure] when channels or clients came, went or moved. */ + private fun scheduleTree(structure: Boolean) { + if (structure) structureChanged.set(true) + if (!treePending.compareAndSet(false, true)) return + treeScope.launch { + delay(TREE_FRAME_MS) + treePending.set(false) + if (structureChanged.getAndSet(false)) roots = conn.model.buildTree() + _tree.value = ServerTree.of(roots, conn.contacts, settings.colorContacts) + val serverName = if (session.isConnected) conn.model.serverName.orEmpty() else "" + val channelName = session.currentChannel()?.name.orEmpty() + _chats.update { + it.copy( + server = it.server.copy(title = serverName.ifBlank { "Server" }), + channel = it.channel.copy(title = channelName.ifBlank { "Channel" }), + ) + } + refresh() + } } private inner class Listener : ServerSession.Listener { override fun onSessionChanged() = refresh() - override fun onSystemMessage(text: String) = log(text) - override fun onOwnChat(scope: ChatScope, peerClientId: Int, peerName: String, text: String) = - log(" $text") + override fun onSystemMessage(text: String) = system(text) + + override fun onOwnChat(scope: ChatScope, peerClientId: Int, peerName: String, text: String) { + val html = ChatHtml.message(conn.selfClientId, conn.selfUniqueId, "${settings.nickname} (you)", text) + append(key(scope, peerClientId), peerName, html) + } override fun onStatus(status: String) {} + override fun onConnected() = refresh() - override fun onDisconnected(reason: String?) = refresh() - override fun onModelChanged() = 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) = - 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)) + 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) { + if (scope != ChatScope.PRIVATE) setHistory(key(scope, 0), "", htmlEntries) + } + + override fun onPrivateChatHistory(clientId: Int, nickname: String, htmlEntries: List) = + 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 plain(html: String) = Html.fromHtml(html, Html.FROM_HTML_MODE_COMPACT).toString().trim() + 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 { + val host = address.substringBefore(':').trim() + val port = address.substringAfter(':', "").trim().toIntOrNull() ?: DEFAULT_PORT + return host to port + } + } } diff --git a/android/app/src/main/java/com/ts3client/android/ui/App.kt b/android/app/src/main/java/com/ts3client/android/ui/App.kt new file mode 100644 index 0000000..4233234 --- /dev/null +++ b/android/app/src/main/java/com/ts3client/android/ui/App.kt @@ -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(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") } }, + ) + } +} diff --git a/android/app/src/main/java/com/ts3client/android/ui/ChannelTree.kt b/android/app/src/main/java/com/ts3client/android/ui/ChannelTree.kt new file mode 100644 index 0000000..0f2ab4a --- /dev/null +++ b/android/app/src/main/java/com/ts3client/android/ui/ChannelTree.kt @@ -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, currentChannel: Int): List { + val rows = ArrayList() + 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, + 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 = 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 +} diff --git a/android/app/src/main/java/com/ts3client/android/ui/ChatPane.kt b/android/app/src/main/java/com/ts3client/android/ui/ChatPane.kt new file mode 100644 index 0000000..add201b --- /dev/null +++ b/android/app/src/main/java/com/ts3client/android/ui/ChatPane.kt @@ -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, 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) + } + } +} diff --git a/android/app/src/main/java/com/ts3client/android/ui/ChatText.kt b/android/app/src/main/java/com/ts3client/android/ui/ChatText.kt new file mode 100644 index 0000000..54204b6 --- /dev/null +++ b/android/app/src/main/java/com/ts3client/android/ui/ChatText.kt @@ -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", +) diff --git a/android/app/src/main/java/com/ts3client/android/ui/ConnectScreen.kt b/android/app/src/main/java/com/ts3client/android/ui/ConnectScreen.kt new file mode 100644 index 0000000..c692ae5 --- /dev/null +++ b/android/app/src/main/java/com/ts3client/android/ui/ConnectScreen.kt @@ -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), + ) +} diff --git a/android/app/src/main/java/com/ts3client/android/ui/ServerScreen.kt b/android/app/src/main/java/com/ts3client/android/ui/ServerScreen.kt new file mode 100644 index 0000000..c1dd750 --- /dev/null +++ b/android/app/src/main/java/com/ts3client/android/ui/ServerScreen.kt @@ -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( + 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.Channel) } + val conversation = chats[chatKey] ?: chats.channel + var clientSheet by remember { mutableStateOf(null) } + var channelSheet by remember { mutableStateOf(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) + } + } +} diff --git a/android/app/src/main/java/com/ts3client/android/ui/SettingsScreen.kt b/android/app/src/main/java/com/ts3client/android/ui/SettingsScreen.kt new file mode 100644 index 0000000..6e58555 --- /dev/null +++ b/android/app/src/main/java/com/ts3client/android/ui/SettingsScreen.kt @@ -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, + 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) }) + } +} diff --git a/android/app/src/main/java/com/ts3client/android/ui/Sheets.kt b/android/app/src/main/java/com/ts3client/android/ui/Sheets.kt new file mode 100644 index 0000000..b2faf46 --- /dev/null +++ b/android/app/src/main/java/com/ts3client/android/ui/Sheets.kt @@ -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") } }, + ) +} diff --git a/android/app/src/main/java/com/ts3client/android/ui/Theme.kt b/android/app/src/main/java/com/ts3client/android/ui/Theme.kt new file mode 100644 index 0000000..0fcea09 --- /dev/null +++ b/android/app/src/main/java/com/ts3client/android/ui/Theme.kt @@ -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) +} diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index c35f5da..c57c290 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -12,6 +12,7 @@ oboe = "1.11.0" androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" } androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" } 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-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" } androidx-lifecycle-service = { module = "androidx.lifecycle:lifecycle-service", version.ref = "lifecycle" }