Notify of pokes and private messages on Android, with TeamSpeak's sounds
While the app is out of sight, a poke or a private message posts a notification; a message's one gathers the conversation, takes a reply inline and opens its chat. The notifications are silent, as the sound pack already plays TeamSpeak's own sound for either. The build bundles the sound packs of the TeamSpeak 3 client found next to the checkout (or at ts3.client.dir / TS3_CLIENT_DIR), unpacked into the profile once per app version. Sound Settings picks the pack and its volume, and speech packs talk through the phone's text-to-speech. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,47 @@ plugins {
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies the sound packs of an installed TeamSpeak 3 client into the app's assets, as the
|
||||
* desktop client borrows them from the installation. They are TeamSpeak's and not in the
|
||||
* repository; a build without the client ships no packs.
|
||||
*/
|
||||
abstract class BundleSoundPacks : DefaultTask() {
|
||||
@get:InputFiles
|
||||
@get:PathSensitive(PathSensitivity.RELATIVE)
|
||||
abstract val packs: DirectoryProperty
|
||||
|
||||
@get:OutputDirectory
|
||||
abstract val assets: DirectoryProperty
|
||||
|
||||
@TaskAction
|
||||
fun copy() {
|
||||
val out = assets.get().asFile
|
||||
out.deleteRecursively()
|
||||
val source = packs.get().asFile
|
||||
if (!source.isDirectory) {
|
||||
logger.warn("No TeamSpeak 3 sound packs at $source; the app ships without sounds")
|
||||
return
|
||||
}
|
||||
source.copyRecursively(File(out, "sound"))
|
||||
}
|
||||
}
|
||||
|
||||
val ts3ClientDir: String = providers.gradleProperty("ts3.client.dir")
|
||||
.orElse(providers.environmentVariable("TS3_CLIENT_DIR"))
|
||||
.getOrElse(rootDir.resolve("../TeamSpeak3-Client-linux_amd64").path)
|
||||
|
||||
val bundleSoundPacks = tasks.register<BundleSoundPacks>("bundleSoundPacks") {
|
||||
packs.set(File(ts3ClientDir, "sound"))
|
||||
assets.set(layout.buildDirectory.dir("generated/soundPacks"))
|
||||
}
|
||||
|
||||
androidComponents {
|
||||
onVariants { variant ->
|
||||
variant.sources.assets?.addGeneratedSourceDirectory(bundleSoundPacks, BundleSoundPacks::assets)
|
||||
}
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.ts3client.android"
|
||||
compileSdk = 37
|
||||
|
||||
@@ -11,6 +11,13 @@
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||
|
||||
<!-- Speech sound packs talk through the phone's text-to-speech engine. -->
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.TTS_SERVICE" />
|
||||
</intent>
|
||||
</queries>
|
||||
|
||||
<application
|
||||
android:name=".Ts3Application"
|
||||
android:label="@string/app_name"
|
||||
@@ -19,6 +26,7 @@
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
@@ -30,5 +38,9 @@
|
||||
android:name=".ConnectionService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="mediaPlayback|microphone" />
|
||||
|
||||
<receiver
|
||||
android:name=".ReplyReceiver"
|
||||
android:exported="false" />
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.ts3client.android
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
@@ -8,12 +9,36 @@ import com.ts3client.android.ui.App
|
||||
import com.ts3client.android.ui.Ts3Theme
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
private val sessions get() = (application as Ts3Application).sessions
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
val sessions = (application as Ts3Application).sessions
|
||||
if (savedInstanceState == null) openRequestedChat(intent)
|
||||
setContent {
|
||||
Ts3Theme { App(sessions) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
openRequestedChat(intent)
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
sessions.foreground = true
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
sessions.foreground = false
|
||||
super.onStop()
|
||||
}
|
||||
|
||||
/** A private message notification opens its conversation. */
|
||||
private fun openRequestedChat(intent: Intent) {
|
||||
val clientId = intent.getIntExtra(MessageNotifier.EXTRA_CHAT, -1)
|
||||
if (clientId >= 0) sessions.requestChat(clientId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
package com.ts3client.android
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Person
|
||||
import android.app.RemoteInput
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
|
||||
/**
|
||||
* Tells the user about pokes and private messages while the app is out of sight. The
|
||||
* notifications themselves are silent: the sound pack already plays TeamSpeak's own sound
|
||||
* for either, as it does in the app.
|
||||
*/
|
||||
class MessageNotifier(private val context: Context) {
|
||||
|
||||
private val manager = context.getSystemService(NotificationManager::class.java)
|
||||
private val me = Person.Builder().setName("You").build()
|
||||
|
||||
/** What each private conversation's notification shows, by the peer's client id. */
|
||||
private val conversations = HashMap<Int, Conversation>()
|
||||
|
||||
private class Conversation(val peer: Person, val messages: MutableList<Notification.MessagingStyle.Message>)
|
||||
|
||||
init {
|
||||
manager.createNotificationChannels(listOf(
|
||||
channel(MESSAGES, R.string.messages_channel),
|
||||
channel(POKES, R.string.pokes_channel),
|
||||
))
|
||||
}
|
||||
|
||||
private fun channel(id: String, name: Int) =
|
||||
NotificationChannel(id, context.getString(name), NotificationManager.IMPORTANCE_HIGH).apply {
|
||||
setSound(null, null)
|
||||
enableVibration(true)
|
||||
}
|
||||
|
||||
fun poke(from: String, message: String) {
|
||||
val notification = Notification.Builder(context, POKES)
|
||||
.setSmallIcon(android.R.drawable.stat_notify_chat)
|
||||
.setContentTitle("$from poked you")
|
||||
.setContentText(message.ifBlank { null })
|
||||
.setCategory(Notification.CATEGORY_MESSAGE)
|
||||
.setContentIntent(open(null))
|
||||
.setAutoCancel(true)
|
||||
.build()
|
||||
manager.notify(POKE_TAG, from.hashCode(), notification)
|
||||
}
|
||||
|
||||
fun clearPokes() {
|
||||
for (active in manager.activeNotifications) {
|
||||
if (active.tag == POKE_TAG) manager.cancel(POKE_TAG, active.id)
|
||||
}
|
||||
}
|
||||
|
||||
/** A message the peer sent, which starts the conversation's notification if it had none. */
|
||||
@Synchronized
|
||||
fun received(clientId: Int, from: String, text: String) {
|
||||
val conversation = conversations[clientId]?.takeIf { it.peer.name == from }
|
||||
?: Conversation(Person.Builder().setName(from).setKey("client:$clientId").build(), ArrayList())
|
||||
conversations[clientId] = conversation
|
||||
add(clientId, conversation, text, conversation.peer, alert = true)
|
||||
}
|
||||
|
||||
/** Our own reply, shown in a conversation already notified of; answers the reply action too. */
|
||||
@Synchronized
|
||||
fun sent(clientId: Int, text: String) {
|
||||
val conversation = conversations[clientId] ?: return
|
||||
add(clientId, conversation, text, me, alert = false)
|
||||
}
|
||||
|
||||
private fun add(clientId: Int, conversation: Conversation, text: String, sender: Person, alert: Boolean) {
|
||||
conversation.messages += Notification.MessagingStyle.Message(text, System.currentTimeMillis(), sender)
|
||||
if (conversation.messages.size > MAX_MESSAGES) conversation.messages.removeAt(0)
|
||||
val style = Notification.MessagingStyle(me)
|
||||
conversation.messages.forEach(style::addMessage)
|
||||
val notification = Notification.Builder(context, MESSAGES)
|
||||
.setSmallIcon(android.R.drawable.stat_notify_chat)
|
||||
.setStyle(style)
|
||||
.setCategory(Notification.CATEGORY_MESSAGE)
|
||||
.setContentIntent(open(clientId))
|
||||
.setOnlyAlertOnce(!alert)
|
||||
.setAutoCancel(true)
|
||||
.addAction(replyAction(clientId))
|
||||
.build()
|
||||
manager.notify(MESSAGE_TAG, clientId, notification)
|
||||
}
|
||||
|
||||
/** The conversation came on screen, or ended with the connection. */
|
||||
@Synchronized
|
||||
fun clear(clientId: Int) {
|
||||
conversations.remove(clientId)
|
||||
manager.cancel(MESSAGE_TAG, clientId)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun clearAll() {
|
||||
conversations.keys.toList().forEach(::clear)
|
||||
clearPokes()
|
||||
}
|
||||
|
||||
private fun open(clientId: Int?): PendingIntent {
|
||||
val intent = Intent(context, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||
if (clientId != null) intent.putExtra(EXTRA_CHAT, clientId)
|
||||
return PendingIntent.getActivity(context, clientId ?: -1, intent,
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
|
||||
}
|
||||
|
||||
private fun replyAction(clientId: Int): Notification.Action {
|
||||
val intent = Intent(context, ReplyReceiver::class.java).putExtra(EXTRA_CHAT, clientId)
|
||||
// Mutable: the system fills the typed reply into it.
|
||||
val pending = PendingIntent.getBroadcast(context, clientId, intent,
|
||||
PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
|
||||
return Notification.Action.Builder(null, "Reply", pending)
|
||||
.addRemoteInput(RemoteInput.Builder(KEY_REPLY).setLabel("Message").build())
|
||||
.setSemanticAction(Notification.Action.SEMANTIC_ACTION_REPLY)
|
||||
.build()
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** The client id of the private chat a notification opens or replies to. */
|
||||
const val EXTRA_CHAT = "com.ts3client.android.CHAT"
|
||||
const val KEY_REPLY = "reply"
|
||||
private const val MESSAGES = "messages"
|
||||
private const val POKES = "pokes"
|
||||
private const val MESSAGE_TAG = "message"
|
||||
private const val POKE_TAG = "poke"
|
||||
private const val MAX_MESSAGES = 25
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.ts3client.android
|
||||
|
||||
import android.app.RemoteInput
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
|
||||
/** Sends what the user typed into a private message notification. */
|
||||
class ReplyReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val clientId = intent.getIntExtra(MessageNotifier.EXTRA_CHAT, -1)
|
||||
val text = RemoteInput.getResultsFromIntent(intent)?.getCharSequence(MessageNotifier.KEY_REPLY)?.toString()
|
||||
if (clientId < 0 || text.isNullOrBlank()) return
|
||||
(context.applicationContext as Ts3Application).sessions.reply(clientId, text)
|
||||
}
|
||||
}
|
||||
@@ -21,9 +21,11 @@ import com.ts3client.net.ChannelNode
|
||||
import com.ts3client.net.ConnectionListener.ChatScope
|
||||
import com.ts3client.session.ServerSession
|
||||
import com.ts3client.sound.SoundNotifier
|
||||
import com.ts3client.sound.SoundPack
|
||||
import com.ts3client.teamspeak.SyncItem
|
||||
import com.ts3client.teamspeak.TeamSpeakImporter
|
||||
import com.ts3client.teamspeak.TeamSpeakSettingsDb
|
||||
import com.ts3client.text.BBCode
|
||||
import com.ts3client.text.ChatHtml
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -73,8 +75,9 @@ class SessionController(private val context: Context) {
|
||||
private val bookmarkStore = Bookmarks.load()
|
||||
private val identities = IdentityStore.load(settings)
|
||||
private val audio = AndroidAudioBackend(context)
|
||||
private val soundPlayer = audio.createSoundPlayer(settings)
|
||||
private val soundPlayer = SpeakingSoundPlayer(context, audio.createSoundPlayer(settings))
|
||||
private val sounds = SoundNotifier(settings).also { it.setPlayer(soundPlayer) }
|
||||
private val notifier = MessageNotifier(context)
|
||||
private val contactStore = ContactStore.load()
|
||||
private val session = ServerSession(
|
||||
settings, identities, audio, sounds, contactStore, Listener()
|
||||
@@ -111,8 +114,21 @@ class SessionController(private val context: Context) {
|
||||
set(value) {
|
||||
field = value
|
||||
if (value != null) markRead(value)
|
||||
if (value is ChatKey.Private) notifier.clear(value.clientId)
|
||||
}
|
||||
|
||||
/** Whether the app is on screen; pokes and private messages are notified of while it is not. */
|
||||
@Volatile
|
||||
var foreground = false
|
||||
set(value) {
|
||||
field = value
|
||||
if (value) notifier.clearPokes()
|
||||
}
|
||||
|
||||
/** A private chat a notification asked to show, until the UI took it up. */
|
||||
private val _chatRequest = MutableStateFlow<ChatKey.Private?>(null)
|
||||
val chatRequest: StateFlow<ChatKey.Private?> = _chatRequest
|
||||
|
||||
private val lineIds = AtomicLong()
|
||||
|
||||
/** Tree snapshots are taken one at a time, at most once per [TREE_FRAME_MS]. */
|
||||
@@ -265,6 +281,25 @@ class SessionController(private val context: Context) {
|
||||
withContext(Dispatchers.IO) { importer.importSelected(all, chosen) }
|
||||
.also { _bookmarks.value = bookmarkStore.all().toList() }
|
||||
|
||||
// ---- sound packs ----
|
||||
|
||||
private val _soundPacks = MutableStateFlow(sounds.availablePacks())
|
||||
val soundPacks: StateFlow<List<SoundPack>> = _soundPacks
|
||||
|
||||
val soundPack: SoundPack? get() = sounds.pack()
|
||||
|
||||
fun setSoundPack(pack: SoundPack) {
|
||||
sounds.setPack(pack)
|
||||
settings.save()
|
||||
}
|
||||
|
||||
/** Unpacks the packs the app ships with, when this version has not yet. */
|
||||
fun installSoundPacks() {
|
||||
if (!SoundPackInstaller(context).install()) return
|
||||
sounds.reload()
|
||||
_soundPacks.value = sounds.availablePacks()
|
||||
}
|
||||
|
||||
// ---- the local client ----
|
||||
|
||||
fun hasMicPermission() =
|
||||
@@ -376,6 +411,19 @@ class SessionController(private val context: Context) {
|
||||
if (_chats.value[key]?.unread == true) _chats.update { chats -> chats.update(key) { it.copy(unread = false) } }
|
||||
}
|
||||
|
||||
fun requestChat(clientId: Int) {
|
||||
_chatRequest.value = ChatKey.Private(clientId)
|
||||
}
|
||||
|
||||
fun chatRequestHandled() {
|
||||
_chatRequest.value = null
|
||||
}
|
||||
|
||||
/** Answers a private message from its notification. */
|
||||
fun reply(clientId: Int, text: String) {
|
||||
if (session.isConnected) send(ChatKey.Private(clientId), text) else notifier.clear(clientId)
|
||||
}
|
||||
|
||||
private fun append(key: ChatKey, title: String, html: String) {
|
||||
if (html.isEmpty()) return
|
||||
val line = ChatLine(lineIds.incrementAndGet(), html)
|
||||
@@ -442,6 +490,7 @@ class SessionController(private val context: Context) {
|
||||
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)
|
||||
if (scope == ChatScope.PRIVATE) notifier.sent(peerClientId, BBCode.toPlain(text))
|
||||
}
|
||||
|
||||
override fun onStatus(status: String) {}
|
||||
@@ -453,6 +502,7 @@ class SessionController(private val context: Context) {
|
||||
|
||||
override fun onDisconnected(reason: String?) {
|
||||
network.onDisconnected()
|
||||
notifier.clearAll()
|
||||
_chats.update { it.copy(private = emptyList()) }
|
||||
scheduleTree(structure = true)
|
||||
refresh()
|
||||
@@ -462,8 +512,10 @@ class SessionController(private val context: Context) {
|
||||
override fun onInfoUpdated() {}
|
||||
override fun onIconsUpdated() {}
|
||||
|
||||
override fun onChat(scope: ChatScope, fromClientId: Int, fromUniqueId: String?, fromName: String, message: String) =
|
||||
override fun onChat(scope: ChatScope, fromClientId: Int, fromUniqueId: String?, fromName: String, message: String) {
|
||||
append(key(scope, fromClientId), fromName, ChatHtml.message(fromClientId, fromUniqueId, fromName, message))
|
||||
if (scope == ChatScope.PRIVATE && !foreground) notifier.received(fromClientId, fromName, BBCode.toPlain(message))
|
||||
}
|
||||
|
||||
override fun onChatHistory(scope: ChatScope, htmlEntries: List<String>) {
|
||||
if (scope != ChatScope.PRIVATE) setHistory(key(scope, 0), "", htmlEntries)
|
||||
@@ -483,6 +535,7 @@ class SessionController(private val context: Context) {
|
||||
|
||||
override fun onPoke(fromName: String, message: String) {
|
||||
_events.tryEmit(SessionEvent.Poke(fromName, message))
|
||||
if (!foreground) notifier.poke(fromName, BBCode.toPlain(message))
|
||||
}
|
||||
|
||||
override fun onServerLog(message: String) = append(ChatKey.Server, "", ChatHtml.serverLog(message))
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.ts3client.android
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.AssetManager
|
||||
import com.ts3client.config.AppDirs
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Unpacks the sound packs bundled in the APK into the profile's `sound` folder, where
|
||||
* [com.ts3client.sound.SoundNotifier] finds packs, as they are read as plain files.
|
||||
* It happens once per installed version of the app; packs of other origins in the
|
||||
* folder are left alone.
|
||||
*/
|
||||
class SoundPackInstaller(private val context: Context) {
|
||||
|
||||
private val root = File(AppDirs.profile(), ASSET_DIR)
|
||||
private val marker = File(root, ".bundled")
|
||||
/** Where a pack is unpacked first, outside [root] so it is never seen half written. */
|
||||
private val staging = File(AppDirs.profile(), ".sound-staging")
|
||||
|
||||
/** @return whether the packs were (re)installed, so the list of them has to be read again */
|
||||
fun install(): Boolean {
|
||||
val version = context.packageManager.getPackageInfo(context.packageName, 0).lastUpdateTime.toString()
|
||||
if (marker.isFile && marker.readText() == version) return false
|
||||
val assets = context.assets
|
||||
for (pack in assets.list(ASSET_DIR).orEmpty()) {
|
||||
staging.deleteRecursively()
|
||||
copy(assets, "$ASSET_DIR/$pack", staging)
|
||||
val target = File(root, pack)
|
||||
target.deleteRecursively()
|
||||
root.mkdirs()
|
||||
check(staging.renameTo(target)) { "Cannot install sound pack $pack" }
|
||||
}
|
||||
root.mkdirs()
|
||||
marker.writeText(version)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun copy(assets: AssetManager, path: String, to: File) {
|
||||
val children = assets.list(path).orEmpty()
|
||||
if (children.isEmpty()) {
|
||||
to.parentFile?.mkdirs()
|
||||
assets.open(path).use { input -> to.outputStream().use { input.copyTo(it) } }
|
||||
return
|
||||
}
|
||||
to.mkdirs()
|
||||
for (child in children) copy(assets, "$path/$child", File(to, child))
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val ASSET_DIR = "sound"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.ts3client.android
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.speech.tts.TextToSpeech
|
||||
import com.ts3client.sound.SoundPlayer
|
||||
|
||||
/**
|
||||
* Adds the phone's text-to-speech to a wave player, for packs that speak their
|
||||
* notifications. Sentences asked for before the engine is up are dropped: they
|
||||
* would come out late, past what they announce.
|
||||
*/
|
||||
class SpeakingSoundPlayer(context: Context, private val waves: SoundPlayer) : SoundPlayer by waves {
|
||||
|
||||
@Volatile
|
||||
private var ready = false
|
||||
private val tts = TextToSpeech(context.applicationContext) { status -> ready = status == TextToSpeech.SUCCESS }
|
||||
|
||||
override fun canSpeak() = true
|
||||
|
||||
override fun say(text: String, volume: Double) {
|
||||
if (!ready) return
|
||||
val params = Bundle().apply { putFloat(TextToSpeech.Engine.KEY_PARAM_VOLUME, volume.toFloat()) }
|
||||
tts.speak(text, TextToSpeech.QUEUE_ADD, params, null)
|
||||
}
|
||||
|
||||
override fun shutdown() {
|
||||
tts.shutdown()
|
||||
waves.shutdown()
|
||||
}
|
||||
}
|
||||
@@ -13,5 +13,6 @@ class Ts3Application : Application() {
|
||||
// Before any store loads: there is no home directory to find them in.
|
||||
AppDirs.init(filesDir, filesDir.toPath().resolve("chats"))
|
||||
sessions = SessionController(this)
|
||||
Thread(sessions::installSoundPacks, "sound-packs").start()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -91,6 +92,14 @@ fun ServerScreen(
|
||||
var clientSheet by remember { mutableStateOf<Int?>(null) }
|
||||
var channelSheet by remember { mutableStateOf<Int?>(null) }
|
||||
|
||||
val chatRequest by vm.chatRequest.collectAsStateWithLifecycle()
|
||||
LaunchedEffect(chatRequest) {
|
||||
val key = chatRequest ?: return@LaunchedEffect
|
||||
chatKey = if (chats[key] != null) key else vm.openPrivateChat(key.clientId)
|
||||
tab = TAB_CHAT
|
||||
vm.chatRequestHandled()
|
||||
}
|
||||
|
||||
val viewing = if (tab == TAB_CHAT) conversation.key else null
|
||||
LifecycleResumeEffect(viewing) {
|
||||
vm.viewing = viewing
|
||||
|
||||
@@ -52,6 +52,8 @@ fun SoundSettingsScreen(vm: SessionController, onBack: () -> Unit) {
|
||||
val outputs by vm.outputDevices.collectAsStateWithLifecycle()
|
||||
val route by vm.route.collectAsStateWithLifecycle()
|
||||
val level by vm.micLevel.collectAsStateWithLifecycle()
|
||||
val packs by vm.soundPacks.collectAsStateWithLifecycle()
|
||||
var pack by remember { mutableStateOf(vm.soundPack?.id()) }
|
||||
LifecycleStartEffect(Unit) {
|
||||
vm.watchLevel(true)
|
||||
onStopOrDispose { vm.watchLevel(false) }
|
||||
@@ -141,6 +143,19 @@ fun SoundSettingsScreen(vm: SessionController, onBack: () -> Unit) {
|
||||
Level("Volume", settings.outputVolume.toFloat(), 0f..2f, { "${(it * 100).roundToInt()} %" }) {
|
||||
change { outputVolume = it.toDouble() }
|
||||
}
|
||||
|
||||
Section("Notification sounds")
|
||||
if (packs.isEmpty()) {
|
||||
Hint("This build of the app came without sound packs.")
|
||||
} else {
|
||||
Picker("Sound pack", packs.map { it.id() to it.name() }, pack) { id ->
|
||||
packs.firstOrNull { it.id() == id }?.let(vm::setSoundPack)
|
||||
pack = id
|
||||
}
|
||||
}
|
||||
Level("Volume", settings.soundVolume.toFloat(), 0f..1f, { "${(it * 100).roundToInt()} %" }) {
|
||||
change { soundVolume = it.toDouble() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,4 +2,6 @@
|
||||
<resources>
|
||||
<string name="app_name">TS3J</string>
|
||||
<string name="connection_channel">Connection</string>
|
||||
<string name="messages_channel">Private messages</string>
|
||||
<string name="pokes_channel">Pokes</string>
|
||||
</resources>
|
||||
|
||||
Reference in New Issue
Block a user