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:
2026-09-25 14:21:51 +00:00
parent d83ba1145b
commit 941365cab0
15 changed files with 1689 additions and 159 deletions

View File

@@ -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)

View 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)

View File

@@ -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")
}
} }
} }
} }

View File

@@ -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,
)

View File

@@ -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
}
}
} }

View 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") } },
)
}
}

View File

@@ -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
}

View 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)
}
}
}

View File

@@ -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",
)

View File

@@ -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),
)
}

View File

@@ -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)
}
}
}

View File

@@ -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) })
}
}

View 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") } },
)
}

View 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)
}

View File

@@ -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" }