Compare commits
175 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2351b174f8 | |||
| 5fd2a612b9 | |||
| 2d83eafffd | |||
| 4911542d39 | |||
| 2c1b37ee30 | |||
| cf1db61a4a | |||
| 76f6a9feed | |||
| e06bb82299 | |||
| 0ad56671af | |||
| d2de76f534 | |||
| 0ff0919db2 | |||
| bda114d2c0 | |||
| b1890cbf79 | |||
| e7d338ce28 | |||
| 8fc7be826c | |||
| 662e031b6b | |||
| 66ef62913a | |||
| f80f619a65 | |||
| 7f30a27d73 | |||
| e799218444 | |||
| 4ca5534ef4 | |||
| af0564702e | |||
| fd86d9e638 | |||
| b678805c37 | |||
| b1ad403fdf | |||
| cd02e4b2d8 | |||
| 9b1aa45611 | |||
| 9f2e5087ce | |||
| 1dda20d1b2 | |||
| 6aa4be38ee | |||
| f1e7c93594 | |||
| 4b50f37735 | |||
| 02ff63341e | |||
| a6efc0b8da | |||
| 002b1ac7ce | |||
| 75df46a03a | |||
| fedb7b4385 | |||
| 570feffc25 | |||
| 5c0a5e4bcf | |||
| e7741e8b34 | |||
| cdf81f3258 | |||
| a7aa122728 | |||
| 73a15a8cfc | |||
| 491cbcb259 | |||
| 5fa7024bcb | |||
| c8e99c8946 | |||
| 62ced89701 | |||
| 2281c125fa | |||
| 25ca586a50 | |||
| ed526897dc | |||
| 967fcadf77 | |||
| 70c6eb769c | |||
| f2c9143997 | |||
| 941365cab0 | |||
| d83ba1145b | |||
| 35d6427456 | |||
| d206b12a30 | |||
| c5a49fc7b3 | |||
| 5d67b0c1e3 | |||
| 51f19c2d03 | |||
| 85c3ae6c8d | |||
| acb3efabd8 | |||
| b6c5a772e6 | |||
| 71ac2c817c | |||
| bf07f0bf49 | |||
| f30cecf51c | |||
| ae8d1f7083 | |||
| 48a0032848 | |||
| 3cdade9ad7 | |||
| d7261c906d | |||
| 1a3a3e37c1 | |||
| a5c4ba1eb5 | |||
| f9b2f4d7cb | |||
| 8e9c461424 | |||
| 12ca585d90 | |||
| 6234ec6d97 | |||
| 0083dc8ad6 | |||
| 7cb4f47a81 | |||
| 8c735a05b8 | |||
| 67f8f46cab | |||
| 665316d515 | |||
| 5b1809cc2d | |||
| eeccb495eb | |||
| d074ffaa7a | |||
| d28bb57598 | |||
| f350b19d31 | |||
| ba5de5a8a8 | |||
| f871a88bce | |||
| 72057ac1df | |||
| 95726d9a8d | |||
| b3ad3ceda0 | |||
| 11a2a14c70 | |||
| 07aa7bd7f9 | |||
| 086b913c8e | |||
| e211ffa7b6 | |||
| 75d9606231 | |||
| cc0243a7c6 | |||
| fecb2ba8f4 | |||
| 8f385b9343 | |||
| 24f4b3813d | |||
| 94748e27ee | |||
| 7396c4ca40 | |||
| 3c389de36b | |||
| 1d61f458ae | |||
| de5f7da236 | |||
| ee0f15fb3c | |||
| b01ed43d60 | |||
| d05c04afb4 | |||
| a4057cd499 | |||
| 249430efad | |||
| 3f65e95afd | |||
| 4de3ad4cab | |||
| 03ea2c86c3 | |||
| f27cc40755 | |||
| 5fc2405d4a | |||
| c743efc3b6 | |||
| 3187608579 | |||
| 9d996f48a2 | |||
| 1fddae3f0b | |||
| 33411fcd13 | |||
| 2a73900dcc | |||
| dbb425403a | |||
| bab89f9b50 | |||
| f409f500fd | |||
| 22e0304f25 | |||
| 636090d075 | |||
| ad62f5fc92 | |||
| 62d5af7cf9 | |||
| a01c598fe6 | |||
| 02533b543d | |||
| 5a00015824 | |||
| ffe98be649 | |||
| 65f3777673 | |||
| 9101542f0a | |||
| a4a8b39410 | |||
| f480262c39 | |||
| 8e3e9401d2 | |||
| 966f3fe9b1 | |||
| b85d1e5076 | |||
| 50b71eb12e | |||
| 98719d717e | |||
| 35f10ed14c | |||
| d952ffd856 | |||
| acfe25db15 | |||
| 08e6c51e90 | |||
| acd438dd14 | |||
| 6e65797f25 | |||
| fa99cadc44 | |||
| 9ba0982f25 | |||
| 275de77c8b | |||
| 189620df7f | |||
| cb05b097a6 | |||
| bcc034e975 | |||
| f68c1e297c | |||
| 52bd180b56 | |||
| f2885d33ad | |||
| d8a56566d4 | |||
| b29f518d59 | |||
| acf0221837 | |||
| e5b607fc29 | |||
| d722377508 | |||
| b694845381 | |||
| cf5ba94092 | |||
| e5fb87a796 | |||
| de02f88f1c | |||
| 71084ea309 | |||
| 047c89404c | |||
| 75ae6b89b2 | |||
| e228dd4ad0 | |||
| 752676e863 | |||
| edc8a0ca3a | |||
| 7e4b9671fe | |||
| 3e2c06248f | |||
| 32e5ffb576 | |||
| ef95b6305d |
20
.gitea/workflows/test.yml
Normal file
20
.gitea/workflows/test.yml
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
name: test
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
submodules: true
|
||||||
|
- uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: 25
|
||||||
|
cache: maven
|
||||||
|
# Builds ts3j from the submodule along with the client; no test needs a display or libopus.
|
||||||
|
- run: mvn -B -f ts3-client/pom.xml verify
|
||||||
11
.gitignore
vendored
11
.gitignore
vendored
@@ -4,11 +4,14 @@ build/
|
|||||||
out/
|
out/
|
||||||
*.class
|
*.class
|
||||||
|
|
||||||
# The bundled proprietary TeamSpeak 3 client (reference binary, not our source)
|
# Bundled proprietary TeamSpeak clients (reference binaries, not our source)
|
||||||
/TeamSpeak3-Client-linux_amd64/
|
/TeamSpeak3-Client-linux_amd64/
|
||||||
|
/ts6/
|
||||||
|
/re-android/
|
||||||
|
|
||||||
# Packages
|
# Packages
|
||||||
*.jar
|
*.jar
|
||||||
|
!android/gradle/wrapper/gradle-wrapper.jar
|
||||||
*.war
|
*.war
|
||||||
*.zip
|
*.zip
|
||||||
*.tar.gz
|
*.tar.gz
|
||||||
@@ -34,3 +37,9 @@ release.properties
|
|||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
# Android / Gradle
|
||||||
|
android/.gradle/
|
||||||
|
android/.kotlin/
|
||||||
|
android/**/.cxx/
|
||||||
|
android/local.properties
|
||||||
|
|||||||
122
android/app/build.gradle.kts
Normal file
122
android/app/build.gradle.kts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.application)
|
||||||
|
alias(libs.plugins.kotlin.compose)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Puts the sound packs of an installed TeamSpeak 3 client into the app's assets, as the
|
||||||
|
* desktop client borrows them from the installation, with the sounds encoded as Opus
|
||||||
|
* (a fifteenth of the WAVs). 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
|
||||||
|
|
||||||
|
/** The desktop code whose libopus does the encoding. */
|
||||||
|
@get:Classpath
|
||||||
|
abstract val encoder: ConfigurableFileCollection
|
||||||
|
|
||||||
|
@get:OutputDirectory
|
||||||
|
abstract val assets: DirectoryProperty
|
||||||
|
|
||||||
|
@get:Inject
|
||||||
|
abstract val exec: ExecOperations
|
||||||
|
|
||||||
|
@TaskAction
|
||||||
|
fun encode() {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
exec.javaexec {
|
||||||
|
classpath = encoder
|
||||||
|
mainClass.set("com.ts3client.audio.desktop.EncodeSoundPacks")
|
||||||
|
jvmArgs("--enable-native-access=ALL-UNNAMED")
|
||||||
|
args(source.path, File(out, "sound").path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val soundPackEncoder: Configuration by configurations.creating {
|
||||||
|
isCanBeConsumed = false
|
||||||
|
attributes {
|
||||||
|
attribute(Usage.USAGE_ATTRIBUTE, objects.named(Usage.JAVA_RUNTIME))
|
||||||
|
attribute(LibraryElements.LIBRARY_ELEMENTS_ATTRIBUTE, objects.named(LibraryElements.JAR))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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"))
|
||||||
|
encoder.from(soundPackEncoder)
|
||||||
|
assets.set(layout.buildDirectory.dir("generated/soundPacks"))
|
||||||
|
}
|
||||||
|
|
||||||
|
androidComponents {
|
||||||
|
onVariants { variant ->
|
||||||
|
variant.sources.assets?.addGeneratedSourceDirectory(bundleSoundPacks, BundleSoundPacks::assets)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.ts3client.android"
|
||||||
|
compileSdk = 37
|
||||||
|
ndkVersion = "30.0.16248370"
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "com.ts3client.android"
|
||||||
|
minSdk = 33
|
||||||
|
targetSdk = 36
|
||||||
|
versionCode = 1
|
||||||
|
versionName = "0.1.0"
|
||||||
|
ndk {
|
||||||
|
abiFilters += listOf("arm64-v8a", "x86_64")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
isMinifyEnabled = true
|
||||||
|
isShrinkResources = true
|
||||||
|
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildFeatures {
|
||||||
|
compose = true
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
|
||||||
|
packaging {
|
||||||
|
resources {
|
||||||
|
// BouncyCastle ships Java 9+ module metadata Android has no use for.
|
||||||
|
excludes += "META-INF/versions/9/OSGI-INF/MANIFEST.MF"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(project(":audio"))
|
||||||
|
soundPackEncoder(project(":desktop"))
|
||||||
|
|
||||||
|
implementation(platform(libs.androidx.compose.bom))
|
||||||
|
implementation(libs.androidx.compose.ui)
|
||||||
|
implementation(libs.androidx.compose.material3)
|
||||||
|
implementation(libs.androidx.compose.material.icons)
|
||||||
|
implementation(libs.androidx.activity.compose)
|
||||||
|
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||||
|
implementation(libs.androidx.lifecycle.service)
|
||||||
|
implementation(libs.androidsvg)
|
||||||
|
}
|
||||||
10
android/app/proguard-rules.pro
vendored
Normal file
10
android/app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# ts3j looks up SRV records through JNDI only where it exists; Android lacks it.
|
||||||
|
-dontwarn javax.naming.**
|
||||||
|
|
||||||
|
# Reached from native code by name.
|
||||||
|
-keepclasseswithmembernames class com.ts3client.android.audio.** {
|
||||||
|
native <methods>;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ts3j builds protocol handlers and packet bodies reflectively.
|
||||||
|
-keep class com.github.manevolent.ts3j.** { *; }
|
||||||
47
android/app/src/main/AndroidManifest.xml
Normal file
47
android/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||||
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<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"
|
||||||
|
android:theme="@android:style/Theme.Material.NoActionBar">
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:launchMode="singleTop"
|
||||||
|
android:windowSoftInputMode="adjustResize">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".ConnectionService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="mediaPlayback|microphone" />
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".ReplyReceiver"
|
||||||
|
android:exported="false" />
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package com.ts3client.android
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import com.ts3client.android.audio.AudioRoute
|
||||||
|
|
||||||
|
/** Where the Android-only audio choices are kept; the rest live in the shared settings. */
|
||||||
|
class AudioRoutePrefs(context: Context) {
|
||||||
|
|
||||||
|
private val prefs = context.getSharedPreferences("audio", Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
fun load(): AudioRoute = AudioRoute(
|
||||||
|
enumOr(prefs.getString(INPUT_PRESET, null), AudioRoute.DEFAULT.inputPreset()),
|
||||||
|
enumOr(prefs.getString(USAGE, null), AudioRoute.DEFAULT.usage()),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun save(route: AudioRoute) {
|
||||||
|
prefs.edit()
|
||||||
|
.putString(INPUT_PRESET, route.inputPreset().name)
|
||||||
|
.putString(USAGE, route.usage().name)
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
private inline fun <reified E : Enum<E>> enumOr(name: String?, default: E): E =
|
||||||
|
enumValues<E>().firstOrNull { it.name == name } ?: default
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val INPUT_PRESET = "inputPreset"
|
||||||
|
const val USAGE = "usage"
|
||||||
|
}
|
||||||
|
}
|
||||||
88
android/app/src/main/java/com/ts3client/android/Chats.kt
Normal file
88
android/app/src/main/java/com/ts3client/android/Chats.kt
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
package com.ts3client.android
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import com.ts3client.text.HtmlText
|
||||||
|
import com.ts3client.text.TextQuery
|
||||||
|
|
||||||
|
/** 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) {
|
||||||
|
/** The text to find in. */
|
||||||
|
val plain: String by lazy { HtmlText.plain(html) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@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)
|
||||||
|
|
||||||
|
/** The [nth] match of the find bar's query in the line [line]. */
|
||||||
|
data class ChatMatch(val line: Long, val nth: Int)
|
||||||
|
|
||||||
|
/** Every match of [query] in [lines], oldest first; past [limit], only the newest. */
|
||||||
|
fun findIn(lines: List<ChatLine>, query: TextQuery, limit: Int): List<ChatMatch> {
|
||||||
|
if (query.isEmpty) return emptyList()
|
||||||
|
val out = ArrayList<ChatMatch>()
|
||||||
|
for (line in lines) {
|
||||||
|
val m = query.matcher(line.plain) ?: return emptyList()
|
||||||
|
var n = 0
|
||||||
|
while (m.find()) {
|
||||||
|
if (m.end() > m.start()) out += ChatMatch(line.id, n++)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return if (out.size > limit) out.subList(out.size - limit, out.size) else out
|
||||||
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
package com.ts3client.android
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.content.pm.ServiceInfo
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.drawable.Icon
|
||||||
|
import android.net.wifi.WifiManager
|
||||||
|
import android.os.PowerManager
|
||||||
|
import androidx.lifecycle.LifecycleService
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import com.ts3client.config.Settings
|
||||||
|
import com.ts3client.net.ClientStatus
|
||||||
|
import com.ts3client.session.SelfState
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.conflate
|
||||||
|
import kotlinx.coroutines.flow.drop
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps the process in the foreground while connected, so the connection and its audio
|
||||||
|
* survive the app leaving the screen, and offers the essentials in its notification.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class ConnectionService : LifecycleService() {
|
||||||
|
|
||||||
|
private lateinit var sessions: SessionController
|
||||||
|
private lateinit var wifiLock: WifiManager.WifiLock
|
||||||
|
private lateinit var wakeLock: PowerManager.WakeLock
|
||||||
|
private lateinit var overlay: PttOverlay
|
||||||
|
private var foregroundTypes = 0
|
||||||
|
private var shown: ServerController? = null
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
sessions = (application as Ts3Application).sessions
|
||||||
|
getSystemService(NotificationManager::class.java).createNotificationChannel(
|
||||||
|
NotificationChannel(CHANNEL, getString(R.string.connection_channel), NotificationManager.IMPORTANCE_LOW)
|
||||||
|
)
|
||||||
|
// Voice goes out every 20 ms; Wi-Fi power saving and a sleeping CPU would stretch that.
|
||||||
|
wifiLock = getSystemService(WifiManager::class.java)
|
||||||
|
.createWifiLock(WifiManager.WIFI_MODE_FULL_LOW_LATENCY, "ts3j:voice")
|
||||||
|
.apply { setReferenceCounted(false); acquire() }
|
||||||
|
wakeLock = getSystemService(PowerManager::class.java)
|
||||||
|
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "ts3j:connection")
|
||||||
|
.apply { setReferenceCounted(false); acquire() }
|
||||||
|
|
||||||
|
show(sessions.current.value?.state?.value ?: SessionState(), capturing = false)
|
||||||
|
lifecycleScope.launch {
|
||||||
|
// The server count shows in the notification too.
|
||||||
|
combine(shownServer(), sessions.micOwner, sessions.servers) { shown, owner, _ -> shown to (owner != null) }
|
||||||
|
.conflate()
|
||||||
|
.collect { (shown, capturing) ->
|
||||||
|
if (shown != null) {
|
||||||
|
this@ConnectionService.shown = shown.first
|
||||||
|
show(shown.second, capturing)
|
||||||
|
// Talking flips the icon often, and Android drops updates that come too fast.
|
||||||
|
delay(MIN_UPDATE_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lifecycleScope.launch {
|
||||||
|
sessions.current.flatMapLatest { it?.state ?: flowOf(null) }.collectLatest { state ->
|
||||||
|
if (state == null) {
|
||||||
|
// A reconnect leaves the server briefly; only stop once it stays gone.
|
||||||
|
delay(STOP_DELAY_MS)
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lifecycleScope.launch {
|
||||||
|
sessions.icons.active.drop(1).collect { shown?.let { show(it.state.value, sessions.micOwner.value != null) } }
|
||||||
|
}
|
||||||
|
|
||||||
|
overlay = PttOverlay(this, sessions.icons, sessions::setPushToTalk)
|
||||||
|
lifecycleScope.launch {
|
||||||
|
val owner = sessions.micOwner.flatMapLatest { server ->
|
||||||
|
server?.let { combine(it.state, it.tree) { state, tree -> state to tree } } ?: flowOf(null)
|
||||||
|
}
|
||||||
|
combine(owner, sessions.onScreen, sessions.pttOverlay) { held, onScreen, enabled ->
|
||||||
|
val (state, tree) = held ?: return@combine null
|
||||||
|
val wanted = enabled && !onScreen && state.connected && state.micActive &&
|
||||||
|
state.inputMode == Settings.InputMode.PUSH_TO_TALK
|
||||||
|
val status = tree.client(state.selfClientId)?.status
|
||||||
|
when {
|
||||||
|
!wanted -> null
|
||||||
|
state.micMuted -> PttOverlay.Look.MUTED
|
||||||
|
status == ClientStatus.TALKING || status == ClientStatus.COMMANDER_TALKING -> PttOverlay.Look.TALKING
|
||||||
|
else -> PttOverlay.Look.IDLE
|
||||||
|
}
|
||||||
|
}.distinctUntilChanged().collect { look ->
|
||||||
|
if (look == null) overlay.hide() else overlay.show(look)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The server the notification speaks for, as the desktop tray icon does: the one
|
||||||
|
* holding the microphone, or the visible one when nobody is capturing.
|
||||||
|
*/
|
||||||
|
private fun shownServer(): Flow<Pair<ServerController, SessionState>?> =
|
||||||
|
combine(sessions.current, sessions.micOwner) { current, owner -> current to owner }
|
||||||
|
.flatMapLatest { (current, owner) ->
|
||||||
|
combine(current?.state ?: flowOf(null), owner?.state ?: flowOf(null)) { currentState, ownerState ->
|
||||||
|
when {
|
||||||
|
owner != null && ownerState?.connected == true -> owner to ownerState
|
||||||
|
current != null && currentState != null -> current to currentState
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
super.onStartCommand(intent, flags, startId)
|
||||||
|
val server = shown ?: sessions.current.value ?: return START_NOT_STICKY
|
||||||
|
val state = server.state.value
|
||||||
|
when (intent?.action) {
|
||||||
|
ACTION_TOGGLE_MUTE -> server.setMicMuted(!state.micMuted)
|
||||||
|
ACTION_TOGGLE_DEAFEN -> server.setDeafened(!state.deafened)
|
||||||
|
ACTION_DISCONNECT -> server.disconnect()
|
||||||
|
}
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The user swiped the app away: they are done with it, connection included. Leaving
|
||||||
|
* properly spares the server a ghost that would hold our identity until it times out.
|
||||||
|
*/
|
||||||
|
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||||
|
super.onTaskRemoved(rootIntent)
|
||||||
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
|
sessions.leave()
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
overlay.hide()
|
||||||
|
wifiLock.release()
|
||||||
|
wakeLock.release()
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Posts the notification, re-declaring what the service uses when that changed. */
|
||||||
|
private fun show(state: SessionState, capturing: Boolean) {
|
||||||
|
val types = ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK or
|
||||||
|
if (capturing && hasMicPermission()) ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE else 0
|
||||||
|
val notification = notification(state)
|
||||||
|
if (types != foregroundTypes) {
|
||||||
|
startForeground(NOTIFICATION_ID, notification, types)
|
||||||
|
foregroundTypes = types
|
||||||
|
} else {
|
||||||
|
getSystemService(NotificationManager::class.java).notify(NOTIFICATION_ID, notification)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasMicPermission() =
|
||||||
|
checkSelfPermission(Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED
|
||||||
|
|
||||||
|
private fun notification(state: SessionState): Notification {
|
||||||
|
val open = PendingIntent.getActivity(
|
||||||
|
this, 0, Intent(this, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
val others = sessions.servers.value.size - 1
|
||||||
|
return Notification.Builder(this, CHANNEL)
|
||||||
|
.setSmallIcon(statusIcon(state.self))
|
||||||
|
.setLargeIcon(largeIcon(state.self))
|
||||||
|
.setContentTitle(if (others > 0) "${state.title} +$others" else state.title)
|
||||||
|
.setContentText(state.status)
|
||||||
|
.setContentIntent(open)
|
||||||
|
.setOngoing(true)
|
||||||
|
.addAction(action(ACTION_TOGGLE_MUTE, if (state.micMuted) "Unmute" else "Mute"))
|
||||||
|
.addAction(action(ACTION_TOGGLE_DEAFEN, if (state.deafened) "Undeafen" else "Deafen"))
|
||||||
|
.addAction(action(ACTION_DISCONNECT, "Disconnect"))
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The status bar shows only the shape of an icon, which pack art loses, so it is
|
||||||
|
* drawn from the Material pack whatever pack is chosen.
|
||||||
|
*/
|
||||||
|
private fun statusIcon(self: SelfState): Icon {
|
||||||
|
val key = self.iconKey() ?: return Icon.createWithResource(this, android.R.drawable.ic_btn_speak_now)
|
||||||
|
val size = (STATUS_ICON_DP * resources.displayMetrics.density).roundToInt()
|
||||||
|
val bitmap = sessions.icons.renderMaterial(key, size, Color.WHITE)
|
||||||
|
?: return Icon.createWithResource(this, android.R.drawable.ic_btn_speak_now)
|
||||||
|
return Icon.createWithBitmap(bitmap)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The expanded notification has room for the chosen pack's own art, as the tree draws it. */
|
||||||
|
private fun largeIcon(self: SelfState): Icon? {
|
||||||
|
val key = self.iconKey() ?: return null
|
||||||
|
val size = (LARGE_ICON_DP * resources.displayMetrics.density).roundToInt()
|
||||||
|
return sessions.icons.render(listOf(key), size, getColor(android.R.color.system_neutral1_500))
|
||||||
|
?.let(Icon::createWithBitmap)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun action(action: String, label: String): Notification.Action {
|
||||||
|
val intent = PendingIntent.getService(
|
||||||
|
this, action.hashCode(), Intent(this, ConnectionService::class.java).setAction(action),
|
||||||
|
PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
return Notification.Action.Builder(null, label, intent).build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val CHANNEL = "connection"
|
||||||
|
const val NOTIFICATION_ID = 1
|
||||||
|
const val STOP_DELAY_MS = 3_000L
|
||||||
|
const val MIN_UPDATE_MS = 250L
|
||||||
|
const val STATUS_ICON_DP = 24
|
||||||
|
const val LARGE_ICON_DP = 48
|
||||||
|
const val ACTION_TOGGLE_MUTE = "com.ts3client.android.TOGGLE_MUTE"
|
||||||
|
const val ACTION_TOGGLE_DEAFEN = "com.ts3client.android.TOGGLE_DEAFEN"
|
||||||
|
const val ACTION_DISCONNECT = "com.ts3client.android.DISCONNECT"
|
||||||
|
}
|
||||||
|
}
|
||||||
143
android/app/src/main/java/com/ts3client/android/IconTheme.kt
Normal file
143
android/app/src/main/java/com/ts3client/android/IconTheme.kt
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
package com.ts3client.android
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Paint
|
||||||
|
import android.graphics.RectF
|
||||||
|
import android.net.Uri
|
||||||
|
import android.provider.OpenableColumns
|
||||||
|
import android.util.LruCache
|
||||||
|
import com.caverock.androidsvg.RenderOptions
|
||||||
|
import com.caverock.androidsvg.SVG
|
||||||
|
import com.ts3client.config.AppDirs
|
||||||
|
import com.ts3client.config.Settings
|
||||||
|
import com.ts3client.gfx.IconPack
|
||||||
|
import com.ts3client.gfx.IconPacks
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The icon packs the app draws with: the Material pack it ships, and TeamSpeak packs
|
||||||
|
* imported into its `gfx` folder, which are the very zips a TeamSpeak 3 install keeps in
|
||||||
|
* its own. Whatever the chosen pack lacks comes from the Material one.
|
||||||
|
*/
|
||||||
|
class IconTheme(private val context: Context, private val settings: Settings) {
|
||||||
|
|
||||||
|
private val folder = File(AppDirs.profile(), "gfx")
|
||||||
|
private val material: IconPack = IconPacks.material()
|
||||||
|
@Volatile
|
||||||
|
private var lookupOrder = listOf(material)
|
||||||
|
private val cache = LruCache<String, Bitmap>(256)
|
||||||
|
|
||||||
|
private val _packs = MutableStateFlow(emptyList<IconPack>())
|
||||||
|
val packs: StateFlow<List<IconPack>> = _packs
|
||||||
|
|
||||||
|
private val _active = MutableStateFlow(material)
|
||||||
|
val active: StateFlow<IconPack> = _active
|
||||||
|
|
||||||
|
init {
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun select(pack: IconPack) {
|
||||||
|
settings.iconPack = pack.id()
|
||||||
|
settings.save()
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copies a pack's zip into the app and switches to it.
|
||||||
|
*
|
||||||
|
* @return the pack, or `null` when the file is no icon pack
|
||||||
|
*/
|
||||||
|
fun import(uri: Uri): IconPack? {
|
||||||
|
folder.mkdirs()
|
||||||
|
val target = File(folder, displayName(uri).substringAfterLast('/').let {
|
||||||
|
if (it.endsWith(".zip", ignoreCase = true)) it else "$it.zip"
|
||||||
|
})
|
||||||
|
val staged = File(folder, target.name + ".part")
|
||||||
|
context.contentResolver.openInputStream(uri)?.use { input ->
|
||||||
|
staged.outputStream().use { input.copyTo(it) }
|
||||||
|
} ?: return null
|
||||||
|
target.delete()
|
||||||
|
staged.renameTo(target)
|
||||||
|
val pack = IconPack.load(target)
|
||||||
|
if (pack == null) {
|
||||||
|
target.delete()
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
select(pack)
|
||||||
|
return _active.value
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draws the first of [keys] the packs have.
|
||||||
|
*
|
||||||
|
* @param rgb the colour for art that paints in the text colour
|
||||||
|
*/
|
||||||
|
fun render(keys: List<String>, size: Int, rgb: Int): Bitmap? = render(lookupOrder, keys, size, rgb)
|
||||||
|
|
||||||
|
/** Draws from the Material pack whatever pack is chosen, for chrome that must stay legible on its own colours. */
|
||||||
|
fun renderMaterial(key: String, size: Int, rgb: Int): Bitmap? = render(listOf(material), listOf(key), size, rgb)
|
||||||
|
|
||||||
|
private fun render(order: List<IconPack>, keys: List<String>, size: Int, rgb: Int): Bitmap? {
|
||||||
|
val cacheKey = "${order.first().id()}/${keys.joinToString(",")}@$size#$rgb"
|
||||||
|
cache.get(cacheKey)?.let { return it }
|
||||||
|
for (key in keys) {
|
||||||
|
for (pack in order) {
|
||||||
|
val data = pack.icon(key, size) ?: continue
|
||||||
|
val bitmap = (if (pack.isVector(key, size)) rasterize(IconPack.withCurrentColor(data, rgb), size)
|
||||||
|
else decode(data, size)) ?: continue
|
||||||
|
cache.put(cacheKey, bitmap)
|
||||||
|
return bitmap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun reload() {
|
||||||
|
val packs = IconPacks.findAll(folder, null)
|
||||||
|
val active = IconPacks.select(packs, settings.iconPack) ?: material
|
||||||
|
lookupOrder = IconPacks.lookupOrder(active, packs, material)
|
||||||
|
cache.evictAll()
|
||||||
|
_packs.value = packs
|
||||||
|
_active.value = active
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun displayName(uri: Uri): String =
|
||||||
|
context.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use {
|
||||||
|
if (it.moveToFirst()) it.getString(0) else null
|
||||||
|
} ?: uri.lastPathSegment ?: "iconpack.zip"
|
||||||
|
|
||||||
|
private fun rasterize(svg: ByteArray, size: Int): Bitmap? = try {
|
||||||
|
val document = SVG.getFromString(String(svg, Charsets.UTF_8))
|
||||||
|
if (document.documentViewBox == null && document.documentWidth > 0 && document.documentHeight > 0) {
|
||||||
|
document.setDocumentViewBox(0f, 0f, document.documentWidth, document.documentHeight)
|
||||||
|
}
|
||||||
|
// Otherwise the art keeps the size it was authored at inside the viewport.
|
||||||
|
document.setDocumentWidth("100%")
|
||||||
|
document.setDocumentHeight("100%")
|
||||||
|
Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888).also {
|
||||||
|
document.renderToCanvas(Canvas(it), RenderOptions().viewPort(0f, 0f, size.toFloat(), size.toFloat()))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scales a bitmap icon into the square, keeping the aspect ratio of art that is not. */
|
||||||
|
private fun decode(data: ByteArray, size: Int): Bitmap? {
|
||||||
|
val source = BitmapFactory.decodeByteArray(data, 0, data.size) ?: return null
|
||||||
|
if (source.width == size && source.height == size) return source
|
||||||
|
val factor = minOf(size.toFloat() / source.width, size.toFloat() / source.height)
|
||||||
|
val w = source.width * factor
|
||||||
|
val h = source.height * factor
|
||||||
|
return Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888).also {
|
||||||
|
val left = (size - w) / 2
|
||||||
|
val top = (size - h) / 2
|
||||||
|
Canvas(it).drawBitmap(source, null, RectF(left, top, left + w, top + h), Paint(Paint.FILTER_BITMAP_FLAG))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
package com.ts3client.android
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import com.ts3client.config.Bookmarks
|
||||||
|
import com.ts3client.config.IdentityEntry
|
||||||
|
import com.ts3client.config.IdentityStore
|
||||||
|
import com.ts3client.config.Settings
|
||||||
|
import com.ts3client.teamspeak.TeamSpeakImporter
|
||||||
|
import com.ts3client.teamspeak.TeamSpeakSettingsDb
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancelAndJoin
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
|
/** What the identity manager shows of one identity. */
|
||||||
|
data class IdentityInfo(
|
||||||
|
val id: String,
|
||||||
|
val name: String,
|
||||||
|
val uniqueId: String,
|
||||||
|
val securityLevel: Int,
|
||||||
|
val isDefault: Boolean,
|
||||||
|
val nickname: String,
|
||||||
|
val phoneticNickname: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** A search for a better key offset under way. */
|
||||||
|
data class Improvement(val id: String, val target: Int, val reached: Int)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The identities, as TeamSpeak's Tools → Identities manages them: create, import, export,
|
||||||
|
* rename, remove, pick the default and raise the security level. Raising the level runs
|
||||||
|
* on until it is reached or stopped, whichever page is on screen.
|
||||||
|
*/
|
||||||
|
class IdentityManager(
|
||||||
|
private val context: Context,
|
||||||
|
private val store: IdentityStore,
|
||||||
|
private val settings: Settings,
|
||||||
|
private val bookmarks: Bookmarks,
|
||||||
|
private val onBookmarksChanged: () -> Unit,
|
||||||
|
) {
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
|
||||||
|
private val _all = MutableStateFlow(snapshot())
|
||||||
|
val all: StateFlow<List<IdentityInfo>> = _all
|
||||||
|
|
||||||
|
private val _improving = MutableStateFlow<Improvement?>(null)
|
||||||
|
val improving: StateFlow<Improvement?> = _improving
|
||||||
|
private var improvement: Job? = null
|
||||||
|
|
||||||
|
/** Picks up identities added elsewhere, such as by an import or a first connect. */
|
||||||
|
fun refresh() {
|
||||||
|
_all.value = snapshot()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun snapshot(): List<IdentityInfo> = store.all().map {
|
||||||
|
IdentityInfo(it.id, it.name, it.uniqueId, it.securityLevel, it.id == settings.defaultIdentityId,
|
||||||
|
it.nickname, it.phoneticNickname)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generates an identity at TeamSpeak's default security level; the first one becomes the default. */
|
||||||
|
suspend fun generate(name: String): IdentityInfo = added {
|
||||||
|
store.generate(name, IdentityStore.DEFAULT_SECURITY_LEVEL)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Imports a TeamSpeak identity file, under the name it carries. */
|
||||||
|
suspend fun importFile(uri: Uri): IdentityInfo = added {
|
||||||
|
context.contentResolver.openInputStream(uri)?.use { store.importStream(it, null) }
|
||||||
|
?: throw IOException("Cannot open the file")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Imports the identities of a TeamSpeak 3 `settings.db`. */
|
||||||
|
suspend fun importFromTeamSpeak(uri: Uri): TeamSpeakImporter.Result = withContext(Dispatchers.IO) {
|
||||||
|
withLocalCopy(context, uri) { TeamSpeakImporter(store, bookmarks, settings).importIdentities(it) }
|
||||||
|
}.also {
|
||||||
|
adoptDefault(null)
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun added(create: () -> IdentityEntry): IdentityInfo {
|
||||||
|
val entry = withContext(Dispatchers.IO) { create() }
|
||||||
|
adoptDefault(entry)
|
||||||
|
refresh()
|
||||||
|
return _all.value.first { it.id == entry.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Makes [entry], or else the first identity, the default while there is none. */
|
||||||
|
private fun adoptDefault(entry: IdentityEntry?) {
|
||||||
|
if (store.byId(settings.defaultIdentityId) != null) return
|
||||||
|
val fallback = entry ?: store.all().firstOrNull() ?: return
|
||||||
|
settings.defaultIdentityId = fallback.id
|
||||||
|
settings.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Writes the identity out in TeamSpeak's format, for the official client or another device. */
|
||||||
|
suspend fun export(id: String, uri: Uri) = withContext(Dispatchers.IO) {
|
||||||
|
val entry = store.byId(id) ?: return@withContext
|
||||||
|
val out = context.contentResolver.openOutputStream(uri, "wt") ?: throw IOException("Cannot write the file")
|
||||||
|
store.exportTo(entry, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun rename(id: String, name: String) {
|
||||||
|
val entry = store.byId(id) ?: return
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
store.rename(entry, name)
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setNicknames(id: String, nickname: String, phoneticNickname: String) {
|
||||||
|
val entry = store.byId(id) ?: return
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
store.setNicknames(entry, nickname, phoneticNickname)
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The nickname of the identity a connect with [id] uses; empty when it has none. */
|
||||||
|
fun nicknameOf(id: String): String =
|
||||||
|
(store.byId(id) ?: store.byId(settings.defaultIdentityId))?.nickname.orEmpty()
|
||||||
|
|
||||||
|
fun setDefault(id: String) {
|
||||||
|
settings.defaultIdentityId = id
|
||||||
|
settings.save()
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deletes the identity; bookmarks that connected with it fall back to the default. */
|
||||||
|
fun remove(id: String) {
|
||||||
|
val entry = store.byId(id) ?: return
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
// Else the search would write the file back once it ends.
|
||||||
|
if (_improving.value?.id == id) improvement?.cancelAndJoin()
|
||||||
|
store.remove(entry, settings, bookmarks)
|
||||||
|
onBookmarksChanged()
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Searches for a key offset reaching [target], keeping the best found when stopped. */
|
||||||
|
fun improve(id: String, target: Int) {
|
||||||
|
if (improvement?.isActive == true) return
|
||||||
|
val entry = store.byId(id) ?: return
|
||||||
|
_improving.value = Improvement(id, target, entry.securityLevel)
|
||||||
|
improvement = scope.launch {
|
||||||
|
try {
|
||||||
|
store.improveSecurity(entry, target) { reached, _ ->
|
||||||
|
_improving.value = Improvement(id, target, reached)
|
||||||
|
isActive
|
||||||
|
}
|
||||||
|
} catch (e: IOException) {
|
||||||
|
// The level reached stays in memory for this run.
|
||||||
|
} finally {
|
||||||
|
_improving.value = null
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stopImproving() {
|
||||||
|
improvement?.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package com.ts3client.android
|
||||||
|
|
||||||
|
import com.ts3client.audio.AudioBackend
|
||||||
|
import com.ts3client.audio.InputLevel
|
||||||
|
import com.ts3client.audio.VoiceInput
|
||||||
|
import com.ts3client.config.Settings
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
/** The microphone's level on [InputLevel]'s scale, and whether that would be sent. */
|
||||||
|
data class MicLevel(
|
||||||
|
val db: Double = InputLevel.SILENCE_DB,
|
||||||
|
val transmitting: Boolean = false,
|
||||||
|
/** Whether a microphone is being listened to at all. */
|
||||||
|
val live: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The level meter behind the voice-activation settings. While connected it listens to the
|
||||||
|
* connection's own microphone, so it shows exactly what gets sent; otherwise it runs a test
|
||||||
|
* microphone of its own for as long as someone watches.
|
||||||
|
*/
|
||||||
|
class LevelMonitor(private val settings: Settings, private val audio: AudioBackend) {
|
||||||
|
|
||||||
|
private val _level = MutableStateFlow(MicLevel())
|
||||||
|
val level: StateFlow<MicLevel> = _level
|
||||||
|
|
||||||
|
/** The connection's microphone, when that is the one listened to. */
|
||||||
|
private var borrowed: VoiceInput? = null
|
||||||
|
private var test: VoiceInput? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts or stops listening.
|
||||||
|
*
|
||||||
|
* @param source the connection's microphone while it captures, else null
|
||||||
|
* @param busy whether a connection is under way, which will bring its own microphone
|
||||||
|
*/
|
||||||
|
@Synchronized
|
||||||
|
fun update(watching: Boolean, source: VoiceInput?, busy: Boolean, mayCapture: Boolean) {
|
||||||
|
when {
|
||||||
|
!watching -> release()
|
||||||
|
source != null -> if (borrowed !== source) {
|
||||||
|
release()
|
||||||
|
source.setLevelListener(::onLevel)
|
||||||
|
borrowed = source
|
||||||
|
}
|
||||||
|
busy || !mayCapture -> release()
|
||||||
|
test == null -> startTest()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The own microphone's transmit state, which the connection reports. */
|
||||||
|
fun talking(transmitting: Boolean) {
|
||||||
|
if (borrowed != null) _level.value = _level.value.copy(transmitting = transmitting)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Puts changed voice settings into effect on the test microphone. */
|
||||||
|
@Synchronized
|
||||||
|
fun apply() {
|
||||||
|
test?.let {
|
||||||
|
it.apply(settings)
|
||||||
|
it.setMode(testMode())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startTest() {
|
||||||
|
release()
|
||||||
|
val input = audio.createInput(settings)
|
||||||
|
input.setMode(testMode())
|
||||||
|
input.setLevelListener(::onLevel)
|
||||||
|
input.setTalkListener { _level.value = _level.value.copy(transmitting = it) }
|
||||||
|
try {
|
||||||
|
input.start()
|
||||||
|
} catch (e: RuntimeException) {
|
||||||
|
return // no microphone to be had; the meter stays idle
|
||||||
|
}
|
||||||
|
test = input
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun release() {
|
||||||
|
borrowed?.setLevelListener(null)
|
||||||
|
borrowed = null
|
||||||
|
test?.stop()
|
||||||
|
test = null
|
||||||
|
_level.value = MicLevel()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onLevel(db: Double) {
|
||||||
|
_level.value = _level.value.copy(db = db, live = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Push-to-talk would never open without the button, and the test is there to tune the gate. */
|
||||||
|
private fun testMode() =
|
||||||
|
if (settings.inputMode == Settings.InputMode.PUSH_TO_TALK) Settings.InputMode.VOICE_ACTIVATION else settings.inputMode
|
||||||
|
}
|
||||||
21
android/app/src/main/java/com/ts3client/android/LocalCopy.kt
Normal file
21
android/app/src/main/java/com/ts3client/android/LocalCopy.kt
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
package com.ts3client.android
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import java.io.File
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hands [read] a temporary copy of the document at [uri], for readers that need a file they
|
||||||
|
* can seek in, which a content URI is not.
|
||||||
|
*/
|
||||||
|
internal fun <T> withLocalCopy(context: Context, uri: Uri, read: (File) -> T): T {
|
||||||
|
val copy = File.createTempFile("import", null, context.cacheDir)
|
||||||
|
try {
|
||||||
|
val input = context.contentResolver.openInputStream(uri) ?: throw IOException("Cannot open the file")
|
||||||
|
input.use { copy.outputStream().use { out -> it.copyTo(out) } }
|
||||||
|
return read(copy)
|
||||||
|
} finally {
|
||||||
|
copy.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package com.ts3client.android
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
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)
|
||||||
|
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 serverId = intent.getIntExtra(MessageNotifier.EXTRA_SERVER, -1)
|
||||||
|
val clientId = intent.getIntExtra(MessageNotifier.EXTRA_CHAT, -1)
|
||||||
|
if (serverId >= 0 && clientId >= 0) sessions.requestChat(serverId, clientId)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
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 [id] of server and peer. */
|
||||||
|
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, 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(serverId: Int, clientId: Int, from: String, text: String) {
|
||||||
|
val id = id(serverId, clientId)
|
||||||
|
val conversation = conversations[id]?.takeIf { it.peer.name == from }
|
||||||
|
?: Conversation(Person.Builder().setName(from).setKey("client:$serverId:$clientId").build(), ArrayList())
|
||||||
|
conversations[id] = conversation
|
||||||
|
add(serverId, clientId, conversation, text, conversation.peer, alert = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Our own reply, shown in a conversation already notified of; answers the reply action too. */
|
||||||
|
@Synchronized
|
||||||
|
fun sent(serverId: Int, clientId: Int, text: String) {
|
||||||
|
val conversation = conversations[id(serverId, clientId)] ?: return
|
||||||
|
add(serverId, clientId, conversation, text, me, alert = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun add(serverId: Int, 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(serverId, clientId))
|
||||||
|
.setOnlyAlertOnce(!alert)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.addAction(replyAction(serverId, clientId))
|
||||||
|
.build()
|
||||||
|
manager.notify(MESSAGE_TAG, id(serverId, clientId), notification)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The conversation came on screen, or ended with the connection. */
|
||||||
|
@Synchronized
|
||||||
|
fun clear(serverId: Int, clientId: Int) {
|
||||||
|
val id = id(serverId, clientId)
|
||||||
|
conversations.remove(id)
|
||||||
|
manager.cancel(MESSAGE_TAG, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The server's conversations, as its connection ended. */
|
||||||
|
@Synchronized
|
||||||
|
fun clearServer(serverId: Int) {
|
||||||
|
conversations.keys.filter { it ushr 16 == serverId }.forEach { clear(serverId, it and 0xFFFF) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun open(serverId: Int?, clientId: Int?): PendingIntent {
|
||||||
|
val intent = Intent(context, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||||
|
if (serverId != null && clientId != null) {
|
||||||
|
intent.putExtra(EXTRA_SERVER, serverId).putExtra(EXTRA_CHAT, clientId)
|
||||||
|
}
|
||||||
|
return PendingIntent.getActivity(context, if (serverId != null && clientId != null) id(serverId, clientId) else -1,
|
||||||
|
intent, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun replyAction(serverId: Int, clientId: Int): Notification.Action {
|
||||||
|
val intent = Intent(context, ReplyReceiver::class.java)
|
||||||
|
.putExtra(EXTRA_SERVER, serverId).putExtra(EXTRA_CHAT, clientId)
|
||||||
|
// Mutable: the system fills the typed reply into it.
|
||||||
|
val pending = PendingIntent.getBroadcast(context, id(serverId, 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()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Client ids are 16 bits, and only unique on their server. */
|
||||||
|
private fun id(serverId: Int, clientId: Int) = serverId shl 16 or clientId
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** The server and the client id of the private chat a notification opens or replies to. */
|
||||||
|
const val EXTRA_SERVER = "com.ts3client.android.SERVER"
|
||||||
|
const val EXTRA_CHAT = "com.ts3client.android.CHAT"
|
||||||
|
const val 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,220 @@
|
|||||||
|
package com.ts3client.android
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.ConnectivityManager
|
||||||
|
import android.net.Network
|
||||||
|
import com.ts3client.session.ServerSession
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
import java.util.concurrent.CopyOnWriteArrayList
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps the connections on the network Android routes over.
|
||||||
|
*
|
||||||
|
* A TS3 server knows its clients by address, so once the phone moves from Wi-Fi to mobile
|
||||||
|
* data (or back) an old connection is dead weight: it can only time out. This notices the
|
||||||
|
* move, says goodbye over the old network while that still works, and connects again over
|
||||||
|
* the new one, back into the channel we were in. A server that still holds our old
|
||||||
|
* connection may refuse the new one for a while as a clone, so a failed attempt is retried.
|
||||||
|
*
|
||||||
|
* The process's sockets are bound to the network a connection was made over. Otherwise the
|
||||||
|
* old connection would silently follow the new default network, and its goodbye would come
|
||||||
|
* from an address the server does not know. A socket keeps the network it was made on, so
|
||||||
|
* each connection remembers its own.
|
||||||
|
*/
|
||||||
|
class NetworkFollower(context: Context) {
|
||||||
|
private val connectivity = context.getSystemService(ConnectivityManager::class.java)
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
private val follows = CopyOnWriteArrayList<Follow>()
|
||||||
|
|
||||||
|
/** The system's default network; null while there is none. */
|
||||||
|
@Volatile
|
||||||
|
private var network: Network? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
connectivity.registerDefaultNetworkCallback(object : ConnectivityManager.NetworkCallback() {
|
||||||
|
override fun onAvailable(available: Network) {
|
||||||
|
network = available
|
||||||
|
follows.forEach(Follow::reconsider)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onLost(lost: Network) {
|
||||||
|
if (network == lost) network = null
|
||||||
|
follows.forEach(Follow::reconsider)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Follows [session] until [Follow.forget]. */
|
||||||
|
fun follow(session: ServerSession, onChange: () -> Unit, system: (String) -> Unit): Follow =
|
||||||
|
Follow(session, onChange, system).also(follows::add)
|
||||||
|
|
||||||
|
/** Binds new sockets to [to], or lets them go by the default network once no connection wants one. */
|
||||||
|
private fun bind(to: Network?) {
|
||||||
|
connectivity.bindProcessToNetwork(to ?: follows.firstNotNullOfOrNull { f -> f.bound.takeIf { f.wanted } })
|
||||||
|
}
|
||||||
|
|
||||||
|
inner class Follow internal constructor(
|
||||||
|
private val session: ServerSession,
|
||||||
|
private val onChange: () -> Unit,
|
||||||
|
private val system: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
/** The network the connection was made over. */
|
||||||
|
@Volatile
|
||||||
|
internal var bound: Network? = null
|
||||||
|
|
||||||
|
/** The user wants to be on the server: from their connect until they, or the server, end it. */
|
||||||
|
@Volatile
|
||||||
|
internal var wanted = false
|
||||||
|
|
||||||
|
private var settle: Job? = null
|
||||||
|
@Volatile
|
||||||
|
private var recovery: Job? = null
|
||||||
|
@Volatile
|
||||||
|
private var attempt: CompletableDeferred<Boolean>? = null
|
||||||
|
|
||||||
|
/** What to show in place of the session's status while getting back on the server. */
|
||||||
|
@Volatile
|
||||||
|
var status: String? = null
|
||||||
|
private set
|
||||||
|
|
||||||
|
val isRecovering: Boolean get() = recovery?.isActive == true
|
||||||
|
|
||||||
|
/** Before a connect the user asked for, which goes over the network there is now. */
|
||||||
|
fun connecting() {
|
||||||
|
recovery?.cancel()
|
||||||
|
wanted = true
|
||||||
|
bindHere(connectivity.activeNetwork)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The user leaves the server. */
|
||||||
|
fun leaving() {
|
||||||
|
wanted = false
|
||||||
|
recovery?.cancel()
|
||||||
|
unbind()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The session is thrown away. */
|
||||||
|
fun forget() {
|
||||||
|
leaving()
|
||||||
|
follows.remove(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onConnected() {
|
||||||
|
attempt?.complete(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onDisconnected() {
|
||||||
|
val pending = attempt
|
||||||
|
when {
|
||||||
|
pending != null -> pending.complete(false)
|
||||||
|
isRecovering -> {}
|
||||||
|
// A socket on a network that went away fails at once, before we hear of the change.
|
||||||
|
wanted && networkGone() -> synchronized(this) {
|
||||||
|
if (!isRecovering) recovery = recover()
|
||||||
|
}
|
||||||
|
// The server let us go (a kick, a shutdown, a failed first connect): leave it at that.
|
||||||
|
else -> {
|
||||||
|
wanted = false
|
||||||
|
unbind()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Waits for the network to settle: a handover often shows several changes in a row. */
|
||||||
|
internal fun reconsider() {
|
||||||
|
synchronized(this) {
|
||||||
|
settle?.cancel()
|
||||||
|
settle = scope.launch {
|
||||||
|
delay(SETTLE_MS)
|
||||||
|
synchronized(this@Follow) {
|
||||||
|
if (wanted && bound != network && !isRecovering) recovery = recover()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun recover() = scope.launch {
|
||||||
|
try {
|
||||||
|
val back = withTimeoutOrNull(GIVE_UP_MS) {
|
||||||
|
system(if (connectivity.activeNetwork == null) "Network lost." else "Network changed, reconnecting…")
|
||||||
|
if (session.isBusy) session.connection().disconnectBlocking("Network changed")
|
||||||
|
var retry = RETRY_MS
|
||||||
|
while (true) {
|
||||||
|
val current = awaitNetwork()
|
||||||
|
bindHere(current)
|
||||||
|
show("Reconnecting…")
|
||||||
|
val done = CompletableDeferred<Boolean>()
|
||||||
|
attempt = done
|
||||||
|
session.reconnect()
|
||||||
|
val connected = done.await()
|
||||||
|
attempt = null
|
||||||
|
if (connected) break
|
||||||
|
if (session.connection().connectErrorId == ERROR_BANNED) break
|
||||||
|
show("Reconnecting, retrying soon…")
|
||||||
|
delay(retry)
|
||||||
|
retry = (retry * 2).coerceAtMost(MAX_RETRY_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (session.connection().connectErrorId == ERROR_BANNED) {
|
||||||
|
wanted = false
|
||||||
|
unbind()
|
||||||
|
} else if (back == null) {
|
||||||
|
system("Could not get back on the server.")
|
||||||
|
wanted = false
|
||||||
|
unbind()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
attempt = null
|
||||||
|
status = null
|
||||||
|
onChange()
|
||||||
|
// The network may have moved again while we were busy.
|
||||||
|
reconsider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun networkGone(): Boolean {
|
||||||
|
val on = bound ?: return false
|
||||||
|
return connectivity.getNetworkCapabilities(on) == null || connectivity.activeNetwork != on
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun awaitNetwork(): Network {
|
||||||
|
while (true) {
|
||||||
|
connectivity.activeNetwork?.let { return it }
|
||||||
|
show("Waiting for network…")
|
||||||
|
delay(POLL_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun show(text: String) {
|
||||||
|
status = text
|
||||||
|
onChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bindHere(to: Network?) {
|
||||||
|
bound = to
|
||||||
|
bind(to)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun unbind() {
|
||||||
|
bound = null
|
||||||
|
bind(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val SETTLE_MS = 1_500L
|
||||||
|
/** The server's refusal for a banned client; asking again will not help. */
|
||||||
|
const val ERROR_BANNED = 3329
|
||||||
|
const val RETRY_MS = 5_000L
|
||||||
|
const val MAX_RETRY_MS = 30_000L
|
||||||
|
const val POLL_MS = 500L
|
||||||
|
const val GIVE_UP_MS = 5 * 60_000L
|
||||||
|
}
|
||||||
|
}
|
||||||
42
android/app/src/main/java/com/ts3client/android/Picture.kt
Normal file
42
android/app/src/main/java/com/ts3client/android/Picture.kt
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
package com.ts3client.android
|
||||||
|
|
||||||
|
import android.graphics.ImageDecoder
|
||||||
|
import android.graphics.drawable.AnimatedImageDrawable
|
||||||
|
import android.graphics.drawable.BitmapDrawable
|
||||||
|
import androidx.compose.ui.graphics.ImageBitmap
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
|
||||||
|
/** An avatar or a picture from a description: still, or moving like an animated GIF. */
|
||||||
|
sealed interface Picture {
|
||||||
|
val width: Int
|
||||||
|
val height: Int
|
||||||
|
|
||||||
|
data class Still(val bitmap: ImageBitmap) : Picture {
|
||||||
|
override val width get() = bitmap.width
|
||||||
|
override val height get() = bitmap.height
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shared by whatever shows it: one animation, drawn wherever it is on screen. */
|
||||||
|
data class Moving(val drawable: AnimatedImageDrawable) : Picture {
|
||||||
|
override val width get() = drawable.intrinsicWidth
|
||||||
|
override val height get() = drawable.intrinsicHeight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Decodes any image Android knows, no larger than [maxPx] on its longer side; null if it is none. */
|
||||||
|
internal fun decodePicture(data: ByteArray, maxPx: Int): Picture? = try {
|
||||||
|
val drawable = ImageDecoder.decodeDrawable(ImageDecoder.createSource(ByteBuffer.wrap(data))) { decoder, info, _ ->
|
||||||
|
val largest = maxOf(info.size.width, info.size.height)
|
||||||
|
if (largest > maxPx) {
|
||||||
|
decoder.setTargetSize(info.size.width * maxPx / largest, info.size.height * maxPx / largest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
when (drawable) {
|
||||||
|
is AnimatedImageDrawable -> Picture.Moving(drawable)
|
||||||
|
is BitmapDrawable -> Picture.Still(drawable.bitmap.asImageBitmap())
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
176
android/app/src/main/java/com/ts3client/android/PttOverlay.kt
Normal file
176
android/app/src/main/java/com/ts3client/android/PttOverlay.kt
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
package com.ts3client.android
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.Paint
|
||||||
|
import android.graphics.PixelFormat
|
||||||
|
import android.provider.Settings
|
||||||
|
import android.view.Gravity
|
||||||
|
import android.view.MotionEvent
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewConfiguration
|
||||||
|
import android.view.WindowManager
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A push-to-talk button floating over other apps. Holding it talks; a double tap whose
|
||||||
|
* second touch is held moves it instead, so moving never transmits more than the first tap.
|
||||||
|
*/
|
||||||
|
class PttOverlay(
|
||||||
|
private val context: Context,
|
||||||
|
private val icons: IconTheme,
|
||||||
|
private val onPressed: (Boolean) -> Unit,
|
||||||
|
) {
|
||||||
|
enum class Look { IDLE, TALKING, MUTED }
|
||||||
|
|
||||||
|
private val windows = context.getSystemService(WindowManager::class.java)
|
||||||
|
private val prefs = context.getSharedPreferences("overlay", Context.MODE_PRIVATE)
|
||||||
|
private val size = (SIZE_DP * context.resources.displayMetrics.density).toInt()
|
||||||
|
private var button: Button? = null
|
||||||
|
|
||||||
|
/** Shows the button, or updates it when it is up; nothing while the app may not draw over others. */
|
||||||
|
fun show(look: Look) {
|
||||||
|
button?.let {
|
||||||
|
it.look = look
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!Settings.canDrawOverlays(context)) return
|
||||||
|
val params = WindowManager.LayoutParams(
|
||||||
|
size, size,
|
||||||
|
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
|
||||||
|
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
|
||||||
|
PixelFormat.TRANSLUCENT,
|
||||||
|
).apply {
|
||||||
|
gravity = Gravity.TOP or Gravity.START
|
||||||
|
val screen = windows.currentWindowMetrics.bounds
|
||||||
|
x = prefs.getInt(POS_X, screen.width() - size - size / 4)
|
||||||
|
y = prefs.getInt(POS_Y, screen.height() / 2)
|
||||||
|
}
|
||||||
|
val view = Button(params).also { it.look = look }
|
||||||
|
try {
|
||||||
|
windows.addView(view, params)
|
||||||
|
} catch (e: WindowManager.BadTokenException) {
|
||||||
|
// The permission went away since the check.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
button = view
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hide() {
|
||||||
|
val view = button ?: return
|
||||||
|
button = null
|
||||||
|
view.release()
|
||||||
|
windows.removeView(view)
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("ViewConstructor")
|
||||||
|
private inner class Button(private val params: WindowManager.LayoutParams) : View(context) {
|
||||||
|
|
||||||
|
var look = Look.IDLE
|
||||||
|
set(value) {
|
||||||
|
field = value
|
||||||
|
restyle()
|
||||||
|
}
|
||||||
|
|
||||||
|
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||||
|
private val doubleTapTimeout = ViewConfiguration.getDoubleTapTimeout().toLong()
|
||||||
|
private var pressed = false
|
||||||
|
private var dragging = false
|
||||||
|
private var lastTapUp = 0L
|
||||||
|
private var grabX = 0f
|
||||||
|
private var grabY = 0f
|
||||||
|
|
||||||
|
init {
|
||||||
|
contentDescription = "Push to talk"
|
||||||
|
alpha = RESTING_ALPHA
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Half see-through at rest, so it hides less of the app beneath; solid while it talks. */
|
||||||
|
private fun restyle() {
|
||||||
|
alpha = if (pressed || look == Look.TALKING) 1f else RESTING_ALPHA
|
||||||
|
invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDraw(canvas: Canvas) {
|
||||||
|
val radius = width / 2f
|
||||||
|
val lit = pressed || look == Look.TALKING
|
||||||
|
paint.color = when {
|
||||||
|
look == Look.MUTED -> MUTED
|
||||||
|
lit -> TALKING
|
||||||
|
else -> IDLE
|
||||||
|
}
|
||||||
|
canvas.drawCircle(radius, radius, radius, paint)
|
||||||
|
if (lit) {
|
||||||
|
paint.color = Color.WHITE
|
||||||
|
paint.style = Paint.Style.STROKE
|
||||||
|
paint.strokeWidth = radius / 12
|
||||||
|
canvas.drawCircle(radius, radius, radius - paint.strokeWidth, paint)
|
||||||
|
paint.style = Paint.Style.FILL
|
||||||
|
}
|
||||||
|
val iconSize = width / 2
|
||||||
|
icon(iconSize)?.let {
|
||||||
|
val offset = (width - iconSize) / 2f
|
||||||
|
canvas.drawBitmap(it, offset, offset, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun icon(size: Int): Bitmap? =
|
||||||
|
icons.renderMaterial(if (look == Look.MUTED) "INPUT_MUTED" else "CAPTURE", size, Color.WHITE)
|
||||||
|
|
||||||
|
@SuppressLint("ClickableViewAccessibility")
|
||||||
|
override fun onTouchEvent(e: MotionEvent): Boolean {
|
||||||
|
when (e.actionMasked) {
|
||||||
|
MotionEvent.ACTION_DOWN -> {
|
||||||
|
dragging = e.eventTime - lastTapUp < doubleTapTimeout
|
||||||
|
if (dragging) {
|
||||||
|
grabX = e.rawX - params.x
|
||||||
|
grabY = e.rawY - params.y
|
||||||
|
} else {
|
||||||
|
press(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MotionEvent.ACTION_MOVE -> if (dragging) moveTo(e.rawX - grabX, e.rawY - grabY)
|
||||||
|
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
|
||||||
|
val tap = !dragging && e.eventTime - e.downTime < doubleTapTimeout
|
||||||
|
lastTapUp = if (tap) e.eventTime else 0L
|
||||||
|
if (dragging) {
|
||||||
|
dragging = false
|
||||||
|
prefs.edit().putInt(POS_X, params.x).putInt(POS_Y, params.y).apply()
|
||||||
|
} else {
|
||||||
|
press(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun moveTo(x: Float, y: Float) {
|
||||||
|
val screen = windows.currentWindowMetrics.bounds
|
||||||
|
params.x = x.toInt().coerceIn(0, screen.width() - width)
|
||||||
|
params.y = y.toInt().coerceIn(0, screen.height() - height)
|
||||||
|
windows.updateViewLayout(this, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun press(down: Boolean) {
|
||||||
|
if (pressed == down) return
|
||||||
|
pressed = down
|
||||||
|
onPressed(down)
|
||||||
|
restyle()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lets go of the microphone should the button vanish under the finger. */
|
||||||
|
fun release() = press(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val SIZE_DP = 64
|
||||||
|
const val RESTING_ALPHA = 0.5f
|
||||||
|
const val POS_X = "x"
|
||||||
|
const val POS_Y = "y"
|
||||||
|
val IDLE = Color.rgb(0x1C, 0x6F, 0xB8)
|
||||||
|
val TALKING = Color.rgb(0x2E, 0x9B, 0xF0)
|
||||||
|
val MUTED = Color.rgb(0x42, 0x42, 0x42)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
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 serverId = intent.getIntExtra(MessageNotifier.EXTRA_SERVER, -1)
|
||||||
|
val clientId = intent.getIntExtra(MessageNotifier.EXTRA_CHAT, -1)
|
||||||
|
val text = RemoteInput.getResultsFromIntent(intent)?.getCharSequence(MessageNotifier.KEY_REPLY)?.toString()
|
||||||
|
if (serverId < 0 || clientId < 0 || text.isNullOrBlank()) return
|
||||||
|
(context.applicationContext as Ts3Application).sessions.reply(serverId, clientId, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package com.ts3client.android
|
||||||
|
|
||||||
|
import android.content.ContentValues
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.os.Environment
|
||||||
|
import android.provider.MediaStore
|
||||||
|
import android.webkit.MimeTypeMap
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
/** Puts pictures into the shared Pictures collection, in a TeamSpeak album, where galleries find them. */
|
||||||
|
object SavedPictures {
|
||||||
|
|
||||||
|
/** Saves [data] as [name], its extension following what the data is; false if it could not be. */
|
||||||
|
suspend fun save(context: Context, name: String, data: ByteArray): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||||
|
BitmapFactory.decodeByteArray(data, 0, data.size, bounds)
|
||||||
|
val mime = bounds.outMimeType ?: return@withContext false
|
||||||
|
val extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mime)
|
||||||
|
val values = ContentValues().apply {
|
||||||
|
put(MediaStore.Images.Media.DISPLAY_NAME, if (extension == null) name else "$name.$extension")
|
||||||
|
put(MediaStore.Images.Media.MIME_TYPE, mime)
|
||||||
|
put(MediaStore.Images.Media.RELATIVE_PATH, "${Environment.DIRECTORY_PICTURES}/TeamSpeak")
|
||||||
|
put(MediaStore.Images.Media.IS_PENDING, 1)
|
||||||
|
}
|
||||||
|
val resolver = context.contentResolver
|
||||||
|
val uri = resolver.insert(MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY), values)
|
||||||
|
?: return@withContext false
|
||||||
|
try {
|
||||||
|
resolver.openOutputStream(uri)!!.use { it.write(data) }
|
||||||
|
resolver.update(uri, ContentValues().apply { put(MediaStore.Images.Media.IS_PENDING, 0) }, null, null)
|
||||||
|
true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
resolver.delete(uri, null, null)
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,469 @@
|
|||||||
|
package com.ts3client.android
|
||||||
|
|
||||||
|
import com.ts3client.contacts.ContactType
|
||||||
|
import com.ts3client.net.ChannelNode
|
||||||
|
import com.ts3client.net.Details
|
||||||
|
import com.ts3client.net.GroupChoice
|
||||||
|
import com.ts3client.net.ban.BanEntry
|
||||||
|
import com.ts3client.net.ban.BanRule
|
||||||
|
import com.ts3client.net.ConnectionListener.ChatScope
|
||||||
|
import com.ts3client.session.ServerSession
|
||||||
|
import com.ts3client.text.BBCode
|
||||||
|
import com.ts3client.text.ChatHtml
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.File
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
import java.util.concurrent.atomic.AtomicLong
|
||||||
|
import kotlin.coroutines.resume
|
||||||
|
import kotlin.coroutines.suspendCoroutine
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One server the app is on: its [ServerSession], and the snapshots the UI shows of it. Session
|
||||||
|
* callbacks arrive on connection threads and are turned into immutable snapshots.
|
||||||
|
*/
|
||||||
|
class ServerController internal constructor(
|
||||||
|
/** Unique among the servers of this process run. */
|
||||||
|
val id: Int,
|
||||||
|
private val app: SessionController,
|
||||||
|
) {
|
||||||
|
internal val session = ServerSession(
|
||||||
|
app.settings, app.identityStore, app.audio, app.sounds, app.contactStore, Listener()
|
||||||
|
)
|
||||||
|
internal val conn get() = session.connection()
|
||||||
|
private val network = app.network.follow(session, ::refresh, ::system)
|
||||||
|
|
||||||
|
/** Whether this server holds the microphone; set by [SessionController] as it moves. */
|
||||||
|
@Volatile
|
||||||
|
internal var micOwner = false
|
||||||
|
|
||||||
|
/** Set once connecting began, so a session that never started is not taken for one that ended. */
|
||||||
|
@Volatile
|
||||||
|
private var started = false
|
||||||
|
|
||||||
|
/** Set when the user left, so the end needs no telling. */
|
||||||
|
@Volatile
|
||||||
|
internal var left = false
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(SessionState())
|
||||||
|
val state: StateFlow<SessionState> = _state
|
||||||
|
|
||||||
|
private val _tree = MutableStateFlow(ServerTree())
|
||||||
|
val tree: StateFlow<ServerTree> = _tree
|
||||||
|
|
||||||
|
private val _chats = MutableStateFlow(Chats())
|
||||||
|
val chats: StateFlow<Chats> = _chats
|
||||||
|
|
||||||
|
/** Channels the user folded away in the tree. */
|
||||||
|
private val _collapsed = MutableStateFlow(emptySet<Int>())
|
||||||
|
val collapsed: StateFlow<Set<Int>> = _collapsed
|
||||||
|
|
||||||
|
/** The conversation on screen, whose new lines are read as they come. */
|
||||||
|
@Volatile
|
||||||
|
var viewing: ChatKey? = null
|
||||||
|
set(value) {
|
||||||
|
field = value
|
||||||
|
if (value != null) markRead(value)
|
||||||
|
if (value is ChatKey.Private) app.notifier.clear(id, value.clientId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Counts the answers to info requests, for the details on screen to be read again. */
|
||||||
|
private val _infoUpdates = MutableStateFlow(0)
|
||||||
|
val infoUpdates: StateFlow<Int> = _infoUpdates
|
||||||
|
|
||||||
|
val images = ServerImages(conn)
|
||||||
|
|
||||||
|
/** A private chat a notification asked to show, until the UI took it up. */
|
||||||
|
private val _chatRequest = MutableStateFlow<ChatKey.Private?>(null)
|
||||||
|
val chatRequest: StateFlow<ChatKey.Private?> = _chatRequest
|
||||||
|
|
||||||
|
private val lineIds = AtomicLong()
|
||||||
|
|
||||||
|
/** Tree snapshots are taken one at a time, at most once per [TREE_FRAME_MS]. */
|
||||||
|
private val treeScope = CoroutineScope(SupervisorJob() + Dispatchers.Default.limitedParallelism(1))
|
||||||
|
private val treePending = AtomicBoolean()
|
||||||
|
private val structureChanged = AtomicBoolean(true)
|
||||||
|
private var roots: List<ChannelNode> = emptyList()
|
||||||
|
|
||||||
|
val isConnected: Boolean get() = session.isConnected
|
||||||
|
internal val isBusy: Boolean get() = session.isBusy || network.isRecovering
|
||||||
|
internal val microphone get() = if (session.isConnected) conn.microphone else null
|
||||||
|
internal val playback get() = if (session.isConnected) conn.playback else null
|
||||||
|
|
||||||
|
/** Not on the server and not trying to get back on it: done with. */
|
||||||
|
internal val ended: Boolean get() = started && !isBusy
|
||||||
|
|
||||||
|
// ---- connection ----
|
||||||
|
|
||||||
|
internal fun start(host: String, port: Int, nickname: String, password: String, identityId: String,
|
||||||
|
channel: String, channelPassword: String) {
|
||||||
|
network.connecting()
|
||||||
|
started = true
|
||||||
|
session.connect(host, port, nickname, password, identityId, channel, channelPassword)
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun disconnect() {
|
||||||
|
left = true
|
||||||
|
network.leaving()
|
||||||
|
session.disconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Before the app goes away with the connection, which [SessionController.leave] then ends. */
|
||||||
|
internal fun leaving() {
|
||||||
|
left = true
|
||||||
|
network.leaving()
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun dispose() {
|
||||||
|
network.forget()
|
||||||
|
treeScope.cancel()
|
||||||
|
session.dispose()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the local client ----
|
||||||
|
|
||||||
|
fun setMicMuted(muted: Boolean) = session.setMicMuted(muted)
|
||||||
|
|
||||||
|
fun setDeafened(deafened: Boolean) = session.setDeafened(deafened)
|
||||||
|
|
||||||
|
fun setAway(away: Boolean) = session.setAway(away, null)
|
||||||
|
|
||||||
|
// ---- channels and clients ----
|
||||||
|
|
||||||
|
fun joinChannel(channelId: Int, password: String? = null) {
|
||||||
|
if (session.isConnected) conn.joinChannel(channelId, password)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSubscribed(channelId: Int, subscribed: Boolean) {
|
||||||
|
if (session.isConnected) conn.setChannelsSubscribed(listOf(channelId), subscribed)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleCollapsed(channelId: Int) =
|
||||||
|
_collapsed.update { if (channelId in it) it - channelId else it + channelId }
|
||||||
|
|
||||||
|
fun expand(channelIds: Collection<Int>) = _collapsed.update { it - channelIds.toSet() }
|
||||||
|
|
||||||
|
val canMoveClients: Boolean get() = session.canMoveClients()
|
||||||
|
|
||||||
|
fun moveClient(clientId: Int, channelId: Int) {
|
||||||
|
if (session.isConnected) conn.moveClient(clientId, channelId, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
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) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- moderation ----
|
||||||
|
|
||||||
|
val canKickFromChannel: Boolean get() = session.canKickFromChannel()
|
||||||
|
val canKickFromServer: Boolean get() = session.canKickFromServer()
|
||||||
|
val canBanClients: Boolean get() = session.canBanClients()
|
||||||
|
val canViewBanList: Boolean get() = session.canViewBanList()
|
||||||
|
val canCreateBans: Boolean get() = session.isConnected && conn.model.permits("b_client_ban_create")
|
||||||
|
|
||||||
|
/** The longest ban we may hand out in seconds; 0 or -1 for no limit. */
|
||||||
|
val maxBanSeconds: Long get() = conn.model.maxBanSeconds()
|
||||||
|
|
||||||
|
fun kickFromChannel(clientId: Int, reason: String) {
|
||||||
|
if (session.isConnected) conn.kickFromChannel(clientId, reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun kickFromServer(clientId: Int, reason: String) {
|
||||||
|
if (session.isConnected) conn.kickFromServer(clientId, reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bans a client for [seconds], or for good with 0. */
|
||||||
|
fun ban(clientId: Int, seconds: Long, reason: String) {
|
||||||
|
if (session.isConnected) conn.banClient(clientId, seconds, reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun serverGroupChoices(clientId: Int): List<GroupChoice> =
|
||||||
|
conn.model.getClient(clientId)?.let(conn.model::serverGroupChoices).orEmpty()
|
||||||
|
|
||||||
|
fun channelGroupChoices(clientId: Int): List<GroupChoice> =
|
||||||
|
conn.model.getClient(clientId)?.let(conn.model::channelGroupChoices).orEmpty()
|
||||||
|
|
||||||
|
fun setServerGroup(clientId: Int, groupId: Int, assign: Boolean) {
|
||||||
|
val client = conn.model.getClient(clientId) ?: return
|
||||||
|
if (session.isConnected) conn.setClientServerGroup(client.databaseId, groupId, assign)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gives a client [groupId] in the channel they are in. */
|
||||||
|
fun setChannelGroup(clientId: Int, groupId: Int) {
|
||||||
|
val client = conn.model.getClient(clientId) ?: return
|
||||||
|
if (session.isConnected) conn.setClientChannelGroup(client.databaseId, client.channelId, groupId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- ban list ----
|
||||||
|
|
||||||
|
/** The server's bans, or why they could not be read. */
|
||||||
|
suspend fun banList(): Result<List<BanEntry>> = suspendCoroutine { done ->
|
||||||
|
conn.requestBanList { bans, error ->
|
||||||
|
done.resume(if (error == null) Result.success(bans) else Result.failure(IllegalStateException(error)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Each of these answers null once done, else why not. */
|
||||||
|
suspend fun addBan(rule: BanRule): String? = suspendCoroutine { done -> conn.addBan(rule) { done.resume(it) } }
|
||||||
|
|
||||||
|
suspend fun replaceBan(banId: Int, rule: BanRule): String? =
|
||||||
|
suspendCoroutine { done -> conn.replaceBan(banId, rule) { done.resume(it) } }
|
||||||
|
|
||||||
|
suspend fun deleteBans(banIds: Collection<Int>): String? =
|
||||||
|
suspendCoroutine { done -> conn.deleteBans(banIds) { done.resume(it) } }
|
||||||
|
|
||||||
|
fun canDeleteBans(bans: Collection<BanEntry>): Boolean = conn.model.canDeleteBans(bans, conn.selfUniqueId)
|
||||||
|
|
||||||
|
fun canEditBan(ban: BanEntry): Boolean = conn.model.canEditBan(ban, conn.selfUniqueId)
|
||||||
|
|
||||||
|
fun isOwnBan(ban: BanEntry): Boolean = ban.createdBy(conn.selfUniqueId)
|
||||||
|
|
||||||
|
/** Uploads [data] as our avatar; null once it is set, else why not. */
|
||||||
|
suspend fun setAvatar(data: ByteArray): String? {
|
||||||
|
if (!session.isConnected) return "Not connected"
|
||||||
|
val file = withContext(Dispatchers.IO) {
|
||||||
|
File.createTempFile("avatar", null, app.cacheDir).apply { writeBytes(data) }
|
||||||
|
}
|
||||||
|
return try {
|
||||||
|
suspendCoroutine { done -> conn.setAvatar(file) { done.resume(it) } }
|
||||||
|
} finally {
|
||||||
|
file.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- details ----
|
||||||
|
|
||||||
|
fun serverDetails(): Details? =
|
||||||
|
if (session.isConnected) Details.server(conn.model, "${conn.serverHost}:${conn.serverPort}") else null
|
||||||
|
|
||||||
|
fun channelDetails(channelId: Int): Details? = conn.model.getChannel(channelId)?.let(Details::channel)
|
||||||
|
|
||||||
|
fun clientDetails(clientId: Int): Details? = conn.model.getClient(clientId)?.let {
|
||||||
|
Details.client(it, conn.model, app.contactStore.find(it.uniqueId))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Asks the server for what the channel's details lack; they change once it answers. */
|
||||||
|
fun requestChannelDetails(channelId: Int) {
|
||||||
|
val channel = conn.model.getChannel(channelId) ?: return
|
||||||
|
if (session.isConnected && !channel.descriptionLoaded) conn.requestChannelInfo(channelId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun requestClientDetails(clientId: Int) {
|
||||||
|
if (session.isConnected) conn.requestClientInfo(clientId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Files a client on the server as a contact; a new entry takes the type's defaults. */
|
||||||
|
fun addContact(clientId: Int, type: ContactType) {
|
||||||
|
val client = conn.model.getClient(clientId) ?: return
|
||||||
|
app.contactStore.add(client.uniqueId, client.nickname, type, app.settings.contactDefaults)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun contactsChanged() {
|
||||||
|
treeScope.launch { conn.applyContacts() }
|
||||||
|
scheduleTree(structure = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- chat ----
|
||||||
|
|
||||||
|
/** Opens the private chat with a client, loading what was said before. */
|
||||||
|
fun openPrivateChat(clientId: Int): ChatKey.Private {
|
||||||
|
val key = ChatKey.Private(clientId)
|
||||||
|
val client = conn.model.getClient(clientId)
|
||||||
|
if (client != null) {
|
||||||
|
_chats.update { chats ->
|
||||||
|
chats.update(key, conn.displayName(client)) { it.copy(uniqueId = client.uniqueId) }
|
||||||
|
}
|
||||||
|
conn.loadPrivateChatHistory(clientId, client.uniqueId)
|
||||||
|
}
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
fun closePrivateChat(key: ChatKey.Private) = _chats.update { it.close(key) }
|
||||||
|
|
||||||
|
fun send(key: ChatKey, text: String) {
|
||||||
|
when (key) {
|
||||||
|
ChatKey.Server -> session.sendChat(ChatScope.SERVER, 0, text)
|
||||||
|
ChatKey.Channel -> session.sendChat(ChatScope.CHANNEL, 0, text)
|
||||||
|
is ChatKey.Private -> session.sendChat(ChatScope.PRIVATE, key.clientId, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun markRead(key: ChatKey) {
|
||||||
|
if (_chats.value[key]?.unread == true) _chats.update { chats -> chats.update(key) { it.copy(unread = false) } }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun requestChat(clientId: Int) {
|
||||||
|
_chatRequest.value = ChatKey.Private(clientId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun chatRequestHandled() {
|
||||||
|
_chatRequest.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Answers a private message from its notification. */
|
||||||
|
internal fun reply(clientId: Int, text: String) {
|
||||||
|
if (session.isConnected) send(ChatKey.Private(clientId), text) else app.notifier.clear(id, clientId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun append(key: ChatKey, title: String, html: String) {
|
||||||
|
if (html.isEmpty()) return
|
||||||
|
val line = ChatLine(lineIds.incrementAndGet(), html)
|
||||||
|
_chats.update { chats -> chats.update(key, title) { it.append(line, read = viewing == key) } }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setHistory(key: ChatKey, title: String, entries: List<String>) {
|
||||||
|
val lines = entries.map(ChatHtml::logEntry).filter { it.isNotEmpty() }
|
||||||
|
.map { ChatLine(lineIds.incrementAndGet(), it) }
|
||||||
|
_chats.update { chats -> chats.update(key, title) { it.copy(lines = lines) } }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun system(text: String) = append(ChatKey.Server, "", ChatHtml.system(text))
|
||||||
|
|
||||||
|
// ---- snapshots ----
|
||||||
|
|
||||||
|
private fun snapshotState(): SessionState {
|
||||||
|
val self = conn.model.getClient(conn.selfClientId)
|
||||||
|
return SessionState(
|
||||||
|
title = session.title(),
|
||||||
|
status = network.status ?: session.status(),
|
||||||
|
connected = session.isConnected,
|
||||||
|
busy = isBusy,
|
||||||
|
micActive = micOwner && app.hasMicPermission(),
|
||||||
|
micPermission = app.hasMicPermission(),
|
||||||
|
micMuted = session.isMicMuted,
|
||||||
|
deafened = session.isDeafened,
|
||||||
|
away = session.isAway,
|
||||||
|
selfClientId = if (session.isConnected) conn.selfClientId else 0,
|
||||||
|
channelId = if (session.isConnected) self?.channelId ?: 0 else 0,
|
||||||
|
inputMode = app.settings.inputMode,
|
||||||
|
hostButton = if (session.isConnected) conn.model.hostButton() else null,
|
||||||
|
self = session.selfState(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun refresh() {
|
||||||
|
_state.value = snapshotState()
|
||||||
|
app.serverChanged(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Takes a new tree snapshot soon; [structure] when channels or clients came, went or moved. */
|
||||||
|
internal fun scheduleTree(structure: Boolean) {
|
||||||
|
if (structure) structureChanged.set(true)
|
||||||
|
if (!treePending.compareAndSet(false, true)) return
|
||||||
|
treeScope.launch {
|
||||||
|
delay(TREE_FRAME_MS)
|
||||||
|
treePending.set(false)
|
||||||
|
if (structureChanged.getAndSet(false)) roots = conn.model.buildTree()
|
||||||
|
_tree.value = ServerTree.of(roots, conn.model, conn.contacts, app.settings.colorContacts,
|
||||||
|
app.settings.countryFlags)
|
||||||
|
val serverName = if (session.isConnected) conn.model.serverName.orEmpty() else ""
|
||||||
|
val channelName = session.currentChannel()?.name.orEmpty()
|
||||||
|
_chats.update {
|
||||||
|
it.copy(
|
||||||
|
server = it.server.copy(title = serverName.ifBlank { "Server" }),
|
||||||
|
channel = it.channel.copy(title = channelName.ifBlank { "Channel" }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private inner class Listener : ServerSession.Listener {
|
||||||
|
override fun onSessionChanged() = refresh()
|
||||||
|
override fun onSystemMessage(text: String) = system(text)
|
||||||
|
|
||||||
|
override fun onOwnChat(scope: ChatScope, peerClientId: Int, peerName: String, text: String) {
|
||||||
|
val self = conn.model.getClient(conn.selfClientId)?.nickname ?: app.settings.nickname
|
||||||
|
val html = ChatHtml.message(conn.selfClientId, conn.selfUniqueId, "$self (you)", text)
|
||||||
|
append(key(scope, peerClientId), peerName, html)
|
||||||
|
if (scope == ChatScope.PRIVATE) app.notifier.sent(id, peerClientId, BBCode.toPlain(text))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStatus(status: String) {}
|
||||||
|
|
||||||
|
override fun onConnected() {
|
||||||
|
network.onConnected()
|
||||||
|
app.connected(this@ServerController)
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDisconnected(reason: String?) {
|
||||||
|
network.onDisconnected()
|
||||||
|
app.notifier.clearServer(id)
|
||||||
|
_chats.update { it.copy(private = emptyList()) }
|
||||||
|
app.disconnected(this@ServerController)
|
||||||
|
scheduleTree(structure = true)
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onModelChanged() {
|
||||||
|
scheduleTree(structure = true)
|
||||||
|
// A server edit may have changed it.
|
||||||
|
if (session.isConnected && conn.model.hostButton() != _state.value.hostButton) refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onServerIconChanged(iconId: Long) =
|
||||||
|
app.serverIconChanged(session.address(), session.port(), iconId)
|
||||||
|
|
||||||
|
override fun onInfoUpdated() {
|
||||||
|
_infoUpdates.update { it + 1 }
|
||||||
|
// Avatars announce their arrival as info updates.
|
||||||
|
images.loaded()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onIconsUpdated() = images.loaded()
|
||||||
|
|
||||||
|
override fun onChat(scope: ChatScope, fromClientId: Int, fromUniqueId: String?, fromName: String, message: String) {
|
||||||
|
append(key(scope, fromClientId), fromName, ChatHtml.message(fromClientId, fromUniqueId, fromName, message))
|
||||||
|
if (scope == ChatScope.PRIVATE && !app.foreground) {
|
||||||
|
app.notifier.received(id, fromClientId, fromName, BBCode.toPlain(message))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onChatHistory(scope: ChatScope, htmlEntries: List<String>) {
|
||||||
|
if (scope != ChatScope.PRIVATE) setHistory(key(scope, 0), "", htmlEntries)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPrivateChatHistory(clientId: Int, nickname: String, htmlEntries: List<String>) =
|
||||||
|
setHistory(ChatKey.Private(clientId), nickname, htmlEntries)
|
||||||
|
|
||||||
|
override fun onTalkStateChanged(clientId: Int, talking: Boolean) {
|
||||||
|
if (clientId == conn.selfClientId && micOwner) app.talking(talking)
|
||||||
|
scheduleTree(structure = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onError(message: String) = app.error(message)
|
||||||
|
|
||||||
|
override fun onPoke(fromName: String, message: String) = app.poked(fromName, message)
|
||||||
|
|
||||||
|
override fun onServerLog(message: String) = append(ChatKey.Server, "", ChatHtml.serverLog(message))
|
||||||
|
override fun onServerLogHtml(html: String) = append(ChatKey.Server, "", ChatHtml.logEntry(html))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun key(scope: ChatScope, peerClientId: Int): ChatKey = when (scope) {
|
||||||
|
ChatScope.SERVER -> ChatKey.Server
|
||||||
|
ChatScope.CHANNEL -> ChatKey.Channel
|
||||||
|
ChatScope.PRIVATE -> ChatKey.Private(peerClientId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TREE_FRAME_MS = 30L
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package com.ts3client.android
|
||||||
|
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import androidx.compose.ui.graphics.ImageBitmap
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import com.ts3client.net.TeamspeakConnection
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One server's icons and its clients' avatars, decoded once. What is still
|
||||||
|
* downloading reads as null until [version] moves on.
|
||||||
|
*/
|
||||||
|
class ServerImages internal constructor(private val conn: TeamspeakConnection) {
|
||||||
|
|
||||||
|
private val icons = ConcurrentHashMap<Long, ImageBitmap>()
|
||||||
|
private val avatars = ConcurrentHashMap<String, Picture>()
|
||||||
|
|
||||||
|
private val _version = MutableStateFlow(0)
|
||||||
|
val version: StateFlow<Int> = _version
|
||||||
|
|
||||||
|
internal fun loaded() = _version.update { it + 1 }
|
||||||
|
|
||||||
|
fun icon(id: Long): ImageBitmap? {
|
||||||
|
if (id == 0L) return null
|
||||||
|
icons[id]?.let { return it }
|
||||||
|
val bitmap = conn.icons.get(id)?.let { decode(it, ICON_PX) } ?: return null
|
||||||
|
icons[id] = bitmap
|
||||||
|
return bitmap
|
||||||
|
}
|
||||||
|
|
||||||
|
fun avatar(uniqueId: String, flag: String): Picture? {
|
||||||
|
if (flag.isEmpty()) return null
|
||||||
|
avatars[flag]?.let { return it }
|
||||||
|
val picture = conn.avatars.get(uniqueId, flag)?.let { decodePicture(it.data(), AVATAR_PX) } ?: return null
|
||||||
|
avatars[flag] = picture
|
||||||
|
return picture
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The avatar's file as the server keeps it; null while it has none or it is still downloading. */
|
||||||
|
fun avatarData(uniqueId: String, flag: String): ByteArray? =
|
||||||
|
if (flag.isEmpty()) null else conn.avatars.get(uniqueId, flag)?.data()
|
||||||
|
|
||||||
|
/** Decoded no larger than needed: avatars may be far bigger than they are ever shown. */
|
||||||
|
private fun decode(data: ByteArray, maxPx: Int): ImageBitmap? {
|
||||||
|
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||||
|
BitmapFactory.decodeByteArray(data, 0, data.size, bounds)
|
||||||
|
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
|
||||||
|
var sample = 1
|
||||||
|
while (maxOf(bounds.outWidth, bounds.outHeight) / (sample * 2) >= maxPx) sample *= 2
|
||||||
|
val options = BitmapFactory.Options().apply { inSampleSize = sample }
|
||||||
|
return BitmapFactory.decodeByteArray(data, 0, data.size, options)?.asImageBitmap()
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val ICON_PX = 64
|
||||||
|
const val AVATAR_PX = 512
|
||||||
|
}
|
||||||
|
}
|
||||||
185
android/app/src/main/java/com/ts3client/android/ServerTree.kt
Normal file
185
android/app/src/main/java/com/ts3client/android/ServerTree.kt
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
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.ServerModel
|
||||||
|
import com.ts3client.net.Spacers
|
||||||
|
import com.ts3client.net.TreeMatch
|
||||||
|
import com.ts3client.text.TextQuery
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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(),
|
||||||
|
/** The server's own icon; 0 for none. */
|
||||||
|
val serverIconId: Long = 0,
|
||||||
|
/** Whether client rows show their country's flag. */
|
||||||
|
val countryFlags: Boolean = false,
|
||||||
|
) {
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The channels from the root down to the one [clientId] is in; null when they are not in the tree. */
|
||||||
|
fun pathTo(clientId: Int): List<Int>? {
|
||||||
|
fun walk(items: List<ChannelItem>): List<Int>? {
|
||||||
|
for (c in items) {
|
||||||
|
if (c.clients.any { it.id == clientId }) return listOf(c.id)
|
||||||
|
walk(c.children)?.let { return listOf(c.id) + it }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return walk(channels)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The channels from the root down to [channelId]'s parent; null when it is not in the tree. */
|
||||||
|
fun parentsOf(channelId: Int): List<Int>? {
|
||||||
|
fun walk(items: List<ChannelItem>, above: List<Int>): List<Int>? {
|
||||||
|
for (c in items) {
|
||||||
|
if (c.id == channelId) return above
|
||||||
|
walk(c.children, above + c.id)?.let { return it }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return walk(channels, emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The ids of what [find] matches, in reading order. */
|
||||||
|
fun hits(find: TreeFind): List<Int> {
|
||||||
|
val out = ArrayList<Int>()
|
||||||
|
fun walk(items: List<ChannelItem>) {
|
||||||
|
for (c in items) {
|
||||||
|
if (find.matches(c)) out += c.id
|
||||||
|
c.clients.forEach { if (find.matches(it)) out += it.id }
|
||||||
|
walk(c.children)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!find.query.isEmpty) walk(channels)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Only what [find] matches: a matching channel with its clients, a matching client, and the channels leading to either. */
|
||||||
|
fun only(find: TreeFind): ServerTree {
|
||||||
|
fun keep(c: ChannelItem): ChannelItem? {
|
||||||
|
val matches = find.matches(c)
|
||||||
|
val clients = if (matches) c.clients else c.clients.filter(find::matches)
|
||||||
|
val children = c.children.mapNotNull(::keep)
|
||||||
|
return if (matches || clients.isNotEmpty() || children.isNotEmpty()) c.copy(clients = clients, children = children) else null
|
||||||
|
}
|
||||||
|
return copy(channels = channels.mapNotNull(::keep))
|
||||||
|
}
|
||||||
|
|
||||||
|
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>, model: ServerModel, contacts: ContactStore, colorContacts: Boolean,
|
||||||
|
countryFlags: Boolean): ServerTree =
|
||||||
|
ServerTree(
|
||||||
|
roots.map { channel(it, model, contacts, colorContacts) },
|
||||||
|
model.serverIconId(),
|
||||||
|
countryFlags,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun channel(c: ChannelNode, model: ServerModel, contacts: ContactStore, colorContacts: Boolean): ChannelItem {
|
||||||
|
val clients = c.clients
|
||||||
|
val spacer = Spacers.parse(c.name)
|
||||||
|
return ChannelItem(
|
||||||
|
id = c.id,
|
||||||
|
name = c.name ?: "",
|
||||||
|
spacer = spacer,
|
||||||
|
iconId = if (spacer == null) c.iconId else 0,
|
||||||
|
locked = c.hasPassword,
|
||||||
|
full = c.maxClients >= 0 && clients.size >= c.maxClients,
|
||||||
|
subscribed = c.subscribed,
|
||||||
|
clients = clients.map { client(it, model, contacts, colorContacts) },
|
||||||
|
children = c.children.map { channel(it, model, contacts, colorContacts) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun client(c: ClientEntry, model: ServerModel, 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,
|
||||||
|
contactType = contact?.type,
|
||||||
|
highlight = if (colorContacts) contact?.type ?: ContactType.NEUTRAL else ContactType.NEUTRAL,
|
||||||
|
groupIconIds = (model.serverGroupsOf(c.serverGroupIds) + listOfNotNull(model.channelGroup(c.channelGroupId)))
|
||||||
|
.map { it.iconId }.filter { it != 0L },
|
||||||
|
avatarFlag = c.avatarFlag.orEmpty(),
|
||||||
|
country = c.country.orEmpty(),
|
||||||
|
serverGroupIds = c.serverGroupIds.toList(),
|
||||||
|
channelGroupId = c.channelGroupId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the find bar looks for in the tree: clients, or with [channels] channels. */
|
||||||
|
@Immutable
|
||||||
|
data class TreeFind(val query: TextQuery, val channels: Boolean) {
|
||||||
|
fun matches(c: ChannelItem) = channels && TreeMatch.channel(query, c.name)
|
||||||
|
fun matches(c: ClientItem) = !channels && TreeMatch.client(query, c.nickname, c.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
@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,
|
||||||
|
/** 0 for none. */
|
||||||
|
val iconId: Long,
|
||||||
|
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,
|
||||||
|
/** How the client is filed in the contacts; null when it is not one. */
|
||||||
|
val contactType: ContactType?,
|
||||||
|
/** The contact colour the name is drawn in, NEUTRAL when contacts are not coloured. */
|
||||||
|
val highlight: ContactType,
|
||||||
|
/** The icons of the client's server groups, then of its channel group, as TeamSpeak lines them up. */
|
||||||
|
val groupIconIds: List<Long>,
|
||||||
|
/** The MD5 of the client's avatar; empty for none. */
|
||||||
|
val avatarFlag: String,
|
||||||
|
/** The code of the country the server places the client in; empty when it cannot tell. */
|
||||||
|
val country: String,
|
||||||
|
val serverGroupIds: List<Int>,
|
||||||
|
val channelGroupId: Int,
|
||||||
|
)
|
||||||
@@ -0,0 +1,475 @@
|
|||||||
|
package com.ts3client.android
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import com.ts3client.android.audio.AndroidAudioBackend
|
||||||
|
import com.ts3client.android.audio.AudioRoute
|
||||||
|
import com.ts3client.audio.AudioDevice
|
||||||
|
import com.ts3client.config.BanReasonPresets
|
||||||
|
import com.ts3client.config.Bookmark
|
||||||
|
import com.ts3client.config.Bookmarks
|
||||||
|
import com.ts3client.config.IdentityStore
|
||||||
|
import com.ts3client.config.Settings
|
||||||
|
import com.ts3client.contacts.Contact
|
||||||
|
import com.ts3client.contacts.ContactStore
|
||||||
|
import com.ts3client.myts.MyTeamSpeak
|
||||||
|
import com.ts3client.net.HostButton
|
||||||
|
import com.ts3client.myts.MyTeamSpeakLogin
|
||||||
|
import com.ts3client.session.SelfState
|
||||||
|
import com.ts3client.session.ServerSession
|
||||||
|
import com.ts3client.session.Sessions
|
||||||
|
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 kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.File
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
|
||||||
|
/** What the app shows of a connection and the local client on it. */
|
||||||
|
data class SessionState(
|
||||||
|
val title: String = "",
|
||||||
|
val status: String = "Not connected",
|
||||||
|
val connected: Boolean = false,
|
||||||
|
val busy: Boolean = false,
|
||||||
|
/** Whether this server holds the microphone, which only one server can at a time. */
|
||||||
|
val micActive: Boolean = false,
|
||||||
|
val micPermission: Boolean = false,
|
||||||
|
val micMuted: Boolean = false,
|
||||||
|
val deafened: Boolean = false,
|
||||||
|
val away: Boolean = false,
|
||||||
|
val selfClientId: Int = 0,
|
||||||
|
val channelId: Int = 0,
|
||||||
|
val inputMode: Settings.InputMode = Settings.InputMode.VOICE_ACTIVATION,
|
||||||
|
val hostButton: HostButton? = null,
|
||||||
|
/** What the tree shows for the local client, and the connection notification too. */
|
||||||
|
val self: SelfState = SelfState.DISCONNECTED,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 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 servers the app is on and what they share, outliving any activity: [ConnectionService]
|
||||||
|
* keeps the process in the foreground while any is connected. Only one server captures with
|
||||||
|
* the microphone at a time; [Sessions] hands it over.
|
||||||
|
*/
|
||||||
|
class SessionController(private val context: Context) {
|
||||||
|
|
||||||
|
val settings: Settings = Settings.load()
|
||||||
|
val icons = IconTheme(context, settings)
|
||||||
|
val pictures = WebPictures(context.cacheDir, settings)
|
||||||
|
internal val cacheDir: File get() = context.cacheDir
|
||||||
|
private val bookmarkStore = Bookmarks.load()
|
||||||
|
internal val identityStore = IdentityStore.load(settings)
|
||||||
|
val identities = IdentityManager(context, identityStore, settings, bookmarkStore) {
|
||||||
|
_bookmarks.value = bookmarkStore.all().toList()
|
||||||
|
}
|
||||||
|
internal val audio = AndroidAudioBackend(context)
|
||||||
|
private val soundPlayer = SpeakingSoundPlayer(context, audio.createSoundPlayer(settings))
|
||||||
|
internal val sounds = SoundNotifier(settings).also { it.setPlayer(soundPlayer) }
|
||||||
|
internal val notifier = MessageNotifier(context)
|
||||||
|
internal val contactStore = ContactStore.load()
|
||||||
|
/** The reasons offered when adding a ban; shared by every server. */
|
||||||
|
val banReasons: BanReasonPresets by lazy { BanReasonPresets.load() }
|
||||||
|
internal val network = NetworkFollower(context)
|
||||||
|
|
||||||
|
private val main = Handler(Looper.getMainLooper())
|
||||||
|
private val nextId = AtomicInteger()
|
||||||
|
/** Touched on the main thread only, as [Sessions] wants. */
|
||||||
|
private val sessions = Sessions { main.post(::microphoneMoved) }
|
||||||
|
|
||||||
|
private val _servers = MutableStateFlow(emptyList<ServerController>())
|
||||||
|
/** Every server the app is on or getting on, in the order they were connected. */
|
||||||
|
val servers: StateFlow<List<ServerController>> = _servers
|
||||||
|
|
||||||
|
private val _current = MutableStateFlow<ServerController?>(null)
|
||||||
|
/** The server on screen; null when there is none. */
|
||||||
|
val current: StateFlow<ServerController?> = _current
|
||||||
|
|
||||||
|
private val _micOwner = MutableStateFlow<ServerController?>(null)
|
||||||
|
/** The server capturing with the microphone; null when none is. */
|
||||||
|
val micOwner: StateFlow<ServerController?> = _micOwner
|
||||||
|
|
||||||
|
/** Why the last connection that ended did, for the connect form to show; null when it was left. */
|
||||||
|
private val _lastEnd = MutableStateFlow<String?>(null)
|
||||||
|
val lastEnd: StateFlow<String?> = _lastEnd
|
||||||
|
|
||||||
|
/** Copies of the contacts, as the store's own entries change in place. */
|
||||||
|
private val _contacts = MutableStateFlow(contactCopies())
|
||||||
|
val contacts: StateFlow<List<Contact>> = _contacts
|
||||||
|
|
||||||
|
private val _bookmarks = MutableStateFlow(bookmarkStore.all().toList())
|
||||||
|
val bookmarks: StateFlow<List<Bookmark>> = _bookmarks
|
||||||
|
|
||||||
|
private val _events = MutableSharedFlow<SessionEvent>(extraBufferCapacity = 16)
|
||||||
|
val events: SharedFlow<SessionEvent> = _events
|
||||||
|
|
||||||
|
private val _onScreen = MutableStateFlow(false)
|
||||||
|
val onScreen: StateFlow<Boolean> = _onScreen
|
||||||
|
|
||||||
|
/** Whether the app is on screen; pokes and private messages are notified of while it is not. */
|
||||||
|
var foreground: Boolean
|
||||||
|
get() = _onScreen.value
|
||||||
|
set(value) {
|
||||||
|
_onScreen.value = value
|
||||||
|
if (value) notifier.clearPokes()
|
||||||
|
// The process may be let go of any time once out of sight.
|
||||||
|
else contactStore.flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
private val levels = LevelMonitor(settings, audio)
|
||||||
|
val micLevel: StateFlow<MicLevel> = levels.level
|
||||||
|
@Volatile
|
||||||
|
private var watchingLevel = false
|
||||||
|
|
||||||
|
private val routePrefs = AudioRoutePrefs(context)
|
||||||
|
private val _route = MutableStateFlow(routePrefs.load())
|
||||||
|
val route: StateFlow<AudioRoute> = _route
|
||||||
|
|
||||||
|
private val _inputDevices = MutableStateFlow(audio.io().inputDevices())
|
||||||
|
val inputDevices: StateFlow<List<AudioDevice>> = _inputDevices
|
||||||
|
private val _outputDevices = MutableStateFlow(audio.io().outputDevices())
|
||||||
|
val outputDevices: StateFlow<List<AudioDevice>> = _outputDevices
|
||||||
|
|
||||||
|
val unprocessedSupported: Boolean get() = audio.unprocessedSupported()
|
||||||
|
|
||||||
|
init {
|
||||||
|
audio.setRoute(_route.value)
|
||||||
|
audio.addDeviceListener {
|
||||||
|
_inputDevices.value = audio.io().inputDevices()
|
||||||
|
_outputDevices.value = audio.io().outputDevices()
|
||||||
|
}
|
||||||
|
// Mutations report on whichever thread made them, a connection thread included.
|
||||||
|
contactStore.addListener {
|
||||||
|
_contacts.value = contactCopies()
|
||||||
|
_servers.value.forEach(ServerController::contactsChanged)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The nickname a connect with [identityId] goes by: the identity's own, else the global one. */
|
||||||
|
fun nicknameFor(identityId: String): String = identities.nicknameOf(identityId).ifBlank { settings.nickname }
|
||||||
|
val lastAddress: String get() = settings.lastAddress
|
||||||
|
|
||||||
|
// ---- connection ----
|
||||||
|
|
||||||
|
/** Connects to `host[:port]`, as another server; an empty [identityId] means the default identity. */
|
||||||
|
fun connect(address: String, nickname: String, password: String = "", identityId: String = "") {
|
||||||
|
val (host, port) = parseAddress(address)
|
||||||
|
settings.lastAddress = address
|
||||||
|
if (identities.nicknameOf(identityId).isBlank()) settings.nickname = nickname
|
||||||
|
settings.save()
|
||||||
|
start(host, port, nickname, password, identityId, "", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun connect(bookmark: Bookmark) {
|
||||||
|
start(bookmark.address, bookmark.port, bookmark.nickname.orEmpty(), 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) {
|
||||||
|
val server = ServerController(nextId.incrementAndGet(), this)
|
||||||
|
sessions.add(server.session)
|
||||||
|
_servers.update { it + server }
|
||||||
|
_current.value = server
|
||||||
|
_lastEnd.value = null
|
||||||
|
context.startForegroundService(Intent(context, ConnectionService::class.java))
|
||||||
|
server.start(host, port, nickname, password, identityId, channel, channelPassword)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun select(server: ServerController) {
|
||||||
|
if (server in _servers.value) _current.value = server
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Leaves every server before returning, for when the app goes away with the connections. */
|
||||||
|
fun leave() {
|
||||||
|
val servers = _servers.value
|
||||||
|
servers.forEach(ServerController::leaving)
|
||||||
|
ServerSession.shutdownAll(servers.map { it.session })
|
||||||
|
contactStore.flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun server(id: Int): ServerController? = _servers.value.firstOrNull { it.id == id }
|
||||||
|
|
||||||
|
/** A server's state changed; one that is done with goes from the list. */
|
||||||
|
internal fun serverChanged(server: ServerController) {
|
||||||
|
if (server.micOwner) updateLevels()
|
||||||
|
if (server.ended) main.post { remove(server) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun remove(server: ServerController) {
|
||||||
|
if (server !in _servers.value || !server.ended) return
|
||||||
|
sessions.remove(server.session)
|
||||||
|
val rest = _servers.value - server
|
||||||
|
_servers.value = rest
|
||||||
|
if (_current.value == server) _current.value = rest.lastOrNull()
|
||||||
|
server.dispose()
|
||||||
|
val reason = server.state.value.status
|
||||||
|
_lastEnd.value = if (server.left) null else reason
|
||||||
|
if (!server.left && rest.isNotEmpty()) error("${server.state.value.title}: $reason")
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun connected(server: ServerController) {
|
||||||
|
// The first connect generates an identity when there was none.
|
||||||
|
identities.refresh()
|
||||||
|
main.post {
|
||||||
|
// Without the permission yet, the UI hands the microphone over once it is granted.
|
||||||
|
if (hasMicPermission()) sessions.connected(server.session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun disconnected(server: ServerController) {
|
||||||
|
main.post { sessions.disconnected(server.session) }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun error(message: String) {
|
||||||
|
_events.tryEmit(SessionEvent.Error(message))
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun poked(from: String, message: String) {
|
||||||
|
_events.tryEmit(SessionEvent.Poke(from, message))
|
||||||
|
if (!foreground) notifier.poke(from, BBCode.toPlain(message))
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun talking(transmitting: Boolean) = levels.talking(transmitting)
|
||||||
|
|
||||||
|
// ---- bookmarks ----
|
||||||
|
|
||||||
|
fun addBookmark(label: String, address: String, nickname: String, password: String, identityId: String) {
|
||||||
|
val (host, port) = parseAddress(address)
|
||||||
|
// Only a nickname other than the identity's is the server's own.
|
||||||
|
val own = nickname.takeIf { it != nicknameFor(identityId) }.orEmpty()
|
||||||
|
bookmarkStore.add(Bookmark(label.ifBlank { address }, host, port, own, password).also {
|
||||||
|
it.identityId = identityId
|
||||||
|
})
|
||||||
|
saveBookmarks()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Connects the bookmark with another identity; empty for the default one. */
|
||||||
|
fun setBookmarkIdentity(bookmark: Bookmark, identityId: String) {
|
||||||
|
val index = bookmarkStore.all().indexOf(bookmark)
|
||||||
|
if (index < 0) return
|
||||||
|
// A new entry, so the list the UI holds changes along with it.
|
||||||
|
bookmarkStore.all()[index] = bookmark.copy().also { it.identityId = identityId }
|
||||||
|
saveBookmarks()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeBookmark(bookmark: Bookmark) {
|
||||||
|
val index = bookmarkStore.all().indexOf(bookmark)
|
||||||
|
if (index < 0) return
|
||||||
|
bookmarkStore.remove(index)
|
||||||
|
saveBookmarks()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bookmarks leading to [address]:[port] take on the icon the server shows now. */
|
||||||
|
internal fun serverIconChanged(address: String, port: Int, iconId: Long) {
|
||||||
|
main.post {
|
||||||
|
val all = bookmarkStore.all()
|
||||||
|
var changed = false
|
||||||
|
for (i in all.indices) {
|
||||||
|
val b = all[i]
|
||||||
|
if (!b.pointsAt(address, port) || b.iconId == iconId) continue
|
||||||
|
// A new entry, so the list the UI holds changes along with it.
|
||||||
|
all[i] = b.copy().also { it.iconId = iconId }
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if (changed) saveBookmarks()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveBookmarks() {
|
||||||
|
bookmarkStore.save()
|
||||||
|
_bookmarks.value = bookmarkStore.all().toList()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- contacts ----
|
||||||
|
|
||||||
|
private fun contactCopies(): List<Contact> = contactStore.all().map { it.copy() }
|
||||||
|
|
||||||
|
/** A copy of the contact to edit and hand back to [saveContact]. */
|
||||||
|
fun contact(uniqueId: String): Contact? = contactStore.find(uniqueId)?.copy()
|
||||||
|
|
||||||
|
fun saveContact(contact: Contact) = contactStore.put(contact)
|
||||||
|
|
||||||
|
fun removeContact(uniqueId: String) = contactStore.remove(uniqueId)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds the contacts of a TeamSpeak 3 `settings.db`, overwriting entries for the same clients.
|
||||||
|
*
|
||||||
|
* @return how many contacts the file held and how many of them were new
|
||||||
|
*/
|
||||||
|
suspend fun importContacts(uri: Uri): Pair<Int, Int> = withContext(Dispatchers.IO) {
|
||||||
|
val found = withLocalCopy(context, uri, TeamSpeakSettingsDb::readContacts)
|
||||||
|
found.size to if (found.isEmpty()) 0 else contactStore.merge(found)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- myTeamSpeak ----
|
||||||
|
|
||||||
|
/** The account the app stays signed in to; its reads block, so they run on [Dispatchers.IO]. */
|
||||||
|
val myTeamSpeak: MyTeamSpeakLogin = MyTeamSpeakLogin.load()
|
||||||
|
private val importer = TeamSpeakImporter(identityStore, bookmarkStore, settings)
|
||||||
|
|
||||||
|
suspend fun signInMyTeamSpeak(email: String, password: String): MyTeamSpeak.Account =
|
||||||
|
withContext(Dispatchers.IO) { myTeamSpeak.signIn(email, password) }
|
||||||
|
|
||||||
|
suspend fun fetchMyTeamSpeak(): MyTeamSpeak.Account = withContext(Dispatchers.IO) { myTeamSpeak.fetch() }
|
||||||
|
|
||||||
|
fun isImported(item: SyncItem): Boolean = importer.isPresent(item)
|
||||||
|
|
||||||
|
/** Imports [chosen] out of the account's [all] items, with the identities the chosen bookmarks use. */
|
||||||
|
suspend fun importMyTeamSpeak(all: List<SyncItem>, chosen: List<SyncItem>): TeamSpeakImporter.Result =
|
||||||
|
withContext(Dispatchers.IO) { importer.importSelected(all, chosen) }
|
||||||
|
.also {
|
||||||
|
_bookmarks.value = bookmarkStore.all().toList()
|
||||||
|
identities.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- sound packs ----
|
||||||
|
|
||||||
|
private val _soundPacks = MutableStateFlow(sounds.availablePacks())
|
||||||
|
val soundPacks: StateFlow<List<SoundPack>> = _soundPacks
|
||||||
|
|
||||||
|
val soundPack: SoundPack? get() = sounds.pack()
|
||||||
|
|
||||||
|
fun setCountryFlags(shown: Boolean) {
|
||||||
|
settings.countryFlags = shown
|
||||||
|
settings.save()
|
||||||
|
servers.value.forEach { it.scheduleTree(structure = false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setDescriptionPictures(shown: Boolean) {
|
||||||
|
settings.descriptionPictures = shown
|
||||||
|
settings.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
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() =
|
||||||
|
context.checkSelfPermission(Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED
|
||||||
|
|
||||||
|
/** Moves the microphone to [server]; needs RECORD_AUDIO granted. */
|
||||||
|
fun giveMicrophoneTo(server: ServerController) {
|
||||||
|
if (server.isConnected) sessions.giveMicrophoneTo(server.session)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Opens the microphone of the server holding it while push-to-talk is held. */
|
||||||
|
fun setPushToTalk(pressed: Boolean) = sessions.setPushToTalk(pressed)
|
||||||
|
|
||||||
|
private fun microphoneMoved() {
|
||||||
|
val owner = sessions.microphoneOwner()
|
||||||
|
for (server in _servers.value) {
|
||||||
|
server.micOwner = server.session == owner
|
||||||
|
server.refresh()
|
||||||
|
}
|
||||||
|
_micOwner.value = _servers.value.firstOrNull { it.micOwner }
|
||||||
|
updateLevels()
|
||||||
|
}
|
||||||
|
|
||||||
|
private val overlayPrefs = context.getSharedPreferences("overlay", Context.MODE_PRIVATE)
|
||||||
|
private val _pttOverlay = MutableStateFlow(overlayPrefs.getBoolean(OVERLAY_ENABLED, false))
|
||||||
|
/** Whether push to talk floats a button over other apps while this one is off screen. */
|
||||||
|
val pttOverlay: StateFlow<Boolean> = _pttOverlay
|
||||||
|
|
||||||
|
fun setPttOverlay(enabled: Boolean) {
|
||||||
|
overlayPrefs.edit().putBoolean(OVERLAY_ENABLED, enabled).apply()
|
||||||
|
_pttOverlay.value = enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Switches between push to talk and voice activation; returns the new mode. */
|
||||||
|
fun toggleInputMode(): Settings.InputMode {
|
||||||
|
settings.inputMode = if (settings.inputMode == Settings.InputMode.PUSH_TO_TALK)
|
||||||
|
Settings.InputMode.VOICE_ACTIVATION else Settings.InputMode.PUSH_TO_TALK
|
||||||
|
applySettings()
|
||||||
|
return settings.inputMode
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Saves the settings and puts the voice and device ones into effect at once. */
|
||||||
|
fun applySettings() {
|
||||||
|
settings.save()
|
||||||
|
soundPlayer.setOutputDevice(settings.outputDevice)
|
||||||
|
try {
|
||||||
|
for (server in _servers.value) {
|
||||||
|
server.playback?.setMasterVolume(settings.outputVolume)
|
||||||
|
server.playback?.setOutputDevice(settings.outputDevice)
|
||||||
|
server.microphone?.apply(settings)
|
||||||
|
server.refresh()
|
||||||
|
server.scheduleTree(structure = false)
|
||||||
|
}
|
||||||
|
levels.apply()
|
||||||
|
} catch (e: RuntimeException) {
|
||||||
|
error(e.message ?: "Microphone unavailable")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Microphone preset and playback usage; the open lines move over at once. */
|
||||||
|
fun setRoute(route: AudioRoute) {
|
||||||
|
routePrefs.save(route)
|
||||||
|
_route.value = route
|
||||||
|
audio.setRoute(route)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Listens to the microphone for the level meter while [on]. */
|
||||||
|
fun watchLevel(on: Boolean) {
|
||||||
|
watchingLevel = on
|
||||||
|
updateLevels()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateLevels() {
|
||||||
|
val owner = _micOwner.value
|
||||||
|
levels.update(watchingLevel, owner?.microphone, owner?.isBusy == true, hasMicPermission())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- notifications ----
|
||||||
|
|
||||||
|
/** Shows a private chat a notification was tapped for. */
|
||||||
|
fun requestChat(serverId: Int, clientId: Int) {
|
||||||
|
val server = server(serverId) ?: return
|
||||||
|
_current.value = server
|
||||||
|
server.requestChat(clientId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Answers a private message from its notification. */
|
||||||
|
fun reply(serverId: Int, clientId: Int, text: String) {
|
||||||
|
server(serverId)?.reply(clientId, text) ?: notifier.clear(serverId, clientId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val DEFAULT_PORT = 9987
|
||||||
|
const val OVERLAY_ENABLED = "enabled"
|
||||||
|
|
||||||
|
fun parseAddress(address: String): Pair<String, Int> {
|
||||||
|
val host = address.substringBefore(':').trim()
|
||||||
|
val port = address.substringAfter(':', "").trim().toIntOrNull() ?: DEFAULT_PORT
|
||||||
|
return host to port
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.ts3client.android
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import com.ts3client.config.AppDirs
|
||||||
|
|
||||||
|
class Ts3Application : Application() {
|
||||||
|
|
||||||
|
lateinit var sessions: SessionController
|
||||||
|
private set
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
// 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package com.ts3client.android
|
||||||
|
|
||||||
|
import android.util.LruCache
|
||||||
|
import com.ts3client.config.Settings
|
||||||
|
import com.ts3client.net.PictureDownloads
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.File
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
|
/** The pictures descriptions show, fetched by core's downloads and decoded while there is room. */
|
||||||
|
class WebPictures(cacheDir: File, private val settings: Settings) {
|
||||||
|
|
||||||
|
sealed interface State {
|
||||||
|
data object Loading : State
|
||||||
|
data object Broken : State
|
||||||
|
data class Shown(val picture: Picture) : State
|
||||||
|
}
|
||||||
|
|
||||||
|
private val downloads = PictureDownloads(File(cacheDir, "pictures"), false)
|
||||||
|
private val decoded = LruCache<String, Picture>(CACHED)
|
||||||
|
private val undecodable = ConcurrentHashMap.newKeySet<String>()
|
||||||
|
|
||||||
|
private val _version = MutableStateFlow(0)
|
||||||
|
/** Moves on whenever a download has come or failed. */
|
||||||
|
val version: StateFlow<Int> = _version
|
||||||
|
|
||||||
|
init {
|
||||||
|
downloads.addListener { _version.update { it + 1 } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether pictures are shown at all, rather than links to them. */
|
||||||
|
val enabled: Boolean get() = settings.descriptionPictures
|
||||||
|
|
||||||
|
suspend fun state(url: String): State {
|
||||||
|
decoded.get(url)?.let { return State.Shown(it) }
|
||||||
|
if (url in undecodable) return State.Broken
|
||||||
|
val file = downloads.get(url) ?: return if (downloads.failed(url)) State.Broken else State.Loading
|
||||||
|
val picture = withContext(Dispatchers.IO) { decodePicture(file.readBytes(), MAX_PX) }
|
||||||
|
if (picture == null) {
|
||||||
|
undecodable += url
|
||||||
|
return State.Broken
|
||||||
|
}
|
||||||
|
decoded.put(url, picture)
|
||||||
|
return State.Shown(picture)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The picture's file as downloaded; null while it is not there. */
|
||||||
|
suspend fun data(url: String): ByteArray? = withContext(Dispatchers.IO) { downloads.get(url)?.readBytes() }
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val CACHED = 32
|
||||||
|
const val MAX_PX = 1024
|
||||||
|
}
|
||||||
|
}
|
||||||
105
android/app/src/main/java/com/ts3client/android/ui/App.kt
Normal file
105
android/app/src/main/java/com/ts3client/android/ui/App.kt
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
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.CompositionLocalProvider
|
||||||
|
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.saveable.rememberSaveableStateHolder
|
||||||
|
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 current by vm.current.collectAsStateWithLifecycle()
|
||||||
|
// Each server keeps its own screen state (chat open, conversation) while another is shown.
|
||||||
|
val serverStates = rememberSaveableStateHolder()
|
||||||
|
var connectOpen by rememberSaveable { mutableStateOf(false) }
|
||||||
|
val snackbar = remember { SnackbarHostState() }
|
||||||
|
var settingsOpen by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var contactsOpen by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var editingContact by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
|
// Set when a contact's page was opened from a client, so leaving it goes back there.
|
||||||
|
var contactDirect 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.current.value?.let(vm::giveMicrophoneTo)
|
||||||
|
}
|
||||||
|
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()
|
||||||
|
connectOpen = false
|
||||||
|
val ask = missing(Manifest.permission.RECORD_AUDIO, Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
if (ask.isNotEmpty()) permissions.launch(ask.toTypedArray())
|
||||||
|
}
|
||||||
|
val enableMicrophone: () -> Unit = {
|
||||||
|
if (missing(Manifest.permission.RECORD_AUDIO).isEmpty()) vm.current.value?.let(vm::giveMicrophoneTo)
|
||||||
|
else permissions.launch(arrayOf(Manifest.permission.RECORD_AUDIO))
|
||||||
|
}
|
||||||
|
|
||||||
|
val openContacts: (String?) -> Unit = { uniqueId ->
|
||||||
|
contactsOpen = true
|
||||||
|
editingContact = uniqueId
|
||||||
|
contactDirect = uniqueId != null
|
||||||
|
}
|
||||||
|
|
||||||
|
CompositionLocalProvider(LocalIconTheme provides vm.icons, LocalWebPictures provides vm.pictures) {
|
||||||
|
val server = current
|
||||||
|
when {
|
||||||
|
contactsOpen -> ContactsScreen(vm, editingContact, onEdit = { uniqueId ->
|
||||||
|
if (uniqueId == null && contactDirect) contactsOpen = false
|
||||||
|
contactDirect = false
|
||||||
|
editingContact = uniqueId
|
||||||
|
}, onBack = { contactsOpen = false })
|
||||||
|
settingsOpen -> SettingsScreen(vm, onContacts = { openContacts(null) }, onBack = { settingsOpen = false })
|
||||||
|
server != null && !connectOpen -> {
|
||||||
|
serverStates.SaveableStateProvider(server.id) {
|
||||||
|
CompositionLocalProvider(LocalServerImages provides server.images) {
|
||||||
|
PictureViewerHost(server) {
|
||||||
|
ServerScreen(vm, server, snackbar, { settingsOpen = true }, openContacts, enableMicrophone,
|
||||||
|
onConnectOther = { connectOpen = true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> ConnectScreen(vm, snackbar, { settingsOpen = true }, connectAsking,
|
||||||
|
onBack = if (current != null) ({ connectOpen = false }) else null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
poke?.let { p ->
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { poke = null },
|
||||||
|
title = { Text("${p.from} poked you") },
|
||||||
|
text = if (p.message.isNotBlank()) ({ Text(p.message) }) else null,
|
||||||
|
confirmButton = { TextButton(onClick = { poke = null }) { Text("OK") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
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.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.imePadding
|
||||||
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
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.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
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.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.ts3client.android.ServerController
|
||||||
|
import com.ts3client.config.BanReasonPresets
|
||||||
|
import com.ts3client.net.ban.BanEntry
|
||||||
|
import com.ts3client.net.ban.BanPattern
|
||||||
|
import com.ts3client.net.ban.BanRule
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.format.FormatStyle
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/** What the ban list shows instead of itself: a ban's editor, a new or an existing ban's. */
|
||||||
|
private sealed interface BanEdit {
|
||||||
|
data object New : BanEdit
|
||||||
|
data class Existing(val ban: BanEntry) : BanEdit
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The server's ban list, as TeamSpeak's Ban List window: searchable, own bans apart, add, edit and remove. */
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun BanListScreen(server: ServerController, presets: BanReasonPresets, onBack: () -> Unit) {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val snackbar = remember { SnackbarHostState() }
|
||||||
|
var bans by remember { mutableStateOf<List<BanEntry>?>(null) }
|
||||||
|
var status by remember { mutableStateOf("Loading…") }
|
||||||
|
/** Bumped to read the list again. */
|
||||||
|
var generation by remember { mutableIntStateOf(0) }
|
||||||
|
var query by rememberSaveable { mutableStateOf("") }
|
||||||
|
var onlyOwn by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var shown by remember { mutableStateOf<BanEntry?>(null) }
|
||||||
|
var edit by remember { mutableStateOf<BanEdit?>(null) }
|
||||||
|
|
||||||
|
LaunchedEffect(generation) {
|
||||||
|
status = "Loading…"
|
||||||
|
server.banList()
|
||||||
|
.onSuccess {
|
||||||
|
bans = it
|
||||||
|
status = if (it.size == 1) "1 ban" else "${it.size} bans"
|
||||||
|
}
|
||||||
|
.onFailure {
|
||||||
|
bans = emptyList()
|
||||||
|
status = "Failed to request ban list: ${it.message}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reports a failed change, or re-reads the list to show what the server now holds. */
|
||||||
|
val change = { action: suspend () -> String?, failure: String ->
|
||||||
|
scope.launch {
|
||||||
|
val error = action()
|
||||||
|
if (error != null) snackbar.showSnackbar("$failure: $error")
|
||||||
|
generation++
|
||||||
|
}
|
||||||
|
Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
edit?.let { e ->
|
||||||
|
val existing = (e as? BanEdit.Existing)?.ban
|
||||||
|
BanEditor(existing, presets, server.maxBanSeconds, onBack = { edit = null }) { rule ->
|
||||||
|
edit = null
|
||||||
|
if (existing == null) change({ server.addBan(rule) }, "Error adding ban")
|
||||||
|
else change({ server.replaceBan(existing.id, rule) }, "Error editing ban")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
SubScreen(
|
||||||
|
"Ban list", onBack,
|
||||||
|
actions = {
|
||||||
|
if (server.canCreateBans) IconButton(onClick = { edit = BanEdit.New }) { PackIcon("ADD", "Add") }
|
||||||
|
IconButton(onClick = { generation++ }) { PackIcon("REFRESH", "Reload") }
|
||||||
|
},
|
||||||
|
snackbar = snackbar,
|
||||||
|
) { padding ->
|
||||||
|
val needle = query.trim().lowercase(Locale.ROOT)
|
||||||
|
val filtered = remember(bans, needle, onlyOwn) {
|
||||||
|
bans.orEmpty().filter { ban ->
|
||||||
|
(!onlyOwn || server.isOwnBan(ban)) && (needle.isEmpty() || searchText(ban).contains(needle))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Column(Modifier.padding(padding).imePadding().fillMaxSize()) {
|
||||||
|
Row(Modifier.padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
OutlinedTextField(query, { query = it }, Modifier.weight(1f), placeholder = { Text("Search") },
|
||||||
|
singleLine = true, leadingIcon = { PackIcon("SEARCH", null) })
|
||||||
|
FilterChip(onlyOwn, { onlyOwn = !onlyOwn }, label = { Text("Own") })
|
||||||
|
}
|
||||||
|
Text(status, Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
LazyColumn(Modifier.weight(1f)) {
|
||||||
|
items(filtered, key = { it.id }) { ban ->
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text(ban.targets(), maxLines = 2, overflow = TextOverflow.Ellipsis) },
|
||||||
|
supportingContent = {
|
||||||
|
Column {
|
||||||
|
if (ban.reason.isNotEmpty()) Text(ban.reason, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
Text("${ban.invokerName} · ${expiry(ban)}", style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.clickable { shown = ban },
|
||||||
|
)
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
shown?.let { ban ->
|
||||||
|
BanSheet(
|
||||||
|
ban,
|
||||||
|
onDismiss = { shown = null },
|
||||||
|
onEdit = if (server.canEditBan(ban)) ({ shown = null; edit = BanEdit.Existing(ban) }) else null,
|
||||||
|
onDelete = if (server.canDeleteBans(listOf(ban))) ({
|
||||||
|
shown = null
|
||||||
|
change({ server.deleteBans(listOf(ban.id)) }, "Error deleting ban")
|
||||||
|
}) else null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun searchText(ban: BanEntry): String =
|
||||||
|
listOf(ban.targets(), ban.reason, ban.invokerName, date(ban.createdEpochSeconds), expiry(ban))
|
||||||
|
.joinToString("\n").lowercase(Locale.ROOT)
|
||||||
|
|
||||||
|
private val DATE: DateTimeFormatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT)
|
||||||
|
|
||||||
|
private fun date(epochSeconds: Long): String =
|
||||||
|
if (epochSeconds <= 0) "" else DATE.format(Instant.ofEpochSecond(epochSeconds).atZone(ZoneId.systemDefault()))
|
||||||
|
|
||||||
|
private fun expiry(ban: BanEntry): String = if (ban.isPermanent) "Never expires" else "Expires ${date(ban.expiresEpochSeconds())}"
|
||||||
|
|
||||||
|
/** Everything about one ban, and what may be done with it. */
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun BanSheet(ban: BanEntry, onDismiss: () -> Unit, onEdit: (() -> Unit)?, onDelete: (() -> Unit)?) {
|
||||||
|
var confirmDelete by remember { mutableStateOf(false) }
|
||||||
|
ModalBottomSheet(onDismissRequest = onDismiss) {
|
||||||
|
Column(Modifier.verticalScroll(rememberScrollState()).navigationBarsPadding().padding(bottom = 16.dp)) {
|
||||||
|
Column(Modifier.padding(horizontal = 24.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
Fact("Name", ban.name)
|
||||||
|
Fact("IP", ban.ip)
|
||||||
|
Fact("Unique ID", ban.uid)
|
||||||
|
Fact("Last nickname", ban.lastNickname)
|
||||||
|
Fact("myTeamSpeak ID", ban.mytsid)
|
||||||
|
Fact("Reason", ban.reason)
|
||||||
|
Fact("Creator", ban.invokerName)
|
||||||
|
Fact("Created", date(ban.createdEpochSeconds))
|
||||||
|
Fact("Expires", if (ban.isPermanent) "Never" else date(ban.expiresEpochSeconds()))
|
||||||
|
Fact("Enforced", ban.enforcements.toString())
|
||||||
|
}
|
||||||
|
onEdit?.let { Action("EDIT", "Edit", onClick = it) }
|
||||||
|
onDelete?.let { Action("DELETE", "Delete") { confirmDelete = true } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (confirmDelete && onDelete != null) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { confirmDelete = false },
|
||||||
|
title = { Text("Delete ban?") },
|
||||||
|
text = { Text(ban.targets()) },
|
||||||
|
confirmButton = { TextButton(onClick = { confirmDelete = false; onDelete() }) { Text("Delete") } },
|
||||||
|
dismissButton = { TextButton(onClick = { confirmDelete = false }) { Text("Cancel") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Fact(label: String, value: String) {
|
||||||
|
if (value.isEmpty()) return
|
||||||
|
Column {
|
||||||
|
Text(label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
Text(value, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Adds a ban, or rewrites [existing]: what it matches, why, and for how long. */
|
||||||
|
@Composable
|
||||||
|
private fun BanEditor(
|
||||||
|
existing: BanEntry?,
|
||||||
|
presets: BanReasonPresets,
|
||||||
|
maxSeconds: Long,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
onSave: (BanRule) -> Unit,
|
||||||
|
) {
|
||||||
|
val decoded = remember { BanPattern.decode(existing?.ip, existing?.name) }
|
||||||
|
var ip by remember { mutableStateOf(decoded.ip) }
|
||||||
|
var name by remember { mutableStateOf(decoded.name) }
|
||||||
|
var pattern by remember { mutableStateOf(if (existing == null) BanPattern.WILDCARD_IPV4 else decoded.pattern) }
|
||||||
|
var uid by remember { mutableStateOf(existing?.uid.orEmpty()) }
|
||||||
|
var mytsid by remember { mutableStateOf(existing?.mytsid.orEmpty()) }
|
||||||
|
var reason by remember { mutableStateOf(existing?.reason.orEmpty()) }
|
||||||
|
val duration = remember { BanDurationState(existing?.durationSeconds ?: DEFAULT_BAN_SECONDS) }
|
||||||
|
|
||||||
|
val rule = BanRule(pattern.ipToRegex(ip), pattern.nameToRegex(name), uid, mytsid, duration.seconds, reason)
|
||||||
|
val ipValid = BanPattern.isValidRegex(rule.ipRegex)
|
||||||
|
val nameValid = BanPattern.isValidRegex(rule.nameRegex)
|
||||||
|
val valid = rule.hasTarget() && ipValid && nameValid && duration.valid
|
||||||
|
|
||||||
|
SubScreen(
|
||||||
|
if (existing == null) "Add ban" else "Edit ban", onBack,
|
||||||
|
actions = { TextButton(onClick = { onSave(rule) }, enabled = valid) { Text("Save") } },
|
||||||
|
) { padding ->
|
||||||
|
Column(
|
||||||
|
Modifier.padding(padding).imePadding().verticalScroll(rememberScrollState()).padding(horizontal = 16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
OutlinedTextField(ip, { ip = it }, Modifier.fillMaxWidth(), label = { Text("IP") }, singleLine = true,
|
||||||
|
isError = !ipValid)
|
||||||
|
OutlinedTextField(name, { name = it }, Modifier.fillMaxWidth(), label = { Text("Name") }, singleLine = true,
|
||||||
|
isError = !nameValid)
|
||||||
|
PatternField(pattern) { pattern = it }
|
||||||
|
OutlinedTextField(uid, { uid = it }, Modifier.fillMaxWidth(), label = { Text("Unique ID") }, singleLine = true)
|
||||||
|
OutlinedTextField(mytsid, { mytsid = it }, Modifier.fillMaxWidth(), label = { Text("myTeamSpeak ID") },
|
||||||
|
singleLine = true)
|
||||||
|
ReasonField(reason, { reason = it }, presets)
|
||||||
|
BanDurationField(duration, maxSeconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How the IP and name are written: wildcards, fixed strings or regular expressions. */
|
||||||
|
@Composable
|
||||||
|
private fun PatternField(pattern: BanPattern, onSelect: (BanPattern) -> Unit) {
|
||||||
|
var open by remember { mutableStateOf(false) }
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text("Interpret as", Modifier.weight(1f))
|
||||||
|
Box {
|
||||||
|
OutlinedButton(onClick = { open = true }) { Text(pattern.label()) }
|
||||||
|
DropdownMenu(open, { open = false }) {
|
||||||
|
for (p in BanPattern.entries) DropdownMenuItem({ Text(p.label()) }, onClick = { open = false; onSelect(p) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The reason, with the saved presets to pick from and the reason typed to save as one. */
|
||||||
|
@Composable
|
||||||
|
private fun ReasonField(reason: String, onChange: (String) -> Unit, presets: BanReasonPresets) {
|
||||||
|
var open by remember { mutableStateOf(false) }
|
||||||
|
var saved by remember { mutableStateOf(presets.all().toList()) }
|
||||||
|
Box {
|
||||||
|
Row(verticalAlignment = Alignment.Top) {
|
||||||
|
LimitedField(reason, onChange, "Reason", BanRule.REASON_LIMIT, Modifier.weight(1f))
|
||||||
|
IconButton(onClick = { open = true }, Modifier.padding(top = 8.dp)) { Icon(Icons.Filled.ArrowDropDown, "Presets") }
|
||||||
|
}
|
||||||
|
DropdownMenu(open, { open = false }, Modifier.align(Alignment.TopEnd)) {
|
||||||
|
for (preset in saved) {
|
||||||
|
DropdownMenuItem({ Text(preset, maxLines = 1, overflow = TextOverflow.Ellipsis) },
|
||||||
|
onClick = { open = false; onChange(preset.take(BanRule.REASON_LIMIT)) },
|
||||||
|
trailingIcon = {
|
||||||
|
IconButton(onClick = {
|
||||||
|
presets.remove(preset)
|
||||||
|
presets.save()
|
||||||
|
saved = presets.all().toList()
|
||||||
|
}) { PackIcon("DELETE", "Remove preset") }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (saved.isNotEmpty()) HorizontalDivider()
|
||||||
|
DropdownMenuItem({ Text("Save as preset") }, enabled = reason.isNotBlank(), onClick = {
|
||||||
|
open = false
|
||||||
|
presets.add(reason)
|
||||||
|
presets.save()
|
||||||
|
saved = presets.all().toList()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
package com.ts3client.android.ui
|
||||||
|
|
||||||
|
import androidx.compose.animation.animateColorAsState
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
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.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
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.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ProvideTextStyle
|
||||||
|
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.setValue
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.rotate
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
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.gfx.IconKeys
|
||||||
|
import com.ts3client.net.ClientStatus
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
|
||||||
|
/** A channel's or a client's row. */
|
||||||
|
data class TreeRef(val channel: Boolean, val id: Int)
|
||||||
|
|
||||||
|
/** One line of the tree as the list shows it. */
|
||||||
|
private sealed interface TreeRow {
|
||||||
|
val ref: TreeRef
|
||||||
|
val key: String get() = (if (ref.channel) "c" else "u") + ref.id
|
||||||
|
|
||||||
|
data class Channel(val channel: ChannelItem, val depth: Int, val expanded: Boolean, val current: Boolean) : TreeRow {
|
||||||
|
override val ref get() = TreeRef(true, channel.id)
|
||||||
|
val expandable get() = channel.clients.isNotEmpty() || channel.children.isNotEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
data class Client(val client: ClientItem, val depth: Int) : TreeRow {
|
||||||
|
override val ref get() = TreeRef(false, 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,
|
||||||
|
onServer: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
/** A row to scroll to and flash, once the channels above it are expanded. */
|
||||||
|
reveal: TreeRef? = null,
|
||||||
|
onRevealed: () -> Unit = {},
|
||||||
|
/** The rows the find bar matched, and the one it is on, which a reveal does not flash. */
|
||||||
|
found: Set<TreeRef> = emptySet(),
|
||||||
|
foundCurrent: TreeRef? = null,
|
||||||
|
) {
|
||||||
|
val rows = remember(tree, collapsed, currentChannel) { flatten(tree, collapsed, currentChannel) }
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
var flashing by remember { mutableStateOf<TreeRef?>(null) }
|
||||||
|
LaunchedEffect(reveal, rows) {
|
||||||
|
val ref = reveal ?: return@LaunchedEffect
|
||||||
|
val index = rows.indexOfFirst { it.ref == ref }
|
||||||
|
if (index < 0) return@LaunchedEffect
|
||||||
|
// Index 0 is the server's row. The row ends up in the middle of the list.
|
||||||
|
val middle = snapshotFlow { listState.layoutInfo.viewportSize.height }.first { it > 0 } / 2
|
||||||
|
listState.animateScrollToItem(index + 1, -middle)
|
||||||
|
if (ref != foundCurrent) flashing = ref
|
||||||
|
onRevealed()
|
||||||
|
}
|
||||||
|
LaunchedEffect(flashing) {
|
||||||
|
if (flashing != null) {
|
||||||
|
delay(1500)
|
||||||
|
flashing = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LazyColumn(modifier.fillMaxSize(), state = listState, contentPadding = PaddingValues(vertical = 4.dp)) {
|
||||||
|
item(key = "server") {
|
||||||
|
RowLayout(depth = 0, onClick = onServer) {
|
||||||
|
PackIcon("SERVER_GREEN", null, size = ICON)
|
||||||
|
Text(serverName, Modifier.weight(1f), fontWeight = FontWeight.Bold, maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis)
|
||||||
|
ServerIcon(tree.serverIconId, size = ICON)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items(rows, key = { it.key }) { row ->
|
||||||
|
val background = rowBackground(
|
||||||
|
when (row.ref) {
|
||||||
|
flashing -> FLASH
|
||||||
|
foundCurrent -> findCurrentColor()
|
||||||
|
in found -> findMatchColor()
|
||||||
|
else -> Color.Transparent
|
||||||
|
},
|
||||||
|
)
|
||||||
|
when (row) {
|
||||||
|
is TreeRow.Channel -> ChannelRow(row, background, onToggle, onChannel)
|
||||||
|
is TreeRow.Client -> ClientRow(row, tree.countryFlags, background, onClient)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val ICON = 16.dp
|
||||||
|
private val INDENT = 14.dp
|
||||||
|
private val ROW = 26.dp
|
||||||
|
/** Amber, see-through so the row's text keeps its own colour and contrast. */
|
||||||
|
private val FLASH = Color(0x80FFB300)
|
||||||
|
|
||||||
|
/** Fades in fast and out slowly, so a flash is seen. */
|
||||||
|
@Composable
|
||||||
|
private fun rowBackground(target: Color): Color {
|
||||||
|
val color by animateColorAsState(target, tween(if (target == FLASH) 150 else 600), label = "row")
|
||||||
|
return color
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RowLayout(
|
||||||
|
depth: Int,
|
||||||
|
onClick: (() -> Unit)?,
|
||||||
|
background: Color = Color.Transparent,
|
||||||
|
content: @Composable RowScope.() -> Unit,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(background)
|
||||||
|
.height(ROW)
|
||||||
|
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
|
||||||
|
.padding(start = 4.dp + INDENT * depth, end = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
ProvideTextStyle(MaterialTheme.typography.bodyMedium, content = { content() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ChannelRow(row: TreeRow.Channel, background: Color, onToggle: (ChannelItem) -> Unit, onChannel: (ChannelItem) -> Unit) {
|
||||||
|
val channel = row.channel
|
||||||
|
val spacer = channel.spacer
|
||||||
|
RowLayout(row.depth, onClick = { onChannel(channel) }, background = background) {
|
||||||
|
Box(
|
||||||
|
Modifier.fillMaxHeight().width(ICON + 4.dp)
|
||||||
|
.then(if (row.expandable) Modifier.clickable { onToggle(channel) } else Modifier),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
if (row.expandable) {
|
||||||
|
Icon(Icons.Filled.KeyboardArrowDown, if (row.expanded) "Collapse" else "Expand",
|
||||||
|
Modifier.size(ICON + 2.dp).rotate(if (row.expanded) 0f else -90f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (spacer != null) {
|
||||||
|
SpacerLabel(spacer.align(), spacer.caption().orEmpty(), Modifier.weight(1f))
|
||||||
|
} else {
|
||||||
|
PackIcon(IconKeys.channel(channel.locked, channel.full, channel.subscribed).asList(), null, size = ICON)
|
||||||
|
Text(
|
||||||
|
channel.name,
|
||||||
|
Modifier.weight(1f),
|
||||||
|
color = if (row.current) MaterialTheme.colorScheme.primary else Color.Unspecified,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
ServerIcon(channel.iconId, size = ICON)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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, countryFlags: Boolean, background: Color, onClient: (ClientItem) -> Unit) {
|
||||||
|
val client = row.client
|
||||||
|
RowLayout(row.depth, onClick = { onClient(client) }, background = background) {
|
||||||
|
Spacer(Modifier.width(ICON + 4.dp))
|
||||||
|
StatusIcon(client.status)
|
||||||
|
Row(Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
Text(
|
||||||
|
client.name,
|
||||||
|
fontWeight = if (client.self) FontWeight.Bold else FontWeight.Normal,
|
||||||
|
color = when (client.highlight) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (id in client.groupIconIds) ServerIcon(id, size = ICON)
|
||||||
|
// The country's flag comes last, at the row's edge.
|
||||||
|
if (countryFlags) CountryFlag(client.country)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun StatusIcon(status: ClientStatus, modifier: Modifier = Modifier) {
|
||||||
|
PackIcon(IconKeys.client(status), status.name, modifier, size = ICON)
|
||||||
|
}
|
||||||
179
android/app/src/main/java/com/ts3client/android/ui/ChatPane.kt
Normal file
179
android/app/src/main/java/com/ts3client/android/ui/ChatPane.kt
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
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.key
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
|
import androidx.compose.runtime.mutableStateMapOf
|
||||||
|
import androidx.compose.runtime.saveable.Saver
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.snapshots.SnapshotStateMap
|
||||||
|
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.ChatMatch
|
||||||
|
import com.ts3client.android.Chats
|
||||||
|
import com.ts3client.android.Conversation
|
||||||
|
import com.ts3client.text.TextQuery
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
|
||||||
|
@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,
|
||||||
|
/** The lines to show, when not all of [selected]'s. */
|
||||||
|
lines: List<ChatLine> = selected.lines,
|
||||||
|
find: TextQuery = TextQuery.EMPTY,
|
||||||
|
/** The match the find bar is on. */
|
||||||
|
current: ChatMatch? = null,
|
||||||
|
/** A line to scroll to. */
|
||||||
|
reveal: Long? = null,
|
||||||
|
onRevealed: () -> Unit = {},
|
||||||
|
) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Each conversation opens at its newest line.
|
||||||
|
key(selected.key) { Lines(lines, onLink, Modifier.weight(1f), find, current, reveal, onRevealed) }
|
||||||
|
// One unsent message per conversation, kept while another one is on screen.
|
||||||
|
val drafts = rememberSaveable(saver = DraftsSaver) { mutableStateMapOf<String, String>() }
|
||||||
|
val draftKey = selected.key.toString()
|
||||||
|
val draft = drafts[draftKey].orEmpty()
|
||||||
|
val send = {
|
||||||
|
if (draft.isNotBlank()) {
|
||||||
|
onSend(draft.trim())
|
||||||
|
drafts.remove(draftKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(Modifier.padding(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
OutlinedTextField(
|
||||||
|
draft, { drafts[draftKey] = 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val DraftsSaver = Saver<SnapshotStateMap<String, String>, HashMap<String, String>>(
|
||||||
|
save = { HashMap(it) },
|
||||||
|
restore = { mutableStateMapOf<String, String>().apply { putAll(it) } },
|
||||||
|
)
|
||||||
|
|
||||||
|
@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,
|
||||||
|
find: TextQuery,
|
||||||
|
current: ChatMatch?,
|
||||||
|
reveal: Long?,
|
||||||
|
onRevealed: () -> Unit,
|
||||||
|
) {
|
||||||
|
val listState = rememberLazyListState(initialFirstVisibleItemIndex = (lines.size - 1).coerceAtLeast(0))
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
LaunchedEffect(reveal, lines) {
|
||||||
|
val id = reveal ?: return@LaunchedEffect
|
||||||
|
val index = lines.indexOfFirst { it.id == id }
|
||||||
|
if (index < 0) return@LaunchedEffect
|
||||||
|
val info = listState.layoutInfo
|
||||||
|
if (info.visibleItemsInfo.none { it.index == index && it.offset >= 0 && it.offset + it.size <= info.viewportEndOffset }) {
|
||||||
|
// The line's top ends up in the middle of the list.
|
||||||
|
val middle = snapshotFlow { listState.layoutInfo.viewportSize.height }.first { it > 0 } / 2
|
||||||
|
listState.animateScrollToItem(index, -middle)
|
||||||
|
}
|
||||||
|
onRevealed()
|
||||||
|
}
|
||||||
|
LazyColumn(modifier.fillMaxWidth(), state = listState, contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp)) {
|
||||||
|
items(lines, key = { it.id }) { line ->
|
||||||
|
RichText(line.html, { link(it) }, Modifier.padding(vertical = 2.dp), MaterialTheme.typography.bodyMedium,
|
||||||
|
find, if (current?.line == line.id) current.nth else -1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
307
android/app/src/main/java/com/ts3client/android/ui/ChatText.kt
Normal file
307
android/app/src/main/java/com/ts3client/android/ui/ChatText.kt
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
package com.ts3client.android.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.LocalTextStyle
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
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.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.fromHtml
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.ts3client.android.WebPictures
|
||||||
|
import com.ts3client.text.BBCode
|
||||||
|
import com.ts3client.text.ChatHtml
|
||||||
|
import com.ts3client.text.TextQuery
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTML as [BBCode][com.ts3client.text.BBCode] and [ChatHtml] write it, with its rules as dividers
|
||||||
|
* and its pictures fetched from the web: Android's HTML parser shows neither, so the text is
|
||||||
|
* split at them.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun RichText(
|
||||||
|
html: String,
|
||||||
|
onLink: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
style: TextStyle = LocalTextStyle.current,
|
||||||
|
/** Matches to highlight: [current] is the index of the one the find bar is on, if it is here. */
|
||||||
|
find: TextQuery = TextQuery.EMPTY,
|
||||||
|
current: Int = -1,
|
||||||
|
) {
|
||||||
|
val link by rememberUpdatedState(onLink)
|
||||||
|
val color = linkColor()
|
||||||
|
val parsed = remember(html, color) { split(html) { chatText(it, color) { href -> link(href) } } }
|
||||||
|
val match = findMatchColor()
|
||||||
|
val currentMatch = findCurrentColor()
|
||||||
|
val pieces = remember(parsed, find, current, match, currentMatch) {
|
||||||
|
if (find.isEmpty) parsed else highlight(parsed, find, current, match, currentMatch)
|
||||||
|
}
|
||||||
|
Column(modifier) {
|
||||||
|
for (piece in pieces) {
|
||||||
|
when (piece) {
|
||||||
|
Piece.Rule -> HorizontalDivider(Modifier.padding(vertical = 6.dp))
|
||||||
|
is Piece.Image -> WebPicture(piece, style) { link(it) }
|
||||||
|
// As wide as there is room, for aligned paragraphs to have somewhere to go.
|
||||||
|
is Piece.Text -> if (piece.text.isNotEmpty()) Text(piece.text, Modifier.fillMaxWidth(), style = style)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** [pieces] with [find]'s matches behind [match], and the [current]th of them, counting across pieces, behind [currentMatch]. */
|
||||||
|
private fun highlight(pieces: List<Piece>, find: TextQuery, current: Int, match: Color, currentMatch: Color): List<Piece> {
|
||||||
|
var n = 0
|
||||||
|
return pieces.map { piece ->
|
||||||
|
if (piece !is Piece.Text) return@map piece
|
||||||
|
val m = find.matcher(piece.text.text) ?: return@map piece
|
||||||
|
val text = AnnotatedString.Builder(piece.text)
|
||||||
|
while (m.find()) {
|
||||||
|
if (m.end() == m.start()) continue
|
||||||
|
text.addStyle(SpanStyle(background = if (n == current) currentMatch else match), m.start(), m.end())
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
Piece.Text(text.toAnnotatedString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The web pictures descriptions show. */
|
||||||
|
val LocalWebPictures = staticCompositionLocalOf<WebPictures?> { null }
|
||||||
|
|
||||||
|
private sealed interface Piece {
|
||||||
|
data class Text(val text: AnnotatedString) : Piece
|
||||||
|
data object Rule : Piece
|
||||||
|
/** @param href where the picture links to, if it sits in a link */
|
||||||
|
data class Image(val url: String, val href: String?, val align: Alignment.Horizontal) : Piece
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [html] cut at its rules and pictures. Each piece of text is closed where it is cut and the
|
||||||
|
* next reopens what it was in, so an aligned block or a link carries on past a picture.
|
||||||
|
*/
|
||||||
|
private fun split(html: String, text: (String) -> AnnotatedString): List<Piece> {
|
||||||
|
val pieces = ArrayList<Piece>()
|
||||||
|
val divs = ArrayList<String>()
|
||||||
|
var anchor: String? = null
|
||||||
|
var href: String? = null
|
||||||
|
val current = StringBuilder()
|
||||||
|
fun cut() {
|
||||||
|
if (anchor != null) current.append("</a>")
|
||||||
|
repeat(divs.size) { current.append("</div>") }
|
||||||
|
pieces += Piece.Text(text(current.toString()))
|
||||||
|
current.setLength(0)
|
||||||
|
divs.forEach { current.append(it) }
|
||||||
|
anchor?.let { current.append(it) }
|
||||||
|
}
|
||||||
|
var last = 0
|
||||||
|
for (m in CUT_TAG.findAll(html)) {
|
||||||
|
current.append(html, last, m.range.first)
|
||||||
|
last = m.range.last + 1
|
||||||
|
val tag = m.value
|
||||||
|
when {
|
||||||
|
tag.startsWith("<hr", ignoreCase = true) -> {
|
||||||
|
cut()
|
||||||
|
pieces += Piece.Rule
|
||||||
|
}
|
||||||
|
tag.startsWith("<img", ignoreCase = true) -> {
|
||||||
|
cut()
|
||||||
|
pieces += Piece.Image(unescape(m.groupValues[1]), href?.let(::unescape), alignment(divs))
|
||||||
|
}
|
||||||
|
tag.startsWith("<div", ignoreCase = true) -> {
|
||||||
|
divs += tag
|
||||||
|
current.append(tag)
|
||||||
|
}
|
||||||
|
tag.equals("</div>", ignoreCase = true) -> {
|
||||||
|
divs.removeLastOrNull()
|
||||||
|
current.append(tag)
|
||||||
|
}
|
||||||
|
tag.startsWith("<a", ignoreCase = true) -> {
|
||||||
|
anchor = tag
|
||||||
|
href = m.groupValues[2]
|
||||||
|
current.append(tag)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
anchor = null
|
||||||
|
href = null
|
||||||
|
current.append(tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
current.append(html, last, html.length)
|
||||||
|
pieces += Piece.Text(text(current.toString()))
|
||||||
|
return pieces
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where the innermost aligned block puts what is in it. */
|
||||||
|
private fun alignment(divs: List<String>): Alignment.Horizontal {
|
||||||
|
val align = divs.asReversed().firstNotNullOfOrNull { ALIGN.find(it)?.groupValues?.get(1) }
|
||||||
|
return when (align) {
|
||||||
|
"center" -> Alignment.CenterHorizontally
|
||||||
|
"right" -> Alignment.End
|
||||||
|
else -> Alignment.Start
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun unescape(s: String) =
|
||||||
|
s.replace("<", "<").replace(">", ">").replace(""", "\"").replace("&", "&")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A picture at its own size, a pixel to a dp as TeamSpeak shows it, narrowed to fit. With
|
||||||
|
* pictures turned off, or when one cannot be had, its address as a link behind a picture icon.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun WebPicture(image: Piece.Image, style: TextStyle, onLink: (String) -> Unit) {
|
||||||
|
val pictures = LocalWebPictures.current
|
||||||
|
Column(Modifier.fillMaxWidth().padding(vertical = 2.dp), horizontalAlignment = image.align) {
|
||||||
|
if (pictures == null || !pictures.enabled) {
|
||||||
|
PictureLink("ICONVIEWER", image, style, onLink)
|
||||||
|
return@Column
|
||||||
|
}
|
||||||
|
val version by pictures.version.collectAsStateWithLifecycle()
|
||||||
|
val state by produceState<WebPictures.State>(WebPictures.State.Loading, image.url, version) {
|
||||||
|
value = pictures.state(image.url)
|
||||||
|
}
|
||||||
|
when (val shown = state) {
|
||||||
|
WebPictures.State.Loading -> PackIcon("LOADING_IMAGE", "Loading picture", size = 16.dp)
|
||||||
|
WebPictures.State.Broken -> PictureLink("BROKEN_IMAGE", image, style, onLink)
|
||||||
|
is WebPictures.State.Shown -> {
|
||||||
|
val picture = shown.picture
|
||||||
|
val viewer = LocalPictureViewer.current
|
||||||
|
Image(
|
||||||
|
rememberPicturePainter(picture),
|
||||||
|
null,
|
||||||
|
Modifier
|
||||||
|
.widthIn(max = picture.width.dp)
|
||||||
|
.aspectRatio(picture.width.toFloat() / picture.height.coerceAtLeast(1))
|
||||||
|
.then(if (viewer != null) Modifier.clickable {
|
||||||
|
val openLink = image.href?.let { href -> { onLink(href) } }
|
||||||
|
viewer(ViewedPicture(pictureName(image.url), image.url, openLink) { pictures.data(image.url) })
|
||||||
|
} else if (image.href != null) Modifier.clickable { onLink(image.href) } else Modifier),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The picture's address as a link behind [iconKey], then the link it sat in, if any, so neither is lost. */
|
||||||
|
@Composable
|
||||||
|
private fun PictureLink(iconKey: String, image: Piece.Image, style: TextStyle, onLink: (String) -> Unit) {
|
||||||
|
val color = linkColor()
|
||||||
|
val text = remember(image, color) {
|
||||||
|
val links = listOfNotNull(image.url, image.href?.takeIf { it != image.url })
|
||||||
|
chatText(links.joinToString(" ") { "<a href=\"${BBCode.escape(it)}\">${BBCode.escape(BBCode.shortAddress(it))}</a>" }, color, onLink)
|
||||||
|
}
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
PackIcon(iconKey, "Picture", size = 16.dp)
|
||||||
|
Text(text, style = style)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTML without rules as styled text. Android's parser knows no stylesheets, tables,
|
||||||
|
* numbered lists, font sizes or `align`, so the classes [ChatHtml] marks lines with become
|
||||||
|
* inline colours, table rows lines of their cells, numbered items numbered lines, sizes
|
||||||
|
* `<big>`/`<small>`, and alignment the style it does know.
|
||||||
|
*/
|
||||||
|
fun chatText(html: String, linkColor: Color, onLink: (String) -> Unit): AnnotatedString {
|
||||||
|
val styled = sizes(numbered(CLASS.replace(html) { m ->
|
||||||
|
CLASS_COLORS[m.groupValues[1]]?.let { "style=\"color:$it\"" } ?: ""
|
||||||
|
}))
|
||||||
|
.replace(ALIGN) { m -> "style=\"text-align:${ALIGNS.getValue(m.groupValues[1])}\"" }
|
||||||
|
.replace(CELL_END, " ")
|
||||||
|
.replace(ROW_END, "<br>")
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Numbers the items of each `<ol>`, innermost first. */
|
||||||
|
private fun numbered(html: String): String {
|
||||||
|
var out = html
|
||||||
|
while (true) {
|
||||||
|
val list = INNER_OL.find(out) ?: return out
|
||||||
|
val letters = list.groupValues[1].isNotEmpty()
|
||||||
|
var n = 0
|
||||||
|
val items = LI.replace(list.groupValues[2]) { m ->
|
||||||
|
if (m.value.startsWith("</")) "" else {
|
||||||
|
n++
|
||||||
|
"<br>" + (if (letters) ('a' + (n - 1) % 26).toString() else n.toString()) + ". "
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = out.replaceRange(list.range, "<div>$items</div>")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns each font-size span into `<big>`s or `<small>` (or a plain span), closing it alike.
|
||||||
|
* Sizes are TeamSpeak's, around its 11 px text.
|
||||||
|
*/
|
||||||
|
private fun sizes(html: String): String {
|
||||||
|
val closes = ArrayDeque<String>()
|
||||||
|
return SPAN.replace(html) { m ->
|
||||||
|
if (m.value.startsWith("</")) {
|
||||||
|
closes.removeLastOrNull() ?: m.value
|
||||||
|
} else {
|
||||||
|
val px = m.groupValues[1].toIntOrNull()
|
||||||
|
val tags = when {
|
||||||
|
px == null -> listOf("span")
|
||||||
|
px >= BIGGER_PX -> listOf("big", "big")
|
||||||
|
px >= BIG_PX -> listOf("big")
|
||||||
|
px <= SMALL_PX -> listOf("small")
|
||||||
|
else -> listOf("span")
|
||||||
|
}
|
||||||
|
closes.addLast(tags.reversed().joinToString("") { "</$it>" })
|
||||||
|
if (px == null) m.value else tags.joinToString("") { "<$it>" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val BIGGER_PX = 18
|
||||||
|
private const val BIG_PX = 13
|
||||||
|
private const val SMALL_PX = 9
|
||||||
|
|
||||||
|
private val INNER_OL = Regex("<ol( type=\"a\")?>((?:(?!<ol[ >]).)*?)</ol>", RegexOption.DOT_MATCHES_ALL)
|
||||||
|
private val LI = Regex("</?li>")
|
||||||
|
private val SPAN = Regex("<span style=\"font-size:(\\d+)px\">|<span[^>]*>|</span>")
|
||||||
|
private val ALIGNS = mapOf("left" to "start", "justify" to "start", "center" to "center", "right" to "end")
|
||||||
|
|
||||||
|
/** Where the text is cut, and what it has to carry across: aligned blocks and links. */
|
||||||
|
private val CUT_TAG = Regex(
|
||||||
|
"<hr\\s*/?>|<img src=\"([^\"]*)\">|<div[^>]*>|</div>|<a [^>]*href=\"([^\"]*)\"[^>]*>|</a>",
|
||||||
|
RegexOption.IGNORE_CASE,
|
||||||
|
)
|
||||||
|
private val CLASS = Regex("class=\"([a-z]+)\"")
|
||||||
|
private val ALIGN = Regex("align=\"(left|center|right|justify)\"")
|
||||||
|
private val CELL_END = Regex("</t[dh]>")
|
||||||
|
private val ROW_END = Regex("</tr>")
|
||||||
|
|
||||||
|
private val CLASS_COLORS = mapOf(
|
||||||
|
ChatHtml.MUTED_CLASS to "#8c8c8c",
|
||||||
|
ChatHtml.EVENT_CLASS to "#7a8fa6",
|
||||||
|
)
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
package com.ts3client.android.ui
|
||||||
|
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
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.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
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.material.icons.filled.MoreVert
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||||
|
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||||
|
import androidx.compose.material3.ExposedDropdownMenuAnchorType
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
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.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
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.IdentityInfo
|
||||||
|
import com.ts3client.android.SessionController
|
||||||
|
import com.ts3client.config.Bookmark
|
||||||
|
import com.ts3client.net.IconRepository
|
||||||
|
|
||||||
|
/** 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,
|
||||||
|
/** Back to the servers already connected; null when there are none. */
|
||||||
|
onBack: (() -> Unit)?,
|
||||||
|
) {
|
||||||
|
val lastEnd by vm.lastEnd.collectAsStateWithLifecycle()
|
||||||
|
val bookmarks by vm.bookmarks.collectAsStateWithLifecycle()
|
||||||
|
val identities by vm.identities.all.collectAsStateWithLifecycle()
|
||||||
|
var address by rememberSaveable { mutableStateOf(vm.lastAddress) }
|
||||||
|
var identityId by rememberSaveable { mutableStateOf("") }
|
||||||
|
var nickname by rememberSaveable { mutableStateOf(vm.nicknameFor("")) }
|
||||||
|
var password by rememberSaveable { mutableStateOf("") }
|
||||||
|
var naming by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var choosingFor by remember { mutableStateOf<Bookmark?>(null) }
|
||||||
|
|
||||||
|
if (onBack != null) BackHandler(onBack = onBack)
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("TeamSpeak") },
|
||||||
|
navigationIcon = {
|
||||||
|
if (onBack != null) IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") }
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = onSettings) { PackIcon("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))
|
||||||
|
IdentityField(identities, identityId) {
|
||||||
|
identityId = it
|
||||||
|
nickname = vm.nicknameFor(it)
|
||||||
|
}
|
||||||
|
lastEnd?.let {
|
||||||
|
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
}
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Button(
|
||||||
|
onClick = { onConnect { vm.connect(address.trim(), nickname.trim(), password, identityId) } },
|
||||||
|
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, identities, { onConnect { vm.connect(bookmark) } },
|
||||||
|
onChooseIdentity = { choosingFor = bookmark }, onRemove = { 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, identityId)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
choosingFor?.let { bookmark ->
|
||||||
|
IdentityDialog(identities, bookmark.identityId, onDismiss = { choosingFor = null }) {
|
||||||
|
choosingFor = null
|
||||||
|
vm.setBookmarkIdentity(bookmark, it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The identities to connect with, the default one first as an empty id. */
|
||||||
|
private fun identityChoices(identities: List<IdentityInfo>): List<Pair<String, String>> {
|
||||||
|
val default = identities.firstOrNull { it.isDefault }?.name
|
||||||
|
return listOf("" to (default?.let { "Default ($it)" } ?: "Default")) + identities.map { it.id to it.name }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An id no longer among the identities connects with the default, and shows as such. */
|
||||||
|
private fun shownId(identities: List<IdentityInfo>, id: String) = id.takeIf { i -> identities.any { it.id == i } } ?: ""
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun IdentityField(identities: List<IdentityInfo>, selected: String, onSelect: (String) -> Unit) {
|
||||||
|
val choices = identityChoices(identities)
|
||||||
|
val shown = shownId(identities, selected)
|
||||||
|
var open by remember { mutableStateOf(false) }
|
||||||
|
ExposedDropdownMenuBox(open, { open = it }) {
|
||||||
|
OutlinedTextField(
|
||||||
|
choices.first { it.first == shown }.second, {}, readOnly = true, singleLine = true,
|
||||||
|
label = { Text("Identity") },
|
||||||
|
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(open) },
|
||||||
|
modifier = Modifier.fillMaxWidth().menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable),
|
||||||
|
)
|
||||||
|
ExposedDropdownMenu(open, { open = false }) {
|
||||||
|
for ((id, name) in choices) {
|
||||||
|
DropdownMenuItem({ Text(name) }, onClick = {
|
||||||
|
open = false
|
||||||
|
onSelect(id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun IdentityDialog(identities: List<IdentityInfo>, selected: String, onDismiss: () -> Unit, onSelect: (String) -> Unit) {
|
||||||
|
val shown = shownId(identities, selected)
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text("Connect with") },
|
||||||
|
text = {
|
||||||
|
Column(Modifier.verticalScroll(rememberScrollState())) {
|
||||||
|
for ((id, name) in identityChoices(identities)) Choice(name, id == shown) { onSelect(id) }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {},
|
||||||
|
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The server's icon as last seen there, else the pack's bookmark icon. */
|
||||||
|
@Composable
|
||||||
|
private fun BookmarkIcon(iconId: Long) {
|
||||||
|
val cached = remember(iconId) {
|
||||||
|
IconRepository.cached(iconId)?.let { BitmapFactory.decodeByteArray(it, 0, it.size)?.asImageBitmap() }
|
||||||
|
}
|
||||||
|
if (cached != null) Image(cached, null, Modifier.size(24.dp))
|
||||||
|
else PackIcon("BOOKMARK_MANAGER", null)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun BookmarkRow(
|
||||||
|
bookmark: Bookmark,
|
||||||
|
identities: List<IdentityInfo>,
|
||||||
|
onConnect: () -> Unit,
|
||||||
|
onChooseIdentity: () -> Unit,
|
||||||
|
onRemove: () -> Unit,
|
||||||
|
) {
|
||||||
|
val identity = identities.firstOrNull { it.id == bookmark.identityId }
|
||||||
|
var menu by remember { mutableStateOf(false) }
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text(bookmark.displayName()) },
|
||||||
|
supportingContent = {
|
||||||
|
Text("${bookmark.address}:${bookmark.port}" + (identity?.let { " · ${it.name}" } ?: ""))
|
||||||
|
},
|
||||||
|
leadingContent = { BookmarkIcon(bookmark.iconId) },
|
||||||
|
trailingContent = {
|
||||||
|
Box {
|
||||||
|
IconButton(onClick = { menu = true }) { Icon(Icons.Default.MoreVert, "Bookmark options") }
|
||||||
|
DropdownMenu(menu, { menu = false }) {
|
||||||
|
DropdownMenuItem({ Text("Identity") }, onClick = { menu = false; onChooseIdentity() })
|
||||||
|
DropdownMenuItem({ Text("Remove") }, onClick = { menu = false; onRemove() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.clickable(onClick = onConnect),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
package com.ts3client.android.ui
|
||||||
|
|
||||||
|
import android.content.ClipData
|
||||||
|
import android.content.ClipboardManager
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
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.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.PrimaryTabRow
|
||||||
|
import androidx.compose.material3.Tab
|
||||||
|
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.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.ts3client.android.SessionController
|
||||||
|
import com.ts3client.contacts.Contact
|
||||||
|
import com.ts3client.contacts.ContactDefaults
|
||||||
|
import com.ts3client.contacts.ContactType
|
||||||
|
import com.ts3client.contacts.NicknameDisplay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
private val SEEN = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")
|
||||||
|
|
||||||
|
private val DISPLAYS = listOf(
|
||||||
|
NicknameDisplay.ONLY_NICKNAME to "Nickname",
|
||||||
|
NicknameDisplay.ONLY_CUSTOM to "Custom name",
|
||||||
|
NicknameDisplay.BOTH to "Both",
|
||||||
|
)
|
||||||
|
|
||||||
|
private val GROUPS = listOf(
|
||||||
|
ContactType.FRIEND to "Friends",
|
||||||
|
ContactType.BLOCKED to "Blocked",
|
||||||
|
ContactType.NEUTRAL to "Neutral",
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The contact list, grouped as TeamSpeak's Contacts window groups it, with each
|
||||||
|
* contact's own page and the defaults new contacts start out with.
|
||||||
|
*
|
||||||
|
* @param editing the unique id of the contact whose page is open, if any
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ContactsScreen(vm: SessionController, editing: String?, onEdit: (String?) -> Unit, onBack: () -> Unit) {
|
||||||
|
var defaults by rememberSaveable { mutableStateOf(false) }
|
||||||
|
when {
|
||||||
|
editing != null -> return ContactScreen(vm, editing, onBack = { onEdit(null) })
|
||||||
|
defaults -> return ContactDefaultsScreen(vm, onBack = { defaults = false })
|
||||||
|
}
|
||||||
|
|
||||||
|
val contacts by vm.contacts.collectAsStateWithLifecycle()
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
var menu by remember { mutableStateOf(false) }
|
||||||
|
var imported by remember { mutableStateOf<String?>(null) }
|
||||||
|
val importer = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
|
||||||
|
if (uri != null) scope.launch {
|
||||||
|
imported = runCatching { vm.importContacts(uri) }.fold(
|
||||||
|
{ (found, added) ->
|
||||||
|
if (found == 0) "That file holds no contacts."
|
||||||
|
else "Imported $found contacts, $added of them new."
|
||||||
|
},
|
||||||
|
{ "That file is not a TeamSpeak 3 settings.db." },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SubScreen("Contacts", onBack, actions = {
|
||||||
|
Box {
|
||||||
|
IconButton(onClick = { menu = true }) { Icon(Icons.Default.MoreVert, "More") }
|
||||||
|
DropdownMenu(menu, { menu = false }) {
|
||||||
|
DropdownMenuItem({ Text("Defaults for new contacts") }, onClick = { menu = false; defaults = true })
|
||||||
|
DropdownMenuItem({ Text("Import from TeamSpeak 3") }, onClick = {
|
||||||
|
menu = false
|
||||||
|
importer.launch(arrayOf("*/*"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}) { padding ->
|
||||||
|
LazyColumn(Modifier.padding(padding)) {
|
||||||
|
imported?.let { item { Hint(it) } }
|
||||||
|
if (contacts.isEmpty()) {
|
||||||
|
item {
|
||||||
|
Hint("No contacts yet. Tap a client on a server to add them as a friend or block them, " +
|
||||||
|
"or import the contacts of TeamSpeak 3 from its settings.db.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for ((type, title) in GROUPS) {
|
||||||
|
val group = contacts.filter { it.type == type }.sortedBy { label(it).lowercase() }
|
||||||
|
if (group.isEmpty()) continue
|
||||||
|
item(key = type) { Section(title) }
|
||||||
|
items(group, key = { it.uniqueId }) { contact ->
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text(label(contact), color = typeColor(contact.type)) },
|
||||||
|
supportingContent = { Text(lastSeen(contact)) },
|
||||||
|
modifier = Modifier.clickable { onEdit(contact.uniqueId) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One contact's settings; each is stored as it is changed, the names once the page is left. */
|
||||||
|
@Composable
|
||||||
|
private fun ContactScreen(vm: SessionController, uniqueId: String, onBack: () -> Unit) {
|
||||||
|
val contacts by vm.contacts.collectAsStateWithLifecycle()
|
||||||
|
val contact = contacts.firstOrNull { it.uniqueId == uniqueId }
|
||||||
|
if (contact == null) {
|
||||||
|
LaunchedEffect(Unit) { onBack() }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Starts from the stored entry, which may have been seen again since this page opened.
|
||||||
|
fun edit(change: Contact.() -> Unit) {
|
||||||
|
vm.contact(uniqueId)?.let { vm.saveContact(it.apply(change)) }
|
||||||
|
}
|
||||||
|
var customName by rememberSaveable(uniqueId) { mutableStateOf(contact.nickname) }
|
||||||
|
var phoneticName by rememberSaveable(uniqueId) { mutableStateOf(contact.phoneticNickname) }
|
||||||
|
var removing by remember { mutableStateOf(false) }
|
||||||
|
val leave = {
|
||||||
|
if (customName.trim() != contact.nickname || phoneticName.trim() != contact.phoneticNickname) {
|
||||||
|
edit {
|
||||||
|
nickname = customName.trim()
|
||||||
|
phoneticNickname = phoneticName.trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onBack()
|
||||||
|
}
|
||||||
|
|
||||||
|
SubScreen(label(contact), leave, actions = {
|
||||||
|
IconButton(onClick = { removing = true }) { PackIcon(if (contact.type == ContactType.BLOCKED) "REMOVE_FOE" else "REMOVE_FRIEND", "Remove from contacts") }
|
||||||
|
}) { padding ->
|
||||||
|
Column(Modifier.padding(padding).imePadding().verticalScroll(rememberScrollState()).padding(vertical = 8.dp)) {
|
||||||
|
OutlinedTextField(customName, { customName = it }, Modifier.fillMaxWidth().padding(horizontal = 16.dp),
|
||||||
|
label = { Text("Custom nickname") }, singleLine = true)
|
||||||
|
OutlinedTextField(phoneticName, { phoneticName = it }, Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
label = { Text("Phonetic nickname") }, singleLine = true)
|
||||||
|
|
||||||
|
Section("Contact")
|
||||||
|
Segments(GROUPS.map { (type, _) -> type to type.label() }, contact.type) { edit { type = it } }
|
||||||
|
|
||||||
|
Section("Show as")
|
||||||
|
Segments(DISPLAYS, contact.nicknameDisplay) { edit { nicknameDisplay = it } }
|
||||||
|
|
||||||
|
Section("Ignore")
|
||||||
|
Toggle("Mute", contact.automute) { edit { automute = it } }
|
||||||
|
Toggle("Ignore server and channel chat", contact.ignorePublicMessages) { edit { ignorePublicMessages = it } }
|
||||||
|
Toggle("Ignore private chat", contact.ignorePrivateMessages) { edit { ignorePrivateMessages = it } }
|
||||||
|
Toggle("Ignore pokes", contact.ignorePokes) { edit { ignorePokes = it } }
|
||||||
|
Toggle("Hide avatar", contact.ignoreAvatar) { edit { ignoreAvatar = it } }
|
||||||
|
Toggle("Hide away message", contact.ignoreAwayMessage) { edit { ignoreAwayMessage = it } }
|
||||||
|
Toggle("Deny whisper", !contact.whisperAllowed) { edit { whisperAllowed = !it } }
|
||||||
|
|
||||||
|
Section("Playback")
|
||||||
|
val volume = if (contact.hasVolumeModifier) contact.volumeModifier.toFloat() else 0f
|
||||||
|
Level("Volume", volume, -30f..20f, ::formatVolume) { db ->
|
||||||
|
val rounded = db.roundToInt().toDouble()
|
||||||
|
edit {
|
||||||
|
hasVolumeModifier = rounded != 0.0
|
||||||
|
volumeModifier = rounded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Details")
|
||||||
|
val context = LocalContext.current
|
||||||
|
LinkRow("Unique ID", contact.uniqueId) {
|
||||||
|
// The system confirms the copy itself.
|
||||||
|
context.getSystemService(ClipboardManager::class.java)
|
||||||
|
.setPrimaryClip(ClipData.newPlainText("Unique ID", contact.uniqueId))
|
||||||
|
}
|
||||||
|
ListItem(headlineContent = { Text(lastSeen(contact)) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (removing) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { removing = false },
|
||||||
|
title = { Text("Remove ${label(contact)}?") },
|
||||||
|
text = { Text("Their settings here are forgotten; they show as anyone else again.") },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
removing = false
|
||||||
|
vm.removeContact(uniqueId)
|
||||||
|
onBack()
|
||||||
|
}) { Text("Remove") }
|
||||||
|
},
|
||||||
|
dismissButton = { TextButton(onClick = { removing = false }) { Text("Cancel") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a client starts out with when added as a friend, blocked or neutral. */
|
||||||
|
@Composable
|
||||||
|
private fun ContactDefaultsScreen(vm: SessionController, onBack: () -> Unit) {
|
||||||
|
val defaults = vm.settings.contactDefaults
|
||||||
|
var tab by rememberSaveable { mutableStateOf(ContactType.FRIEND) }
|
||||||
|
var entry by remember(tab) { mutableStateOf(ContactDefaults.Entry().apply { copyFrom(defaults.of(tab)) }) }
|
||||||
|
fun edit(change: ContactDefaults.Entry.() -> Unit) {
|
||||||
|
val changed = ContactDefaults.Entry().apply { copyFrom(entry); change() }
|
||||||
|
defaults.of(tab).copyFrom(changed)
|
||||||
|
vm.settings.save()
|
||||||
|
entry = changed
|
||||||
|
}
|
||||||
|
|
||||||
|
SubScreen("Defaults for new contacts", onBack) { padding ->
|
||||||
|
Column(Modifier.padding(padding)) {
|
||||||
|
PrimaryTabRow(selectedTabIndex = GROUPS.indexOfFirst { it.first == tab }) {
|
||||||
|
for ((type, title) in GROUPS) Tab(tab == type, onClick = { tab = type }, text = { Text(title) })
|
||||||
|
}
|
||||||
|
Column(Modifier.verticalScroll(rememberScrollState()).padding(bottom = 8.dp)) {
|
||||||
|
Section("Show as")
|
||||||
|
Segments(DISPLAYS, entry.nicknameDisplay) { edit { nicknameDisplay = it } }
|
||||||
|
Section("Ignore")
|
||||||
|
Toggle("Mute", entry.automute) { edit { automute = it } }
|
||||||
|
Toggle("Ignore server and channel chat", entry.ignorePublicMessages) { edit { ignorePublicMessages = it } }
|
||||||
|
Toggle("Ignore private chat", entry.ignorePrivateMessages) { edit { ignorePrivateMessages = it } }
|
||||||
|
Toggle("Ignore pokes", entry.ignorePokes) { edit { ignorePokes = it } }
|
||||||
|
Toggle("Hide avatar", entry.ignoreAvatar) { edit { ignoreAvatar = it } }
|
||||||
|
Toggle("Hide away message", entry.ignoreAwayMessage) { edit { ignoreAwayMessage = it } }
|
||||||
|
Toggle("Deny whisper", entry.ignoreWhisper) { edit { ignoreWhisper = it } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun label(contact: Contact) = contact.nickname.ifBlank { contact.uniqueId }
|
||||||
|
|
||||||
|
private fun lastSeen(contact: Contact): String {
|
||||||
|
val seen = contact.lastSeen ?: return "Never seen"
|
||||||
|
val where = contact.lastSeenServerName.ifBlank { contact.lastSeenServerAddress }
|
||||||
|
return "Last seen ${SEEN.format(seen)}" + if (where.isNotBlank()) " on $where" else ""
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatVolume(db: Float): String {
|
||||||
|
val rounded = db.roundToInt()
|
||||||
|
return if (rounded == 0) "Unchanged" else "${if (rounded > 0) "+" else ""}$rounded dB"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun typeColor(type: ContactType): Color = when (type) {
|
||||||
|
ContactType.FRIEND -> StatusColors.friend
|
||||||
|
ContactType.BLOCKED -> StatusColors.blocked
|
||||||
|
ContactType.NEUTRAL -> Color.Unspecified
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
package com.ts3client.android.ui
|
||||||
|
|
||||||
|
import androidx.compose.animation.animateContentSize
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
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.draw.clipToBounds
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.layout.Layout
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.Constraints
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.ts3client.net.Details
|
||||||
|
import com.ts3client.text.BBCode
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The facts of the server, a channel or a client, then its description, for the top of its sheet.
|
||||||
|
* The minor facts wait behind "more…".
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun DetailsSection(details: Details, onLink: (String) -> Unit, modifier: Modifier = Modifier) {
|
||||||
|
var all by rememberSaveable(details.title()) { mutableStateOf(false) }
|
||||||
|
val shown = if (all) details.fields() else details.fields().filterNot { it.minor() }
|
||||||
|
val more = !all && shown.size < details.fields().size
|
||||||
|
Column(modifier.padding(horizontal = 24.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
if (shown.isNotEmpty() || more) {
|
||||||
|
Column(Modifier.animateContentSize(), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||||
|
for (field in shown) Fact(field)
|
||||||
|
if (more) {
|
||||||
|
Text("more…", Modifier.clickable { all = true }.padding(vertical = 2.dp),
|
||||||
|
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
when {
|
||||||
|
details.description().isNotBlank() -> Description(details.description(), onLink)
|
||||||
|
details.pending() -> Text("Loading description…", style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
private fun Fact(field: Details.Field) {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text(field.label(), Modifier.width(96.dp), style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
if (field.groups().isEmpty()) {
|
||||||
|
Text(field.value(), style = MaterialTheme.typography.bodySmall)
|
||||||
|
} else {
|
||||||
|
// Each group with its icon, as TeamSpeak's info frame lists them.
|
||||||
|
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
// A nameless group shows only its icon, and takes no comma.
|
||||||
|
val lastNamed = field.groups().indexOfLast { it.name.isNotBlank() }
|
||||||
|
field.groups().forEachIndexed { i, group ->
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
ServerIcon(group.iconId, size = 14.dp)
|
||||||
|
if (group.name.isNotBlank()) {
|
||||||
|
Text(group.name.trim() + if (i < lastNamed) "," else "", style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The description in a box that opens up, when there is more of it than fits folded. */
|
||||||
|
@Composable
|
||||||
|
private fun Description(bbcode: String, onLink: (String) -> Unit) {
|
||||||
|
val html = remember(bbcode) { BBCode.toHtml(bbcode, true) }
|
||||||
|
var expanded by rememberSaveable(bbcode) { mutableStateOf(false) }
|
||||||
|
var overflows by remember(bbcode) { mutableStateOf(false) }
|
||||||
|
val expandable = overflows || expanded
|
||||||
|
val background = MaterialTheme.colorScheme.surfaceContainerHigh
|
||||||
|
Surface(
|
||||||
|
Modifier.fillMaxWidth().then(if (expandable) Modifier.clickable { expanded = !expanded } else Modifier),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
color = background,
|
||||||
|
) {
|
||||||
|
Column(Modifier.animateContentSize().padding(12.dp)) {
|
||||||
|
Box {
|
||||||
|
Folded(FOLDED_HEIGHT, expanded, onOverflow = { overflows = it }) {
|
||||||
|
RichText(html, onLink, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
}
|
||||||
|
if (overflows && !expanded) {
|
||||||
|
Box(Modifier.align(Alignment.BottomCenter).fillMaxWidth().height(24.dp)
|
||||||
|
.background(Brush.verticalGradient(listOf(Color.Transparent, background))))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (expandable) {
|
||||||
|
Text(if (expanded) "Less" else "More", Modifier.padding(top = 4.dp),
|
||||||
|
style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Its content cut to [height] unless [open], telling whether there is more of it than that. */
|
||||||
|
@Composable
|
||||||
|
private fun Folded(height: Dp, open: Boolean, onOverflow: (Boolean) -> Unit, content: @Composable () -> Unit) {
|
||||||
|
val report by rememberUpdatedState(onOverflow)
|
||||||
|
Layout(content, Modifier.clipToBounds()) { measurables, constraints ->
|
||||||
|
val placeable = measurables.first().measure(constraints.copy(maxHeight = Constraints.Infinity))
|
||||||
|
val limit = height.roundToPx()
|
||||||
|
report(placeable.height > limit)
|
||||||
|
val shown = if (open) placeable.height else minOf(placeable.height, limit)
|
||||||
|
layout(placeable.width, shown) { placeable.place(0, 0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val FOLDED_HEIGHT = 128.dp
|
||||||
190
android/app/src/main/java/com/ts3client/android/ui/FindBar.kt
Normal file
190
android/app/src/main/java/com/ts3client/android/ui/FindBar.kt
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
package com.ts3client.android.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
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.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||||
|
import androidx.compose.material.icons.filled.KeyboardArrowUp
|
||||||
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
|
import androidx.compose.material3.Checkbox
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextField
|
||||||
|
import androidx.compose.material3.TextFieldDefaults
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.Stable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.listSaver
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
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.TextRange
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.input.TextFieldValue
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.ts3client.gfx.IconKeys
|
||||||
|
import com.ts3client.net.ClientStatus
|
||||||
|
import com.ts3client.text.TextQuery
|
||||||
|
|
||||||
|
/** What the find bar holds: the text, how to match it, and whether it is [open]. */
|
||||||
|
@Stable
|
||||||
|
class FindState(text: String = "", flags: Int = 0) {
|
||||||
|
var open by mutableStateOf(false)
|
||||||
|
var input by mutableStateOf(TextFieldValue(text))
|
||||||
|
var matchCase by mutableStateOf(flags and 1 != 0)
|
||||||
|
var wholeWord by mutableStateOf(flags and 2 != 0)
|
||||||
|
var regex by mutableStateOf(flags and 4 != 0)
|
||||||
|
/** Counts the times it was asked to show, for the field to take the focus each time. */
|
||||||
|
var shown by mutableStateOf(0)
|
||||||
|
private set
|
||||||
|
/** Show only what matches. */
|
||||||
|
var filter by mutableStateOf(flags and 8 != 0)
|
||||||
|
/** In the tree, find channels rather than clients. */
|
||||||
|
var channels by mutableStateOf(flags and 16 != 0)
|
||||||
|
|
||||||
|
/** What is typed, as it is to be matched; empty while the bar is closed. */
|
||||||
|
val query: TextQuery
|
||||||
|
get() = if (open) TextQuery(input.text, matchCase, wholeWord, regex) else TextQuery.EMPTY
|
||||||
|
|
||||||
|
/** [query], but nothing when it is not a valid regular expression. */
|
||||||
|
val usable: TextQuery
|
||||||
|
get() = query.let { if (it.isInvalid) TextQuery.EMPTY else it }
|
||||||
|
|
||||||
|
/** Opens the bar with what it holds selected, to be typed over. */
|
||||||
|
fun show() {
|
||||||
|
input = input.copy(selection = TextRange(0, input.text.length))
|
||||||
|
open = true
|
||||||
|
shown++
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val Saver = listSaver<FindState, Any>(
|
||||||
|
save = {
|
||||||
|
val flags = (if (it.matchCase) 1 else 0) or (if (it.wholeWord) 2 else 0) or (if (it.regex) 4 else 0) or
|
||||||
|
(if (it.filter) 8 else 0) or (if (it.channels) 16 else 0)
|
||||||
|
listOf(it.input.text, flags, it.open)
|
||||||
|
},
|
||||||
|
restore = { FindState(it[0] as String, it[1] as Int).apply { open = it[2] as Boolean } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The find bar, in the place of the top bar: the field, where the view is among the matches,
|
||||||
|
* previous/next, and the options. Enter goes to the next match, Shift+Enter to the previous.
|
||||||
|
*
|
||||||
|
* @param inTree whether it finds in the tree, which offers finding channels instead of clients
|
||||||
|
* @param position the 1-based match the view is on, or 0 for none
|
||||||
|
* @param more whether there are more matches than [total]
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun FindBar(state: FindState, inTree: Boolean, position: Int, total: Int, more: Boolean, onStep: (Int) -> Unit) {
|
||||||
|
val focus = remember { FocusRequester() }
|
||||||
|
LaunchedEffect(state.shown) { focus.requestFocus() }
|
||||||
|
val query = state.query
|
||||||
|
TopAppBar(
|
||||||
|
expandedHeight = 48.dp,
|
||||||
|
navigationIcon = { IconButton(onClick = { state.open = false }) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Close") } },
|
||||||
|
title = {
|
||||||
|
TextField(
|
||||||
|
state.input, { state.input = it },
|
||||||
|
Modifier.focusRequester(focus).onPreviewKeyEvent { e ->
|
||||||
|
when {
|
||||||
|
e.key == Key.Enter -> {
|
||||||
|
if (e.type == KeyEventType.KeyDown) onStep(if (e.isShiftPressed) -1 else 1)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
e.key == Key.Escape && e.type == KeyEventType.KeyDown -> {
|
||||||
|
state.open = false
|
||||||
|
true
|
||||||
|
}
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
placeholder = {
|
||||||
|
Text(if (!inTree) "Find" else if (state.channels) "Find channels" else "Find clients", maxLines = 1)
|
||||||
|
},
|
||||||
|
leadingIcon = if (inTree) ({
|
||||||
|
IconButton(onClick = { state.channels = !state.channels }) {
|
||||||
|
if (state.channels) PackIcon(IconKeys.channel(false, false, true).asList(), "Finding channels", size = 18.dp)
|
||||||
|
else PackIcon(IconKeys.client(ClientStatus.IDLE), "Finding clients", size = 18.dp)
|
||||||
|
}
|
||||||
|
}) else null,
|
||||||
|
trailingIcon = if (state.input.text.isNotEmpty()) ({
|
||||||
|
IconButton(onClick = { state.input = TextFieldValue() }) { Icon(Icons.Filled.Close, "Clear", Modifier.size(18.dp)) }
|
||||||
|
}) else null,
|
||||||
|
isError = query.isInvalid,
|
||||||
|
singleLine = true,
|
||||||
|
textStyle = MaterialTheme.typography.bodyLarge,
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||||
|
keyboardActions = KeyboardActions(onSearch = { onStep(1) }),
|
||||||
|
colors = TextFieldDefaults.colors(
|
||||||
|
focusedContainerColor = Color.Transparent,
|
||||||
|
unfocusedContainerColor = Color.Transparent,
|
||||||
|
errorContainerColor = Color.Transparent,
|
||||||
|
focusedIndicatorColor = Color.Transparent,
|
||||||
|
unfocusedIndicatorColor = Color.Transparent,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
if (!query.isEmpty) {
|
||||||
|
Text(
|
||||||
|
if (query.isInvalid) "!" else "${if (position > 0) "$position/" else ""}$total${if (more) "+" else ""}",
|
||||||
|
Modifier.padding(horizontal = 4.dp),
|
||||||
|
color = if (query.isInvalid || total == 0) MaterialTheme.colorScheme.error
|
||||||
|
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val navigable = total > 1 || (total == 1 && position == 0)
|
||||||
|
IconButton(onClick = { onStep(-1) }, enabled = navigable) { Icon(Icons.Filled.KeyboardArrowUp, "Previous") }
|
||||||
|
IconButton(onClick = { onStep(1) }, enabled = navigable) { Icon(Icons.Filled.KeyboardArrowDown, "Next") }
|
||||||
|
FindOptions(state)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun FindOptions(state: FindState) {
|
||||||
|
var open by remember { mutableStateOf(false) }
|
||||||
|
IconButton(onClick = { open = true }) { Icon(Icons.Filled.MoreVert, "Options") }
|
||||||
|
DropdownMenu(open, onDismissRequest = { open = false }) {
|
||||||
|
@Composable
|
||||||
|
fun option(label: String, checked: Boolean, set: (Boolean) -> Unit) = DropdownMenuItem(
|
||||||
|
text = { Text(label) },
|
||||||
|
onClick = { set(!checked) },
|
||||||
|
leadingIcon = { Checkbox(checked, set) },
|
||||||
|
)
|
||||||
|
option("Only matches", state.filter) { state.filter = it }
|
||||||
|
option("Match case", state.matchCase) { state.matchCase = it }
|
||||||
|
option("Whole words", state.wholeWord) { state.wholeWord = it }
|
||||||
|
option("Regular expression", state.regex) { state.regex = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package com.ts3client.android.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.ts3client.android.Picture
|
||||||
|
import com.ts3client.android.WebPictures
|
||||||
|
import com.ts3client.net.HostButton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The server's host button in the top bar: its own picture fitted to the bar's icons, or
|
||||||
|
* the pack's host button icon while there is none (yet).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun HostButtonAction(button: HostButton, onOpen: (String) -> Unit) {
|
||||||
|
val label = button.tooltip.ifEmpty { button.url }.ifEmpty { null }
|
||||||
|
val picture = hostPicture(button.gfxUrl)
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxHeight()
|
||||||
|
.clickable(enabled = button.url.isNotEmpty()) { onOpen(button.url) }
|
||||||
|
.padding(horizontal = 12.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
if (picture == null) {
|
||||||
|
PackIcon("HOSTER_BUTTON", label)
|
||||||
|
} else {
|
||||||
|
Image(
|
||||||
|
rememberPicturePainter(picture),
|
||||||
|
label,
|
||||||
|
Modifier
|
||||||
|
.widthIn(max = 24.dp * MAX_ASPECT)
|
||||||
|
.height(24.dp)
|
||||||
|
.aspectRatio(picture.width.toFloat() / picture.height.coerceAtLeast(1)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun hostPicture(url: String): Picture? {
|
||||||
|
val pictures = LocalWebPictures.current
|
||||||
|
if (pictures == null || url.isEmpty()) return null
|
||||||
|
val version by pictures.version.collectAsStateWithLifecycle()
|
||||||
|
val state by produceState<WebPictures.State>(WebPictures.State.Loading, url, version) {
|
||||||
|
value = pictures.state(url)
|
||||||
|
}
|
||||||
|
return (state as? WebPictures.State.Shown)?.picture
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keeps a banner-shaped picture from crowding out the server's name. */
|
||||||
|
private const val MAX_ASPECT = 6
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
package com.ts3client.android.ui
|
||||||
|
|
||||||
|
import android.content.ClipData
|
||||||
|
import android.content.ClipboardManager
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
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.rememberScrollState
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
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.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.ts3client.android.IdentityInfo
|
||||||
|
import com.ts3client.android.IdentityManager
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/** Beyond this a search takes years; TeamSpeak's own dialog stops there too. */
|
||||||
|
private const val MAX_SECURITY_LEVEL = 40
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The identities a server knows this client by, as TeamSpeak's Tools → Identities
|
||||||
|
* manages them, with each identity's own page.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun IdentitiesScreen(manager: IdentityManager, onBack: () -> Unit) {
|
||||||
|
var editing by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
|
editing?.let { id -> return IdentityScreen(manager, id, onBack = { editing = null }) }
|
||||||
|
|
||||||
|
val identities by manager.all.collectAsStateWithLifecycle()
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
var menu by remember { mutableStateOf(false) }
|
||||||
|
var creating by remember { mutableStateOf(false) }
|
||||||
|
var busy by remember { mutableStateOf(false) }
|
||||||
|
var message by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
fun work(failure: String, block: suspend () -> String?) {
|
||||||
|
busy = true
|
||||||
|
scope.launch {
|
||||||
|
message = try {
|
||||||
|
block()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
"$failure: ${e.message ?: e}"
|
||||||
|
}
|
||||||
|
busy = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val fileImporter = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
|
||||||
|
if (uri != null) work("That file is not a TeamSpeak identity") {
|
||||||
|
editing = manager.importFile(uri).id
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val dbImporter = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
|
||||||
|
if (uri != null) work("That file is not a TeamSpeak 3 settings.db") {
|
||||||
|
val r = manager.importFromTeamSpeak(uri)
|
||||||
|
if (r.identities() == 0) "That file holds no identities."
|
||||||
|
else "Imported ${r.identitiesAdded()} of ${r.identities()} identities."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SubScreen("Identities", onBack, actions = {
|
||||||
|
IconButton(onClick = { creating = true }, enabled = !busy) { PackIcon("IDENTITY_ADD", "New identity") }
|
||||||
|
Box {
|
||||||
|
IconButton(onClick = { menu = true }) { Icon(Icons.Default.MoreVert, "More") }
|
||||||
|
DropdownMenu(menu, { menu = false }) {
|
||||||
|
DropdownMenuItem({ Text("Import identity file") }, onClick = {
|
||||||
|
menu = false
|
||||||
|
fileImporter.launch(arrayOf("*/*"))
|
||||||
|
})
|
||||||
|
DropdownMenuItem({ Text("Import from TeamSpeak 3") }, onClick = {
|
||||||
|
menu = false
|
||||||
|
dbImporter.launch(arrayOf("*/*"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}) { padding ->
|
||||||
|
LazyColumn(Modifier.padding(padding)) {
|
||||||
|
if (busy) item { LinearProgressIndicator(Modifier.fillMaxWidth().padding(horizontal = 16.dp)) }
|
||||||
|
message?.let { item { Hint(it) } }
|
||||||
|
if (identities.isEmpty()) {
|
||||||
|
item {
|
||||||
|
Hint("No identities. One is created on the first connect.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items(identities, key = { it.id }) { identity ->
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text(identity.name, maxLines = 1, overflow = TextOverflow.Ellipsis) },
|
||||||
|
supportingContent = {
|
||||||
|
Text("Security level ${identity.securityLevel}", maxLines = 1)
|
||||||
|
},
|
||||||
|
trailingContent = if (identity.isDefault) ({ PackIcon("IDENTITY_DEFAULT", "Default") }) else null,
|
||||||
|
modifier = Modifier.clickable { editing = identity.id },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (creating) {
|
||||||
|
NewIdentityDialog(onDismiss = { creating = false }) { name ->
|
||||||
|
creating = false
|
||||||
|
work("Could not create the identity") {
|
||||||
|
editing = manager.generate(name).id
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One identity: its name, whether it is the default, its security level and unique ID. */
|
||||||
|
@Composable
|
||||||
|
private fun IdentityScreen(manager: IdentityManager, id: String, onBack: () -> Unit) {
|
||||||
|
val identities by manager.all.collectAsStateWithLifecycle()
|
||||||
|
val improving by manager.improving.collectAsStateWithLifecycle()
|
||||||
|
val identity = identities.firstOrNull { it.id == id }
|
||||||
|
if (identity == null) {
|
||||||
|
LaunchedEffect(Unit) { onBack() }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
var name by rememberSaveable(id) { mutableStateOf(identity.name) }
|
||||||
|
var nickname by rememberSaveable(id) { mutableStateOf(identity.nickname) }
|
||||||
|
var phonetic by rememberSaveable(id) { mutableStateOf(identity.phoneticNickname) }
|
||||||
|
var removing by remember { mutableStateOf(false) }
|
||||||
|
var raising by remember { mutableStateOf(false) }
|
||||||
|
var message by remember { mutableStateOf<String?>(null) }
|
||||||
|
val leave = {
|
||||||
|
val trimmed = name.trim()
|
||||||
|
if (trimmed.isNotEmpty() && trimmed != identity.name) manager.rename(id, trimmed)
|
||||||
|
if (nickname.trim() != identity.nickname || phonetic.trim() != identity.phoneticNickname) {
|
||||||
|
manager.setNicknames(id, nickname.trim(), phonetic.trim())
|
||||||
|
}
|
||||||
|
onBack()
|
||||||
|
}
|
||||||
|
val exporter = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/octet-stream")) { uri ->
|
||||||
|
if (uri != null) scope.launch {
|
||||||
|
message = runCatching { manager.export(id, uri) }.fold(
|
||||||
|
{ "Exported." },
|
||||||
|
{ "Could not export: ${it.message ?: it}" },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val mine = improving?.takeIf { it.id == id }
|
||||||
|
|
||||||
|
SubScreen(identity.name, leave, actions = {
|
||||||
|
IconButton(onClick = { exporter.launch(fileName(name.ifBlank { identity.name })) }) {
|
||||||
|
PackIcon("IDENTITY_EXPORT", "Export")
|
||||||
|
}
|
||||||
|
IconButton(onClick = { removing = true }) { PackIcon("DELETE", "Remove") }
|
||||||
|
}) { padding ->
|
||||||
|
Column(Modifier.padding(padding).imePadding().verticalScroll(rememberScrollState()).padding(vertical = 8.dp)) {
|
||||||
|
OutlinedTextField(name, { name = it }, Modifier.fillMaxWidth().padding(horizontal = 16.dp),
|
||||||
|
label = { Text("Name") }, singleLine = true, isError = name.isBlank())
|
||||||
|
OutlinedTextField(nickname, { nickname = it }, Modifier.fillMaxWidth().padding(horizontal = 16.dp).padding(top = 8.dp),
|
||||||
|
label = { Text("Nickname") }, placeholder = { Text("Global nickname") }, singleLine = true)
|
||||||
|
OutlinedTextField(phonetic, { phonetic = it }, Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
label = { Text("Phonetic nickname") }, singleLine = true)
|
||||||
|
message?.let { Hint(it) }
|
||||||
|
|
||||||
|
Section("Use")
|
||||||
|
if (identity.isDefault) {
|
||||||
|
Hint("Default identity")
|
||||||
|
} else {
|
||||||
|
LinkRow("Make default", null, "IDENTITY_DEFAULT") {
|
||||||
|
manager.setDefault(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Security level")
|
||||||
|
if (mine != null) {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text("Improving to ${mine.target}: reached ${mine.reached}") },
|
||||||
|
supportingContent = { LinearProgressIndicator(Modifier.fillMaxWidth().padding(top = 8.dp)) },
|
||||||
|
trailingContent = { TextButton(onClick = manager::stopImproving) { Text("Stop") } },
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
LinkRow("Level ${identity.securityLevel}",
|
||||||
|
if (improving != null) "Another identity is being improved" else "Improve",
|
||||||
|
"SECURITY") { if (improving == null) raising = true }
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Details")
|
||||||
|
val context = LocalContext.current
|
||||||
|
LinkRow("Unique ID", identity.uniqueId) {
|
||||||
|
// The system confirms the copy itself.
|
||||||
|
context.getSystemService(ClipboardManager::class.java)
|
||||||
|
.setPrimaryClip(ClipData.newPlainText("Unique ID", identity.uniqueId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (raising) {
|
||||||
|
LevelDialog(identity, onDismiss = { raising = false }) { target ->
|
||||||
|
raising = false
|
||||||
|
manager.improve(id, target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (removing) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { removing = false },
|
||||||
|
title = { Text("Remove ${identity.name}?") },
|
||||||
|
text = {
|
||||||
|
Text("Server groups and permissions tied to it are lost. Export it first to keep it.")
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
removing = false
|
||||||
|
manager.remove(id)
|
||||||
|
}) { Text("Remove") }
|
||||||
|
},
|
||||||
|
dismissButton = { TextButton(onClick = { removing = false }) { Text("Cancel") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun NewIdentityDialog(onDismiss: () -> Unit, onConfirm: (String) -> Unit) {
|
||||||
|
var name by remember { mutableStateOf("") }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text("New identity") },
|
||||||
|
text = {
|
||||||
|
OutlinedTextField(name, { name = it }, Modifier.fillMaxWidth(), label = { Text("Name") }, singleLine = true)
|
||||||
|
},
|
||||||
|
confirmButton = { TextButton(onClick = { onConfirm(name.trim()) }, enabled = name.isNotBlank()) { Text("Create") } },
|
||||||
|
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LevelDialog(identity: IdentityInfo, onDismiss: () -> Unit, onConfirm: (Int) -> Unit) {
|
||||||
|
var text by remember { mutableStateOf((identity.securityLevel + 1).toString()) }
|
||||||
|
val level = text.trim().toIntOrNull()?.takeIf { it in identity.securityLevel + 1..MAX_SECURITY_LEVEL }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text("Improve security level") },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
Text("Current level: ${identity.securityLevel}", style = MaterialTheme.typography.bodyMedium)
|
||||||
|
OutlinedTextField(text, { text = it }, Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||||
|
label = { Text("Target level") }, singleLine = true, isError = level == null,
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = { TextButton(onClick = { level?.let(onConfirm) }, enabled = level != null) { Text("Start") } },
|
||||||
|
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fileName(name: String) = name.replace(Regex("[^\\w.-]+"), "_") + ".ini"
|
||||||
225
android/app/src/main/java/com/ts3client/android/ui/Moderation.kt
Normal file
225
android/app/src/main/java/com/ts3client/android/ui/Moderation.kt
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
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.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Checkbox
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.RadioButton
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
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.draw.alpha
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.ts3client.android.ClientItem
|
||||||
|
import com.ts3client.android.ServerController
|
||||||
|
import com.ts3client.net.GroupChoice
|
||||||
|
import com.ts3client.net.TeamspeakConnection
|
||||||
|
import com.ts3client.net.ban.BanRule
|
||||||
|
import com.ts3client.net.ban.BanUnit
|
||||||
|
|
||||||
|
/** Asks to confirm a kick, with an optional reason. */
|
||||||
|
@Composable
|
||||||
|
fun KickPrompt(title: String, onDismiss: () -> Unit, onKick: (String) -> Unit) {
|
||||||
|
var reason by remember { mutableStateOf("") }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(title) },
|
||||||
|
text = { LimitedField(reason, { reason = it }, "Reason", TeamspeakConnection.KICK_REASON_LIMIT) },
|
||||||
|
confirmButton = { TextButton(onClick = { onKick(reason.trim()) }) { Text("Kick") } },
|
||||||
|
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Asks for a ban's reason and length. */
|
||||||
|
@Composable
|
||||||
|
fun BanPrompt(name: String, maxSeconds: Long, onDismiss: () -> Unit, onBan: (seconds: Long, reason: String) -> Unit) {
|
||||||
|
var reason by remember { mutableStateOf("") }
|
||||||
|
val duration = remember { BanDurationState(DEFAULT_BAN_SECONDS) }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text("Ban $name") },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
LimitedField(reason, { reason = it }, "Reason", BanRule.REASON_LIMIT)
|
||||||
|
BanDurationField(duration, maxSeconds)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = { onBan(duration.seconds, reason.trim()) }, enabled = duration.valid) { Text("Ban") }
|
||||||
|
},
|
||||||
|
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Thirty minutes, as the official client starts its ban dialog. */
|
||||||
|
internal const val DEFAULT_BAN_SECONDS = 30 * 60L
|
||||||
|
|
||||||
|
/** A one-line field that takes no more than [limit] characters, counting them as they come. */
|
||||||
|
@Composable
|
||||||
|
internal fun LimitedField(value: String, onChange: (String) -> Unit, label: String, limit: Int, modifier: Modifier = Modifier) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value, { onChange(it.take(limit)) },
|
||||||
|
modifier.fillMaxWidth(),
|
||||||
|
label = { Text(label) },
|
||||||
|
singleLine = true,
|
||||||
|
supportingText = { Text("${value.length}/$limit", Modifier.fillMaxWidth(), textAlign = TextAlign.End) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A ban's length as typed: an amount of a [BanUnit]. */
|
||||||
|
internal class BanDurationState(seconds: Long) {
|
||||||
|
var unit by mutableStateOf(BanUnit.largestFor(seconds))
|
||||||
|
var amount by mutableStateOf(BanUnit.largestFor(seconds).amountOf(seconds).takeIf { it > 0 }?.toString() ?: "30")
|
||||||
|
|
||||||
|
val valid: Boolean get() = unit == BanUnit.PERMANENT || (amount.toLongOrNull() ?: 0) > 0
|
||||||
|
|
||||||
|
/** The length in seconds; 0 for permanent. */
|
||||||
|
val seconds: Long get() = unit.toSeconds(amount.toLongOrNull() ?: 0)
|
||||||
|
|
||||||
|
fun set(seconds: Long) {
|
||||||
|
unit = BanUnit.largestFor(seconds)
|
||||||
|
if (unit != BanUnit.PERMANENT) amount = unit.amountOf(seconds).toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The amount, its unit, and a jump to the longest ban the server lets us hand out. */
|
||||||
|
@Composable
|
||||||
|
internal fun BanDurationField(state: BanDurationState, maxSeconds: Long, modifier: Modifier = Modifier) {
|
||||||
|
var units by remember { mutableStateOf(false) }
|
||||||
|
Row(modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
OutlinedTextField(
|
||||||
|
if (state.unit == BanUnit.PERMANENT) "" else state.amount,
|
||||||
|
{ text -> state.amount = text.filter(Char::isDigit).take(9) },
|
||||||
|
Modifier.width(120.dp),
|
||||||
|
enabled = state.unit != BanUnit.PERMANENT,
|
||||||
|
label = { Text("Duration") },
|
||||||
|
singleLine = true,
|
||||||
|
isError = !state.valid,
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
|
)
|
||||||
|
Box(Modifier.weight(1f)) {
|
||||||
|
OutlinedButton(onClick = { units = true }, Modifier.fillMaxWidth()) { Text(state.unit.label()) }
|
||||||
|
DropdownMenu(units, { units = false }) {
|
||||||
|
for (unit in BanUnit.entries) {
|
||||||
|
DropdownMenuItem({ Text(unit.label()) }, onClick = { units = false; state.unit = unit })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (maxSeconds != 0L) TextButton(onClick = { state.set(maxSeconds) }) { Text("Max") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A client's groups to tick: several server groups, or with [single] the one channel group.
|
||||||
|
* Groups we have no power over show greyed out; a change shows once the server made it.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun GroupsDialog(
|
||||||
|
title: String,
|
||||||
|
choices: List<GroupChoice>,
|
||||||
|
single: Boolean,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onChange: (groupId: Int, assign: Boolean) -> Unit,
|
||||||
|
) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(title) },
|
||||||
|
text = {
|
||||||
|
LazyColumn {
|
||||||
|
items(choices, key = { it.group().id }) { choice ->
|
||||||
|
val group = choice.group()
|
||||||
|
// A client always has one channel group: picking another is the only change.
|
||||||
|
val changeable = choice.allowed() && !(single && choice.assigned())
|
||||||
|
val toggle = { if (changeable) onChange(group.id, !choice.assigned()) }
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth()
|
||||||
|
.clickable(enabled = changeable, onClick = toggle)
|
||||||
|
.alpha(if (choice.allowed()) 1f else 0.38f)
|
||||||
|
.padding(vertical = 2.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
if (single) {
|
||||||
|
RadioButton(choice.assigned(), onClick = toggle, enabled = choice.allowed())
|
||||||
|
} else {
|
||||||
|
Checkbox(choice.assigned(), onCheckedChange = { toggle() }, enabled = choice.allowed())
|
||||||
|
}
|
||||||
|
Box(Modifier.width(24.dp), contentAlignment = Alignment.Center) {
|
||||||
|
if (group.iconId != 0L) ServerIcon(group.iconId)
|
||||||
|
}
|
||||||
|
Text(group.name, Modifier.padding(start = 8.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = { TextButton(onClick = onDismiss) { Text("Close") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The kicks, the ban and the groups of [client], as far as we may use them; [onDone] once one went out. */
|
||||||
|
@Composable
|
||||||
|
fun ClientModeration(server: ServerController, client: ClientItem, onDone: () -> Unit) {
|
||||||
|
var kick by remember { mutableStateOf<Boolean?>(null) }
|
||||||
|
var banning by remember { mutableStateOf(false) }
|
||||||
|
var groups by remember { mutableStateOf<Boolean?>(null) }
|
||||||
|
val serverGroups = remember(client) { server.serverGroupChoices(client.id) }
|
||||||
|
val channelGroups = remember(client) { server.channelGroupChoices(client.id) }
|
||||||
|
|
||||||
|
if (!client.self) {
|
||||||
|
if (server.canKickFromChannel) Action("KICK_FROM_CHANNEL", "Kick from channel") { kick = false }
|
||||||
|
if (server.canKickFromServer) Action("KICK_FROM_SERVER", "Kick from server") { kick = true }
|
||||||
|
if (server.canBanClients) Action("BAN_CLIENT", "Ban") { banning = true }
|
||||||
|
}
|
||||||
|
if (serverGroups.any { it.allowed() }) Action("PERMISSIONS_SERVER_GROUPS", "Server groups") { groups = true }
|
||||||
|
if (channelGroups.any { it.allowed() && !it.assigned() }) {
|
||||||
|
Action("PERMISSIONS_CHANNEL_GROUPS", "Channel group") { groups = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
kick?.let { fromServer ->
|
||||||
|
KickPrompt(
|
||||||
|
if (fromServer) "Kick ${client.name} from the server?" else "Kick ${client.name} from the channel?",
|
||||||
|
onDismiss = { kick = null },
|
||||||
|
) { reason ->
|
||||||
|
kick = null
|
||||||
|
if (fromServer) server.kickFromServer(client.id, reason) else server.kickFromChannel(client.id, reason)
|
||||||
|
onDone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (banning) {
|
||||||
|
BanPrompt(client.name, server.maxBanSeconds, onDismiss = { banning = false }) { seconds, reason ->
|
||||||
|
banning = false
|
||||||
|
server.ban(client.id, seconds, reason)
|
||||||
|
onDone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
groups?.let { ofServer ->
|
||||||
|
GroupsDialog(
|
||||||
|
if (ofServer) "Server groups" else "Channel group",
|
||||||
|
if (ofServer) serverGroups else channelGroups,
|
||||||
|
single = !ofServer,
|
||||||
|
onDismiss = { groups = null },
|
||||||
|
) { groupId, assign ->
|
||||||
|
if (ofServer) server.setServerGroup(client.id, groupId, assign) else server.setChannelGroup(client.id, groupId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
package com.ts3client.android.ui
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
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.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Checkbox
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
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.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.ts3client.android.SessionController
|
||||||
|
import com.ts3client.myts.MyTeamSpeak
|
||||||
|
import com.ts3client.myts.MyTsException
|
||||||
|
import com.ts3client.teamspeak.SyncItem
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
|
private data class ItemRow(val item: SyncItem, val imported: Boolean, val selected: Boolean)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The myTeamSpeak account: sign in and out, and pick what the account synchronises to
|
||||||
|
* import. Signing in lasts until signing out; the password itself is not kept.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun MyTeamSpeakScreen(vm: SessionController, onBack: () -> Unit) {
|
||||||
|
val login = vm.myTeamSpeak
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
var signedIn by remember { mutableStateOf(login.isSignedIn) }
|
||||||
|
var busy by remember { mutableStateOf(false) }
|
||||||
|
var message by remember { mutableStateOf<String?>(null) }
|
||||||
|
var error by remember { mutableStateOf(false) }
|
||||||
|
var email by remember { mutableStateOf("") }
|
||||||
|
var password by remember { mutableStateOf("") }
|
||||||
|
var all by remember { mutableStateOf(emptyList<SyncItem>()) }
|
||||||
|
var rows by remember { mutableStateOf(emptyList<ItemRow>()) }
|
||||||
|
BackHandler(onBack = onBack)
|
||||||
|
|
||||||
|
fun show(account: MyTeamSpeak.Account) {
|
||||||
|
all = account.items()
|
||||||
|
rows = all.filter { it !is SyncItem.Folder }.map {
|
||||||
|
val imported = vm.isImported(it)
|
||||||
|
ItemRow(it, imported, !imported)
|
||||||
|
}
|
||||||
|
message = if (rows.isEmpty()) "The account holds no bookmarks or identities." else null
|
||||||
|
error = false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun work(block: suspend () -> Unit) {
|
||||||
|
busy = true
|
||||||
|
message = null
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
block()
|
||||||
|
} catch (e: MyTsException) {
|
||||||
|
message = e.message
|
||||||
|
error = true
|
||||||
|
} catch (e: IOException) {
|
||||||
|
message = "Could not reach myTeamSpeak: ${e.message}"
|
||||||
|
error = true
|
||||||
|
}
|
||||||
|
// A sign-in the server no longer takes has signed out.
|
||||||
|
signedIn = login.isSignedIn
|
||||||
|
busy = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
if (signedIn) work { show(vm.fetchMyTeamSpeak()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("myTeamSpeak") },
|
||||||
|
navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") } },
|
||||||
|
actions = {
|
||||||
|
if (signedIn) {
|
||||||
|
IconButton(onClick = { work { show(vm.fetchMyTeamSpeak()) } }, enabled = !busy) {
|
||||||
|
PackIcon("REFRESH", "Refresh")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { padding ->
|
||||||
|
LazyColumn(
|
||||||
|
Modifier.padding(padding).imePadding(),
|
||||||
|
contentPadding = PaddingValues(vertical = 8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
if (busy) item { LinearProgressIndicator(Modifier.fillMaxWidth().padding(horizontal = 16.dp)) }
|
||||||
|
if (!signedIn) {
|
||||||
|
item {
|
||||||
|
Column(Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text("Sign in to bring over the bookmarks and identities your account synchronises. " +
|
||||||
|
"The app stays signed in; your password itself is not kept.")
|
||||||
|
OutlinedTextField(email, { email = it }, Modifier.fillMaxWidth(), label = { Text("Email") },
|
||||||
|
singleLine = true, enabled = !busy,
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email))
|
||||||
|
OutlinedTextField(password, { password = it }, Modifier.fillMaxWidth(), label = { Text("Password") },
|
||||||
|
singleLine = true, enabled = !busy, visualTransformation = PasswordVisualTransformation(),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password))
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
work {
|
||||||
|
show(vm.signInMyTeamSpeak(email.trim(), password))
|
||||||
|
password = ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = !busy && email.isNotBlank() && password.isNotEmpty(),
|
||||||
|
) { Text("Sign in") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
item {
|
||||||
|
Row(Modifier.padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text("Signed in as ${login.username()}", style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text(login.email(), style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
OutlinedButton(onClick = {
|
||||||
|
login.signOut()
|
||||||
|
signedIn = false
|
||||||
|
rows = emptyList()
|
||||||
|
all = emptyList()
|
||||||
|
message = null
|
||||||
|
}) { Text("Sign out") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (rows.isNotEmpty()) {
|
||||||
|
item {
|
||||||
|
Text("Synchronised by the account", Modifier.padding(start = 16.dp, top = 16.dp),
|
||||||
|
style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items(rows, key = { it.item.uuid() }) { row ->
|
||||||
|
val toggle = { rows = rows.map { if (it === row) it.copy(selected = !it.selected) else it } }
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text(name(row.item), maxLines = 1, overflow = TextOverflow.Ellipsis) },
|
||||||
|
supportingContent = { Text(details(row.item), maxLines = 1, overflow = TextOverflow.Ellipsis) },
|
||||||
|
leadingContent = { Checkbox(row.selected, { toggle() }) },
|
||||||
|
trailingContent = if (row.imported) ({ Text("Imported", style = MaterialTheme.typography.labelMedium) }) else null,
|
||||||
|
modifier = Modifier.clickable(onClick = toggle),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (rows.isNotEmpty()) {
|
||||||
|
item {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
work {
|
||||||
|
val r = vm.importMyTeamSpeak(all, rows.filter { it.selected }.map { it.item })
|
||||||
|
rows = rows.map { it.copy(imported = vm.isImported(it.item), selected = false) }
|
||||||
|
message = "Imported ${count(r.identitiesAdded(), "identity", "identities")} and " +
|
||||||
|
"${count(r.bookmarksAdded(), "bookmark", "bookmarks")}."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = !busy && rows.any { it.selected },
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
|
) { Text("Import selected") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
message?.let { text ->
|
||||||
|
item {
|
||||||
|
Text(text, Modifier.padding(horizontal = 16.dp),
|
||||||
|
color = if (error) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun name(item: SyncItem) = when (item) {
|
||||||
|
is SyncItem.Bookmark -> item.name().ifBlank { item.address() }
|
||||||
|
is SyncItem.Identity -> "Identity: ${item.name()}"
|
||||||
|
else -> ""
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun details(item: SyncItem) = when (item) {
|
||||||
|
is SyncItem.Bookmark -> "${item.address()}:${item.port()}"
|
||||||
|
is SyncItem.Identity -> item.nickname()
|
||||||
|
else -> ""
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun count(n: Int, one: String, many: String) = "$n ${if (n == 1) one else many}"
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package com.ts3client.android.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.material3.LocalContentColor
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.graphics.toArgb
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.ts3client.android.IconTheme
|
||||||
|
|
||||||
|
val LocalIconTheme = staticCompositionLocalOf<IconTheme> { error("No icon theme provided") }
|
||||||
|
|
||||||
|
/** An icon of the active pack, named by its TeamSpeak key; art in the text colour takes [LocalContentColor]. */
|
||||||
|
@Composable
|
||||||
|
fun PackIcon(key: String, contentDescription: String?, modifier: Modifier = Modifier, size: Dp = 24.dp) =
|
||||||
|
PackIcon(listOf(key), contentDescription, modifier, size)
|
||||||
|
|
||||||
|
/** The first of [keys] the pack has, for icons packs need not ship in every variant. */
|
||||||
|
@Composable
|
||||||
|
fun PackIcon(keys: List<String>, contentDescription: String?, modifier: Modifier = Modifier, size: Dp = 24.dp) {
|
||||||
|
val theme = LocalIconTheme.current
|
||||||
|
val active by theme.active.collectAsStateWithLifecycle()
|
||||||
|
val pixels = with(LocalDensity.current) { size.roundToPx() }
|
||||||
|
val color = LocalContentColor.current
|
||||||
|
val rgb = color.copy(alpha = 1f).toArgb()
|
||||||
|
val bitmap = remember(keys, pixels, rgb, active) { theme.render(keys, pixels, rgb)?.asImageBitmap() }
|
||||||
|
if (bitmap == null) {
|
||||||
|
Spacer(modifier.size(size))
|
||||||
|
} else {
|
||||||
|
// Disabled controls fade their content colour; the pack's own colours fade with it.
|
||||||
|
Image(bitmap, contentDescription, modifier.size(size), alpha = color.alpha)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
package com.ts3client.android.ui
|
||||||
|
|
||||||
|
import android.content.ClipData
|
||||||
|
import android.content.ClipboardManager
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
|
import androidx.compose.foundation.gestures.detectTransformGestures
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.LocalContentColor
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.unit.IntSize
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
|
import com.ts3client.android.Picture
|
||||||
|
import com.ts3client.android.SavedPictures
|
||||||
|
import com.ts3client.android.ServerController
|
||||||
|
import com.ts3client.android.decodePicture
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
/** A picture to open full size. */
|
||||||
|
class ViewedPicture(
|
||||||
|
/** What to call it when saved, without an extension. */
|
||||||
|
val name: String,
|
||||||
|
/** Where it is on the web; null for an avatar. */
|
||||||
|
val url: String? = null,
|
||||||
|
/** Follows the link it sits in; null when it sits in none. */
|
||||||
|
val openLink: (() -> Unit)? = null,
|
||||||
|
/** Our own avatar, which there is no taking. */
|
||||||
|
val ownAvatar: Boolean = false,
|
||||||
|
/** Its file; null when it is not there. */
|
||||||
|
val read: suspend () -> ByteArray?,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Opens a picture full size; null where nothing can. */
|
||||||
|
val LocalPictureViewer = staticCompositionLocalOf<((ViewedPicture) -> Unit)?> { null }
|
||||||
|
|
||||||
|
/** Lets [content] open pictures full size, over everything, with [server] to take one as our avatar on. */
|
||||||
|
@Composable
|
||||||
|
fun PictureViewerHost(server: ServerController, content: @Composable () -> Unit) {
|
||||||
|
var viewed by remember { mutableStateOf<ViewedPicture?>(null) }
|
||||||
|
CompositionLocalProvider(LocalPictureViewer provides { viewed = it }, content = content)
|
||||||
|
val context = LocalContext.current
|
||||||
|
viewed?.let { picture ->
|
||||||
|
PictureViewer(
|
||||||
|
picture,
|
||||||
|
onSteal = if (picture.ownAvatar) null else { data ->
|
||||||
|
val error = server.setAvatar(data)
|
||||||
|
Toast.makeText(context, error ?: "Avatar set.", Toast.LENGTH_SHORT).show()
|
||||||
|
},
|
||||||
|
onDismiss = { viewed = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A name to save the picture at [url] under: its file's, or "picture" when it has none. */
|
||||||
|
fun pictureName(url: String): String =
|
||||||
|
url.substringBefore('?').substringBefore('#').trimEnd('/').substringAfterLast('/')
|
||||||
|
.substringBeforeLast('.').ifBlank { "picture" }
|
||||||
|
|
||||||
|
/** The picture on black, filling the screen; pinch or double-tap to zoom, tap to hide the bar. */
|
||||||
|
@Composable
|
||||||
|
private fun PictureViewer(
|
||||||
|
picture: ViewedPicture,
|
||||||
|
/** Takes the picture as our avatar; null when it cannot be. */
|
||||||
|
onSteal: (suspend (ByteArray) -> Unit)?,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val state by produceState<Shown?>(null, picture) {
|
||||||
|
val data = picture.read()
|
||||||
|
value = Shown(data, data?.let { withContext(Dispatchers.Default) { decodePicture(it, MAX_PX) } })
|
||||||
|
}
|
||||||
|
val data = state?.data
|
||||||
|
var bar by remember { mutableStateOf(true) }
|
||||||
|
var scale by remember { mutableFloatStateOf(1f) }
|
||||||
|
var offset by remember { mutableStateOf(Offset.Zero) }
|
||||||
|
var size by remember { mutableStateOf(IntSize.Zero) }
|
||||||
|
fun panTo(target: Offset) {
|
||||||
|
val maxX = size.width * (scale - 1) / 2
|
||||||
|
val maxY = size.height * (scale - 1) / 2
|
||||||
|
offset = Offset(target.x.coerceIn(-maxX, maxX), target.y.coerceIn(-maxY, maxY))
|
||||||
|
}
|
||||||
|
|
||||||
|
Dialog(onDismiss, DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false)) {
|
||||||
|
Box(
|
||||||
|
Modifier.fillMaxSize().background(Color.Black).onSizeChanged { size = it }
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
detectTapGestures(
|
||||||
|
onTap = { bar = !bar },
|
||||||
|
onDoubleTap = { scale = if (scale > 1f) 1f else DOUBLE_TAP_SCALE; panTo(Offset.Zero) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
detectTransformGestures { _, pan, zoom, _ ->
|
||||||
|
scale = (scale * zoom).coerceIn(1f, MAX_SCALE)
|
||||||
|
panTo(offset + pan)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
val shown = state?.picture
|
||||||
|
when {
|
||||||
|
state == null -> PackIcon("LOADING_IMAGE", "Loading picture")
|
||||||
|
shown == null -> PackIcon("BROKEN_IMAGE", "Broken picture")
|
||||||
|
else -> Image(
|
||||||
|
rememberPicturePainter(shown), null,
|
||||||
|
Modifier.fillMaxSize().graphicsLayer {
|
||||||
|
scaleX = scale
|
||||||
|
scaleY = scale
|
||||||
|
translationX = offset.x
|
||||||
|
translationY = offset.y
|
||||||
|
},
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (bar) {
|
||||||
|
CompositionLocalProvider(LocalContentColor provides Color.White) {
|
||||||
|
Bar(picture, data, onSteal, onDismiss, Modifier.align(Alignment.TopCenter))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Bar(
|
||||||
|
picture: ViewedPicture,
|
||||||
|
data: ByteArray?,
|
||||||
|
onSteal: (suspend (ByteArray) -> Unit)?,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
modifier: Modifier,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
var menu by remember { mutableStateOf(false) }
|
||||||
|
Row(
|
||||||
|
modifier.fillMaxWidth()
|
||||||
|
.background(Brush.verticalGradient(listOf(Color.Black.copy(alpha = 0.6f), Color.Transparent)))
|
||||||
|
.statusBarsPadding(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
IconButton(onClick = onDismiss) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") }
|
||||||
|
Box(Modifier.weight(1f))
|
||||||
|
Box {
|
||||||
|
IconButton(onClick = { menu = true }) { Icon(Icons.Default.MoreVert, "Picture options") }
|
||||||
|
DropdownMenu(menu, onDismissRequest = { menu = false }) {
|
||||||
|
DropdownMenuItem({ Text("Save") }, enabled = data != null, onClick = {
|
||||||
|
menu = false
|
||||||
|
val bytes = data ?: return@DropdownMenuItem
|
||||||
|
scope.launch {
|
||||||
|
val saved = SavedPictures.save(context, picture.name, bytes)
|
||||||
|
Toast.makeText(context, if (saved) "Saved." else "Could not save.", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (onSteal != null) {
|
||||||
|
DropdownMenuItem({ Text("Steal for avatar") }, enabled = data != null, onClick = {
|
||||||
|
menu = false
|
||||||
|
val bytes = data ?: return@DropdownMenuItem
|
||||||
|
scope.launch { onSteal(bytes) }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
picture.url?.let { url ->
|
||||||
|
DropdownMenuItem({ Text("Copy URL") }, onClick = {
|
||||||
|
menu = false
|
||||||
|
context.getSystemService(ClipboardManager::class.java)
|
||||||
|
.setPrimaryClip(ClipData.newPlainText("URL", url))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
picture.openLink?.let { open ->
|
||||||
|
DropdownMenuItem({ Text("Open link") }, onClick = {
|
||||||
|
menu = false
|
||||||
|
onDismiss()
|
||||||
|
open()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The picture's file, and the picture when the file is one; both null when it is not there. */
|
||||||
|
private class Shown(val data: ByteArray?, val picture: Picture?)
|
||||||
|
|
||||||
|
private const val MAX_PX = 4096
|
||||||
|
private const val MAX_SCALE = 8f
|
||||||
|
private const val DOUBLE_TAP_SCALE = 2.5f
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
package com.ts3client.android.ui
|
||||||
|
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.graphics.drawable.AnimatedImageDrawable
|
||||||
|
import android.graphics.drawable.Drawable
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.LocalContentColor
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.graphics.ImageBitmap
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||||
|
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
|
||||||
|
import androidx.compose.ui.graphics.nativeCanvas
|
||||||
|
import androidx.compose.ui.graphics.painter.BitmapPainter
|
||||||
|
import androidx.compose.ui.graphics.painter.Painter
|
||||||
|
import androidx.compose.ui.graphics.toArgb
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.ts3client.android.Picture
|
||||||
|
import com.ts3client.android.ServerImages
|
||||||
|
import com.ts3client.gfx.CountryFlags
|
||||||
|
import com.ts3client.net.IconRepository
|
||||||
|
import java.util.Optional
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
/** The images of the server on screen. */
|
||||||
|
val LocalServerImages = staticCompositionLocalOf<ServerImages?> { null }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A group, channel or server icon; nothing while it has none or it is still downloading.
|
||||||
|
* TeamSpeak's default group icons are the pack's, drawn in its style, as on the desktop.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ServerIcon(id: Long, modifier: Modifier = Modifier, size: Dp = 16.dp) {
|
||||||
|
if (id == 0L) return
|
||||||
|
if (id in 1..IconRepository.MAX_BUNDLED_ID) {
|
||||||
|
val theme = LocalIconTheme.current
|
||||||
|
val active by theme.active.collectAsStateWithLifecycle()
|
||||||
|
val pixels = with(LocalDensity.current) { size.roundToPx() }
|
||||||
|
val rgb = LocalContentColor.current.copy(alpha = 1f).toArgb()
|
||||||
|
val themed = remember(id, pixels, rgb, active) { theme.render(listOf("GROUP_$id"), pixels, rgb)?.asImageBitmap() }
|
||||||
|
if (themed != null) {
|
||||||
|
Image(themed, null, modifier.size(size))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val images = LocalServerImages.current ?: return
|
||||||
|
val version by images.version.collectAsStateWithLifecycle()
|
||||||
|
val bitmap = remember(id, version) { images.icon(id) } ?: return
|
||||||
|
Image(bitmap, null, modifier.size(size))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A client's avatar, moving if it is an animated GIF; nothing while they have none or it is still downloading. */
|
||||||
|
@Composable
|
||||||
|
fun Avatar(uniqueId: String, flag: String, modifier: Modifier = Modifier, size: Dp = 64.dp) {
|
||||||
|
val images = LocalServerImages.current ?: return
|
||||||
|
val version by images.version.collectAsStateWithLifecycle()
|
||||||
|
val picture = remember(flag, version) { images.avatar(uniqueId, flag) } ?: return
|
||||||
|
Image(rememberPicturePainter(picture), "Avatar", modifier.size(size).clip(RoundedCornerShape(8.dp)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Draws [picture], playing it while on screen if it moves. */
|
||||||
|
@Composable
|
||||||
|
fun rememberPicturePainter(picture: Picture): Painter = when (picture) {
|
||||||
|
is Picture.Still -> remember(picture) { BitmapPainter(picture.bitmap) }
|
||||||
|
is Picture.Moving -> rememberMovingPainter(picture.drawable)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun rememberMovingPainter(drawable: AnimatedImageDrawable): Painter {
|
||||||
|
val painter = remember(drawable) { MovingPainter(drawable) }
|
||||||
|
DisposableEffect(drawable) {
|
||||||
|
drawable.callback = painter
|
||||||
|
drawable.start()
|
||||||
|
onDispose {
|
||||||
|
drawable.stop()
|
||||||
|
if (drawable.callback === painter) drawable.callback = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return painter
|
||||||
|
}
|
||||||
|
|
||||||
|
private class MovingPainter(private val drawable: AnimatedImageDrawable) : Painter(), Drawable.Callback {
|
||||||
|
/** Read while drawing, so that each new frame draws again. */
|
||||||
|
private var frame by mutableIntStateOf(0)
|
||||||
|
private val main = Handler(Looper.getMainLooper())
|
||||||
|
|
||||||
|
override val intrinsicSize = Size(drawable.intrinsicWidth.toFloat(), drawable.intrinsicHeight.toFloat())
|
||||||
|
|
||||||
|
override fun DrawScope.onDraw() {
|
||||||
|
frame
|
||||||
|
drawIntoCanvas { canvas ->
|
||||||
|
drawable.setBounds(0, 0, size.width.roundToInt(), size.height.roundToInt())
|
||||||
|
drawable.draw(canvas.nativeCanvas)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun invalidateDrawable(who: Drawable) {
|
||||||
|
frame++
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun scheduleDrawable(who: Drawable, what: Runnable, `when`: Long) {
|
||||||
|
main.postAtTime(what, `when`)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun unscheduleDrawable(who: Drawable, what: Runnable) {
|
||||||
|
main.removeCallbacks(what)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val flags = ConcurrentHashMap<String, Optional<ImageBitmap>>()
|
||||||
|
|
||||||
|
/** The flag of a country, at TeamSpeak's 16×11; nothing for no country or one without a flag. */
|
||||||
|
@Composable
|
||||||
|
fun CountryFlag(code: String, modifier: Modifier = Modifier) {
|
||||||
|
if (code.isEmpty()) return
|
||||||
|
val bitmap = remember(code) {
|
||||||
|
flags.getOrPut(code) {
|
||||||
|
Optional.ofNullable(CountryFlags.png(code)?.let { BitmapFactory.decodeByteArray(it, 0, it.size)?.asImageBitmap() })
|
||||||
|
}.orElse(null)
|
||||||
|
} ?: return
|
||||||
|
Image(bitmap, CountryFlags.name(code), modifier.size(16.dp, 11.dp))
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,584 @@
|
|||||||
|
package com.ts3client.android.ui
|
||||||
|
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
|
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.heightIn
|
||||||
|
import androidx.compose.foundation.layout.imePadding
|
||||||
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||||
|
import androidx.compose.material3.Badge
|
||||||
|
import androidx.compose.material3.BadgedBox
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FilledTonalButton
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.IconToggleButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.MenuDefaults
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.minimumInteractiveComponentSize
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.key
|
||||||
|
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.draw.alpha
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.input.key.Key
|
||||||
|
import androidx.compose.ui.input.key.KeyEventType
|
||||||
|
import androidx.compose.ui.input.key.isCtrlPressed
|
||||||
|
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.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalUriHandler
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
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.ChatMatch
|
||||||
|
import com.ts3client.android.ServerController
|
||||||
|
import com.ts3client.android.ServerTree
|
||||||
|
import com.ts3client.android.SessionController
|
||||||
|
import com.ts3client.android.SessionState
|
||||||
|
import com.ts3client.android.TreeFind
|
||||||
|
import com.ts3client.android.findIn
|
||||||
|
import com.ts3client.config.Settings
|
||||||
|
import com.ts3client.net.ClientStatus
|
||||||
|
import com.ts3client.text.TsLink
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
|
||||||
|
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,
|
||||||
|
server: ServerController,
|
||||||
|
snackbar: SnackbarHostState,
|
||||||
|
onSettings: () -> Unit,
|
||||||
|
/** Opens the given client's contact page. */
|
||||||
|
onEditContact: (String) -> Unit,
|
||||||
|
onEnableMicrophone: () -> Unit,
|
||||||
|
onConnectOther: () -> Unit,
|
||||||
|
) {
|
||||||
|
val state by server.state.collectAsStateWithLifecycle()
|
||||||
|
val tree by server.tree.collectAsStateWithLifecycle()
|
||||||
|
val chats by server.chats.collectAsStateWithLifecycle()
|
||||||
|
val collapsed by server.collapsed.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
var showChat by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var chatKey by rememberSaveable(stateSaver = ChatKeySaver) { mutableStateOf<ChatKey>(ChatKey.Channel) }
|
||||||
|
val conversation = chats[chatKey] ?: chats.channel
|
||||||
|
var clientSheet by remember { mutableStateOf<Int?>(null) }
|
||||||
|
var clientSheetFromTree by remember { mutableStateOf(false) }
|
||||||
|
/** A row to show in the tree. */
|
||||||
|
var reveal by remember { mutableStateOf<TreeRef?>(null) }
|
||||||
|
var channelSheet by remember { mutableStateOf<Int?>(null) }
|
||||||
|
var serverSheet by remember { mutableStateOf(false) }
|
||||||
|
var banList by rememberSaveable { mutableStateOf(false) }
|
||||||
|
/** The client to move, while a channel for them is being picked. */
|
||||||
|
var moving by remember { mutableStateOf<Int?>(null) }
|
||||||
|
val infoUpdates by server.infoUpdates.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
val chatRequest by server.chatRequest.collectAsStateWithLifecycle()
|
||||||
|
LaunchedEffect(chatRequest) {
|
||||||
|
val key = chatRequest ?: return@LaunchedEffect
|
||||||
|
chatKey = if (chats[key] != null) key else server.openPrivateChat(key.clientId)
|
||||||
|
showChat = true
|
||||||
|
server.chatRequestHandled()
|
||||||
|
}
|
||||||
|
|
||||||
|
val viewing = if (showChat) conversation.key else null
|
||||||
|
LifecycleResumeEffect(viewing) {
|
||||||
|
server.viewing = viewing
|
||||||
|
onPauseOrDispose { server.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; clientSheetFromTree = false }
|
||||||
|
else -> channelSheet = ref.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find: in the tree, clients or channels; in the chat, the conversation on screen.
|
||||||
|
val find = rememberSaveable(saver = FindState.Saver) { FindState() }
|
||||||
|
val treeFind = TreeFind(find.usable, find.channels)
|
||||||
|
val treeHits = remember(tree, treeFind) { tree.hits(treeFind) }
|
||||||
|
val treeFiltering = find.filter && !treeFind.query.isEmpty
|
||||||
|
val shownTree = remember(tree, treeFind, treeFiltering) { if (treeFiltering) tree.only(treeFind) else tree }
|
||||||
|
// While the tree is filtered every channel opens, so every match shows; folding one then lasts as long as the filter.
|
||||||
|
var filterCollapsed by remember(treeFiltering, treeFind) { mutableStateOf(emptySet<Int>()) }
|
||||||
|
/** The match the tree is on. */
|
||||||
|
var treeAt by remember { mutableStateOf<Int?>(null) }
|
||||||
|
// Lambdas, not local functions: Compose remembers a reference to a local function with what it first captured.
|
||||||
|
val showTreeHit = { id: Int? ->
|
||||||
|
if (id != null) {
|
||||||
|
treeAt = id
|
||||||
|
val path = (if (treeFind.channels) tree.parentsOf(id) else tree.pathTo(id)).orEmpty()
|
||||||
|
if (treeFiltering) filterCollapsed = filterCollapsed - path.toSet() else server.expand(path)
|
||||||
|
reveal = TreeRef(treeFind.channels, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(treeFind, showChat) {
|
||||||
|
treeAt = null
|
||||||
|
if (!showChat) showTreeHit(treeHits.firstOrNull())
|
||||||
|
}
|
||||||
|
|
||||||
|
val chatQuery = find.usable
|
||||||
|
val chatLines = remember(conversation.lines, chatQuery, find.filter) {
|
||||||
|
if (find.filter && !chatQuery.isEmpty) conversation.lines.filter { chatQuery.matches(it.plain) } else conversation.lines
|
||||||
|
}
|
||||||
|
val chatHits = remember(chatLines, chatQuery) { findIn(chatLines, chatQuery, MAX_CHAT_MATCHES) }
|
||||||
|
/** The match the chat is on; a new search starts from the newest. */
|
||||||
|
var chatAt by remember { mutableStateOf<ChatMatch?>(null) }
|
||||||
|
var chatReveal by remember { mutableStateOf<Long?>(null) }
|
||||||
|
LaunchedEffect(chatQuery, conversation.key, find.filter, showChat) {
|
||||||
|
chatAt = if (showChat) chatHits.lastOrNull() else null
|
||||||
|
chatReveal = chatAt?.line
|
||||||
|
}
|
||||||
|
|
||||||
|
val step = { delta: Int ->
|
||||||
|
if (showChat && chatHits.isNotEmpty()) {
|
||||||
|
chatAt = chatHits[next(chatHits.indexOf(chatAt), delta, chatHits.size)]
|
||||||
|
chatReveal = chatAt?.line
|
||||||
|
} else if (!showChat && treeHits.isNotEmpty()) {
|
||||||
|
showTreeHit(treeHits[next(treeHits.indexOf(treeAt), delta, treeHits.size)])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (banList) {
|
||||||
|
BanListScreen(server, vm.banReasons, onBack = { banList = false })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
BackHandler(showChat) { showChat = false }
|
||||||
|
BackHandler(moving != null && !showChat) { moving = null }
|
||||||
|
BackHandler(find.open) { find.open = false }
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
// A hardware keyboard's Ctrl+F, as on the desktop.
|
||||||
|
modifier = Modifier.onPreviewKeyEvent { e ->
|
||||||
|
val ctrlF = e.isCtrlPressed && e.key == Key.F
|
||||||
|
if (ctrlF && e.type == KeyEventType.KeyDown) find.show()
|
||||||
|
ctrlF
|
||||||
|
},
|
||||||
|
topBar = {
|
||||||
|
if (find.open) {
|
||||||
|
if (showChat) {
|
||||||
|
FindBar(find, inTree = false, chatHits.indexOf(chatAt) + 1, chatHits.size,
|
||||||
|
chatHits.size == MAX_CHAT_MATCHES, step)
|
||||||
|
} else {
|
||||||
|
FindBar(find, inTree = true, treeHits.indexOf(treeAt) + 1, treeHits.size, false, step)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
TopAppBar(
|
||||||
|
title = { ServerSwitcher(vm, server, state, onConnectOther) },
|
||||||
|
expandedHeight = 48.dp,
|
||||||
|
actions = {
|
||||||
|
state.hostButton?.let { HostButtonAction(it) { url -> runCatching { uriHandler.openUri(url) } } }
|
||||||
|
IconButton(onClick = find::show) { PackIcon("FIND", "Find") }
|
||||||
|
IconButton(onClick = onSettings) { PackIcon("SETTINGS", "Settings") }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
bottomBar = {
|
||||||
|
// Push-to-talk talks on whichever server holds the microphone, which need not be this one.
|
||||||
|
val owner by vm.micOwner.collectAsStateWithLifecycle()
|
||||||
|
val ownerState by remember(owner) { owner?.state ?: MutableStateFlow(SessionState()) }
|
||||||
|
.collectAsStateWithLifecycle()
|
||||||
|
val ownerTree by remember(owner) { owner?.tree ?: MutableStateFlow(ServerTree()) }
|
||||||
|
.collectAsStateWithLifecycle()
|
||||||
|
val self = ownerTree.client(ownerState.selfClientId)
|
||||||
|
val talking = self?.status == ClientStatus.TALKING || self?.status == ClientStatus.COMMANDER_TALKING
|
||||||
|
VoiceBar(state, owner != null, talking, vm, server, onEnableMicrophone, showChat, chats.anyUnread) {
|
||||||
|
showChat = !showChat
|
||||||
|
}
|
||||||
|
},
|
||||||
|
snackbarHost = { SnackbarHost(snackbar) },
|
||||||
|
) { padding ->
|
||||||
|
Column(Modifier.padding(padding).consumeWindowInsets(padding).imePadding()) {
|
||||||
|
if (!showChat) {
|
||||||
|
moving?.let { id ->
|
||||||
|
val client = tree.client(id)
|
||||||
|
if (client == null) {
|
||||||
|
LaunchedEffect(id) { moving = null }
|
||||||
|
} else {
|
||||||
|
MovePrompt(client.name) { moving = null }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ChannelTree(
|
||||||
|
serverName = state.title,
|
||||||
|
tree = shownTree,
|
||||||
|
collapsed = if (treeFiltering) filterCollapsed else collapsed,
|
||||||
|
currentChannel = state.channelId,
|
||||||
|
onToggle = {
|
||||||
|
if (!treeFiltering) server.toggleCollapsed(it.id)
|
||||||
|
else if (it.id in filterCollapsed) filterCollapsed -= it.id
|
||||||
|
else filterCollapsed += it.id
|
||||||
|
},
|
||||||
|
onChannel = { channel ->
|
||||||
|
val client = moving
|
||||||
|
when {
|
||||||
|
channel.spacer != null -> {}
|
||||||
|
client != null -> { server.moveClient(client, channel.id); moving = null }
|
||||||
|
else -> channelSheet = channel.id
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onClient = { clientSheet = it.id; clientSheetFromTree = true },
|
||||||
|
onServer = { serverSheet = true },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
reveal = reveal,
|
||||||
|
onRevealed = { reveal = null },
|
||||||
|
found = remember(treeHits) { treeHits.mapTo(HashSet()) { TreeRef(treeFind.channels, it) } },
|
||||||
|
foundCurrent = treeAt?.takeIf { it in treeHits }?.let { TreeRef(treeFind.channels, it) },
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
ChatPane(
|
||||||
|
chats = chats,
|
||||||
|
selected = conversation,
|
||||||
|
canSend = state.connected,
|
||||||
|
onSelect = { chatKey = it },
|
||||||
|
onClose = { key ->
|
||||||
|
if (chatKey == key) chatKey = ChatKey.Channel
|
||||||
|
server.closePrivateChat(key)
|
||||||
|
},
|
||||||
|
onSend = { server.send(conversation.key, it) },
|
||||||
|
onLink = onLink,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
lines = chatLines,
|
||||||
|
find = chatQuery,
|
||||||
|
current = chatAt,
|
||||||
|
reveal = chatReveal,
|
||||||
|
onRevealed = { chatReveal = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A sheet goes with its client or channel when that leaves the tree.
|
||||||
|
clientSheet?.let { id ->
|
||||||
|
LaunchedEffect(id) { server.requestClientDetails(id) }
|
||||||
|
tree.client(id)?.let { client ->
|
||||||
|
ClientSheet(
|
||||||
|
client = client,
|
||||||
|
details = remember(id, tree, infoUpdates) { server.clientDetails(id) },
|
||||||
|
onLink = onLink,
|
||||||
|
away = state.away,
|
||||||
|
initialVolume = remember(id) { server.clientVolume(id) },
|
||||||
|
onDismiss = { clientSheet = null },
|
||||||
|
onMessage = {
|
||||||
|
clientSheet = null
|
||||||
|
chatKey = server.openPrivateChat(id)
|
||||||
|
showChat = true
|
||||||
|
},
|
||||||
|
onPoke = { server.poke(id, it); clientSheet = null },
|
||||||
|
onVolume = { server.setClientVolume(id, it) },
|
||||||
|
onAway = { server.setAway(it); clientSheet = null },
|
||||||
|
onAddContact = { server.addContact(id, it) },
|
||||||
|
onEditContact = { clientSheet = null; onEditContact(client.uniqueId) },
|
||||||
|
onMove = if (server.canMoveClients) ({ clientSheet = null; moving = id }) else null,
|
||||||
|
onMoveToOwnChannel = if (server.canMoveClients && tree.channel(state.channelId)?.clients?.none { it.id == id } == true) {
|
||||||
|
{ server.moveClient(id, state.channelId); clientSheet = null }
|
||||||
|
} else null,
|
||||||
|
onFindInTree = if (clientSheetFromTree) null else ({
|
||||||
|
clientSheet = null
|
||||||
|
tree.pathTo(id)?.let { server.expand(it) }
|
||||||
|
showChat = false
|
||||||
|
reveal = TreeRef(false, id)
|
||||||
|
}),
|
||||||
|
moderation = { ClientModeration(server, client) { clientSheet = null } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
channelSheet?.let { id ->
|
||||||
|
LaunchedEffect(id) { server.requestChannelDetails(id) }
|
||||||
|
tree.channel(id)?.let { channel ->
|
||||||
|
ChannelSheet(
|
||||||
|
channel = channel,
|
||||||
|
details = remember(id, tree, infoUpdates) { server.channelDetails(id) },
|
||||||
|
onLink = onLink,
|
||||||
|
current = id == state.channelId,
|
||||||
|
onDismiss = { channelSheet = null },
|
||||||
|
onJoin = { server.joinChannel(id, it); channelSheet = null },
|
||||||
|
onSubscribe = { server.setSubscribed(id, it); channelSheet = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (serverSheet) {
|
||||||
|
remember(tree, infoUpdates) { server.serverDetails() }?.let {
|
||||||
|
ServerSheet(it, onLink, onDismiss = { serverSheet = false },
|
||||||
|
onBanList = if (server.canViewBanList) ({ serverSheet = false; banList = true }) else null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** More matches than this nobody steps through one by one. */
|
||||||
|
private const val MAX_CHAT_MATCHES = 5000
|
||||||
|
|
||||||
|
/** The index [delta] on from [at] among [size], wrapping around; from none, the first or the last. */
|
||||||
|
private fun next(at: Int, delta: Int, size: Int): Int =
|
||||||
|
if (at < 0) (if (delta > 0) 0 else size - 1) else Math.floorMod(at + delta, size)
|
||||||
|
|
||||||
|
/** Says a channel is being picked for [name] to move to, until it is or [onCancel]. */
|
||||||
|
@Composable
|
||||||
|
private fun MovePrompt(name: String, onCancel: () -> Unit) {
|
||||||
|
Surface(color = MaterialTheme.colorScheme.secondaryContainer) {
|
||||||
|
Row(Modifier.fillMaxWidth().padding(start = 16.dp, end = 8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
PackIcon("CHANNEL_SWITCH", null)
|
||||||
|
Text("Move $name to…", Modifier.weight(1f).padding(horizontal = 12.dp), maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
TextButton(onClick = onCancel) { Text("Cancel") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mute, deafen, away and the chat, around the push-to-talk button when that is how the
|
||||||
|
* microphone is used. Holding the mute button switches between push to talk and voice activation.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun VoiceBar(
|
||||||
|
state: SessionState,
|
||||||
|
/** Whether some server holds the microphone, for push-to-talk to talk on. */
|
||||||
|
captured: Boolean,
|
||||||
|
talking: Boolean,
|
||||||
|
vm: SessionController,
|
||||||
|
server: ServerController,
|
||||||
|
onEnableMicrophone: () -> Unit,
|
||||||
|
showChat: Boolean,
|
||||||
|
unread: Boolean,
|
||||||
|
onChat: () -> Unit,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val ptt = state.connected && captured && state.inputMode == Settings.InputMode.PUSH_TO_TALK
|
||||||
|
val mic = @Composable {
|
||||||
|
BarButton(
|
||||||
|
onClick = { server.setMicMuted(!state.micMuted) },
|
||||||
|
onLongClick = {
|
||||||
|
val mode = vm.toggleInputMode()
|
||||||
|
val text = if (mode == Settings.InputMode.PUSH_TO_TALK) "Switched to push-to-talk mode."
|
||||||
|
else "Switched to voice activation mode."
|
||||||
|
Toast.makeText(context, text, Toast.LENGTH_SHORT).show()
|
||||||
|
},
|
||||||
|
enabled = state.connected,
|
||||||
|
) {
|
||||||
|
if (state.micMuted) PackIcon("INPUT_MUTED", "Unmute microphone")
|
||||||
|
else PackIcon("CAPTURE", "Mute microphone")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val deafen = @Composable {
|
||||||
|
IconToggleButton(state.deafened, { server.setDeafened(it) }, enabled = state.connected) {
|
||||||
|
if (state.deafened) PackIcon("OUTPUT_MUTED", "Undeafen")
|
||||||
|
else PackIcon("PLAYBACK", "Deafen")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Another server holds the microphone.
|
||||||
|
val activate = @Composable {
|
||||||
|
if (state.connected && state.micPermission && !state.micActive) {
|
||||||
|
IconButton(onClick = onEnableMicrophone) { PackIcon("ACTIVATE_MICROPHONE", "Speak on this server") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val middle = @Composable {
|
||||||
|
when {
|
||||||
|
ptt -> PushToTalk(talking, vm::setPushToTalk, Modifier.fillMaxWidth())
|
||||||
|
!state.micPermission -> FilledTonalButton(onClick = onEnableMicrophone) {
|
||||||
|
PackIcon("CAPTURE", null)
|
||||||
|
Text("Use microphone", Modifier.padding(start = 8.dp), maxLines = 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val away = @Composable {
|
||||||
|
IconToggleButton(state.away, { server.setAway(it) }, enabled = state.connected) {
|
||||||
|
PackIcon("AWAY", if (state.away) "Back" else "Away", Modifier.alpha(if (state.away) 1f else 0.5f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val chat = @Composable {
|
||||||
|
IconToggleButton(showChat, { onChat() }) {
|
||||||
|
BadgedBox(badge = { if (unread && !showChat) Badge() }) {
|
||||||
|
PackIcon(listOf("MESSAGES", "CHANNEL_CHAT"), if (showChat) "Channels" else "Chat",
|
||||||
|
Modifier.alpha(if (showChat) 1f else 0.7f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Surface(tonalElevation = 3.dp) {
|
||||||
|
// The same places whatever shows: sides of three buttons each keep the talk button centred.
|
||||||
|
Row(Modifier.fillMaxWidth().navigationBarsPadding().padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Row(Modifier.width(SIDE), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Slot { mic() }; Slot { deafen() }; Slot { activate() }
|
||||||
|
}
|
||||||
|
Box(Modifier.weight(1f).height(48.dp), contentAlignment = Alignment.Center) { middle() }
|
||||||
|
Row(Modifier.width(SIDE), horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Slot { away() }; Slot { chat() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val SLOT = 48.dp
|
||||||
|
private val SIDE = SLOT * 3
|
||||||
|
|
||||||
|
/** One button's place in the bar, kept whether or not the button shows. */
|
||||||
|
@Composable
|
||||||
|
private fun Slot(content: @Composable () -> Unit) {
|
||||||
|
Box(Modifier.size(SLOT), contentAlignment = Alignment.Center) { content() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An icon button that also answers a long press. */
|
||||||
|
@Composable
|
||||||
|
private fun BarButton(onClick: () -> Unit, onLongClick: () -> Unit, enabled: Boolean, content: @Composable () -> Unit) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.minimumInteractiveComponentSize()
|
||||||
|
.size(40.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.combinedClickable(enabled = enabled, role = Role.Button, onLongClick = onLongClick, onClick = onClick)
|
||||||
|
.alpha(if (enabled) 1f else 0.38f),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) { content() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PushToTalk(talking: Boolean, onPressed: (Boolean) -> Unit, modifier: Modifier) {
|
||||||
|
val colors = MaterialTheme.colorScheme
|
||||||
|
Surface(
|
||||||
|
modifier
|
||||||
|
.height(48.dp)
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
detectTapGestures(onPress = {
|
||||||
|
onPressed(true)
|
||||||
|
try {
|
||||||
|
awaitRelease()
|
||||||
|
} finally {
|
||||||
|
onPressed(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
shape = RoundedCornerShape(24.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The server's own icon, else the pack's server icon. */
|
||||||
|
@Composable
|
||||||
|
private fun ServerLogo(server: ServerController, modifier: Modifier = Modifier) {
|
||||||
|
val tree by server.tree.collectAsStateWithLifecycle()
|
||||||
|
val version by server.images.version.collectAsStateWithLifecycle()
|
||||||
|
val icon = remember(tree.serverIconId, version) { server.images.icon(tree.serverIconId) }
|
||||||
|
if (icon != null) Image(icon, null, modifier.size(24.dp))
|
||||||
|
else PackIcon("SERVER_GREEN", null, modifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The server on screen, opening onto the others and connecting to another. */
|
||||||
|
@Composable
|
||||||
|
private fun ServerSwitcher(vm: SessionController, server: ServerController, state: SessionState, onConnectOther: () -> Unit) {
|
||||||
|
val servers by vm.servers.collectAsStateWithLifecycle()
|
||||||
|
var open by remember { mutableStateOf(false) }
|
||||||
|
Box(Modifier.fillMaxWidth()) {
|
||||||
|
Row(Modifier.fillMaxWidth().heightIn(min = 48.dp).clickable { open = true },
|
||||||
|
verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
ServerLogo(server, Modifier.padding(end = 12.dp))
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(state.title, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
if (!state.connected) {
|
||||||
|
Text(state.status, style = MaterialTheme.typography.bodySmall, maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Icon(Icons.Filled.ArrowDropDown, "Servers")
|
||||||
|
}
|
||||||
|
DropdownMenu(open, onDismissRequest = { open = false }) {
|
||||||
|
for (other in servers) {
|
||||||
|
key(other.id) {
|
||||||
|
val otherState by other.state.collectAsStateWithLifecycle()
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(otherState.title, maxLines = 1, overflow = TextOverflow.Ellipsis) },
|
||||||
|
onClick = { open = false; vm.select(other) },
|
||||||
|
leadingIcon = {
|
||||||
|
ServerLogo(other, Modifier.alpha(if (otherState.connected) 1f else 0.5f))
|
||||||
|
},
|
||||||
|
trailingIcon = if (otherState.micActive) ({ PackIcon("ACTIVATE_MICROPHONE", "Microphone") }) else null,
|
||||||
|
colors = if (other == server) MenuDefaults.itemColors(textColor = MaterialTheme.colorScheme.primary)
|
||||||
|
else MenuDefaults.itemColors(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HorizontalDivider()
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Connect…") },
|
||||||
|
onClick = { open = false; onConnectOther() },
|
||||||
|
leadingIcon = { PackIcon("CONNECT", null) },
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Disconnect from current") },
|
||||||
|
onClick = { open = false; server.disconnect() },
|
||||||
|
leadingIcon = { PackIcon("DISCONNECT", null) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package com.ts3client.android.ui
|
||||||
|
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.ts3client.android.IconTheme
|
||||||
|
import com.ts3client.android.SessionController
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
private enum class SettingsPage { SOUND, DESIGN, IDENTITIES, MY_TEAMSPEAK }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The settings, by category. Every change is saved and put into effect at once,
|
||||||
|
* as there is no dialog to confirm.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun SettingsScreen(vm: SessionController, onContacts: () -> Unit, onBack: () -> Unit) {
|
||||||
|
var page by rememberSaveable { mutableStateOf<SettingsPage?>(null) }
|
||||||
|
when (page) {
|
||||||
|
SettingsPage.SOUND -> return SoundSettingsScreen(vm, onBack = { page = null })
|
||||||
|
SettingsPage.DESIGN -> return DesignScreen(vm, onBack = { page = null })
|
||||||
|
SettingsPage.IDENTITIES -> return IdentitiesScreen(vm.identities, onBack = { page = null })
|
||||||
|
SettingsPage.MY_TEAMSPEAK -> return MyTeamSpeakScreen(vm, onBack = { page = null })
|
||||||
|
null -> {}
|
||||||
|
}
|
||||||
|
|
||||||
|
SubScreen("Settings", onBack) { padding ->
|
||||||
|
Column(Modifier.padding(padding).verticalScroll(rememberScrollState())) {
|
||||||
|
LinkRow("Contacts", "Friends and blocked users", "CONTACT", onContacts)
|
||||||
|
LinkRow("Sound Settings", "Devices, transmission, voice activation, processing, volume", "PLAYBACK") {
|
||||||
|
page = SettingsPage.SOUND
|
||||||
|
}
|
||||||
|
LinkRow("Design", "Icon pack, country flags, description pictures", "GUISETUP") {
|
||||||
|
page = SettingsPage.DESIGN
|
||||||
|
}
|
||||||
|
LinkRow("Identities", "Create, import, export, security level",
|
||||||
|
"IDENTITY_MANAGER") { page = SettingsPage.IDENTITIES }
|
||||||
|
LinkRow("myTeamSpeak",
|
||||||
|
if (vm.myTeamSpeak.isSignedIn) "Signed in as ${vm.myTeamSpeak.username()}"
|
||||||
|
else "Sign in to bring over your synchronised bookmarks and identities",
|
||||||
|
"MYTS_ACCOUNT") { page = SettingsPage.MY_TEAMSPEAK }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DesignScreen(vm: SessionController, onBack: () -> Unit) {
|
||||||
|
SubScreen("Design", onBack) { padding ->
|
||||||
|
Column(Modifier.padding(padding).verticalScroll(rememberScrollState())) {
|
||||||
|
IconPackPicker(vm.icons)
|
||||||
|
var flags by remember { mutableStateOf(vm.settings.countryFlags) }
|
||||||
|
Toggle("Show country flags in the channel tree", flags) {
|
||||||
|
flags = it
|
||||||
|
vm.setCountryFlags(it)
|
||||||
|
}
|
||||||
|
var pictures by remember { mutableStateOf(vm.settings.descriptionPictures) }
|
||||||
|
Toggle("Show pictures in descriptions", pictures) {
|
||||||
|
pictures = it
|
||||||
|
vm.setDescriptionPictures(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Chooses the icon pack, and brings in TeamSpeak packs from a file. */
|
||||||
|
@Composable
|
||||||
|
private fun IconPackPicker(theme: IconTheme) {
|
||||||
|
val packs by theme.packs.collectAsStateWithLifecycle()
|
||||||
|
val active by theme.active.collectAsStateWithLifecycle()
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
var failed by remember { mutableStateOf<String?>(null) }
|
||||||
|
val importer = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
|
||||||
|
if (uri != null) scope.launch {
|
||||||
|
val pack = withContext(Dispatchers.IO) { runCatching { theme.import(uri) }.getOrNull() }
|
||||||
|
failed = if (pack == null) "That file is not an icon pack." else null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Picker("Icon pack", packs.map { it to it.name() }, active) { theme.select(it) }
|
||||||
|
LinkRow("Import icon pack", "A TeamSpeak 3 pack (.zip), such as those in the gfx folder of a TeamSpeak 3 install") {
|
||||||
|
importer.launch(arrayOf("application/zip", "application/octet-stream"))
|
||||||
|
}
|
||||||
|
failed?.let { Hint(it) }
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
package com.ts3client.android.ui
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.RowScope
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
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.RadioButton
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SegmentedButton
|
||||||
|
import androidx.compose.material3.SegmentedButtonDefaults
|
||||||
|
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||||
|
import androidx.compose.material3.Slider
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
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.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/** The width a segment needs for a label of a word or two; narrower screens get a list instead. */
|
||||||
|
private val SEGMENT_WIDTH = 110.dp
|
||||||
|
|
||||||
|
/** A full-screen page with a back arrow; the system back gesture leaves it the same way. */
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
internal fun SubScreen(
|
||||||
|
title: String,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
actions: @Composable RowScope.() -> Unit = {},
|
||||||
|
snackbar: SnackbarHostState? = null,
|
||||||
|
content: @Composable (PaddingValues) -> Unit,
|
||||||
|
) {
|
||||||
|
BackHandler(onBack = onBack)
|
||||||
|
Scaffold(
|
||||||
|
snackbarHost = { snackbar?.let { SnackbarHost(it) } },
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis) },
|
||||||
|
navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") } },
|
||||||
|
actions = actions,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A row that opens another page, or does one thing when tapped. */
|
||||||
|
@Composable
|
||||||
|
internal fun LinkRow(title: String, summary: String?, iconKey: String? = null, onClick: () -> Unit) {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text(title) },
|
||||||
|
supportingContent = summary?.let { { Text(it) } },
|
||||||
|
leadingContent = iconKey?.let { { PackIcon(it, null) } },
|
||||||
|
modifier = Modifier.clickable(onClick = onClick),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal 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
|
||||||
|
internal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One of [options] as a row of segments, or as radio buttons where the segments would not fit. */
|
||||||
|
@Composable
|
||||||
|
internal fun <T> Segments(options: List<Pair<T, String>>, selected: T, onSelect: (T) -> Unit) {
|
||||||
|
BoxWithConstraints(Modifier.fillMaxWidth()) {
|
||||||
|
if (maxWidth - 32.dp < SEGMENT_WIDTH * options.size) {
|
||||||
|
Column { for ((value, label) in options) Choice(label, value == selected) { onSelect(value) } }
|
||||||
|
return@BoxWithConstraints
|
||||||
|
}
|
||||||
|
SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp)) {
|
||||||
|
options.forEachIndexed { index, (value, label) ->
|
||||||
|
SegmentedButton(
|
||||||
|
selected = value == selected,
|
||||||
|
onClick = { onSelect(value) },
|
||||||
|
shape = SegmentedButtonDefaults.itemShape(index, options.size),
|
||||||
|
) { Text(label, maxLines = 1, overflow = TextOverflow.Ellipsis) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun Toggle(label: String, checked: Boolean, onChange: (Boolean) -> Unit) {
|
||||||
|
Row(Modifier.fillMaxWidth().clickable { onChange(!checked) }.padding(horizontal = 16.dp, vertical = 2.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(label, Modifier.weight(1f))
|
||||||
|
Switch(checked, onChange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun Hint(text: String) {
|
||||||
|
Text(text, Modifier.padding(horizontal = 16.dp, vertical = 2.dp),
|
||||||
|
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One of [options], shown as its label and picked from a menu. */
|
||||||
|
@Composable
|
||||||
|
internal fun <T> Picker(label: String, options: List<Pair<T, String>>, selected: T, onSelect: (T) -> Unit) {
|
||||||
|
var open by remember { mutableStateOf(false) }
|
||||||
|
Box {
|
||||||
|
Column(Modifier.fillMaxWidth().clickable { open = true }.padding(horizontal = 16.dp, vertical = 8.dp)) {
|
||||||
|
Text(label)
|
||||||
|
Text(options.firstOrNull { it.first == selected }?.second ?: "", style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
DropdownMenu(open, { open = false }) {
|
||||||
|
for ((value, text) in options) {
|
||||||
|
DropdownMenuItem({ Text(text) }, onClick = {
|
||||||
|
open = false
|
||||||
|
onSelect(value)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A slider that applies its value once let go of. */
|
||||||
|
@Composable
|
||||||
|
internal fun Level(
|
||||||
|
label: String,
|
||||||
|
value: Float,
|
||||||
|
range: ClosedFloatingPointRange<Float>,
|
||||||
|
format: (Float) -> String,
|
||||||
|
steps: Int = 0,
|
||||||
|
onChange: (Float) -> Unit = {},
|
||||||
|
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
|
||||||
|
onChange(it)
|
||||||
|
}, valueRange = range, steps = steps, onValueChangeFinished = { onDone(current) })
|
||||||
|
}
|
||||||
|
}
|
||||||
221
android/app/src/main/java/com/ts3client/android/ui/Sheets.kt
Normal file
221
android/app/src/main/java/com/ts3client/android/ui/Sheets.kt
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
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.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
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 androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import com.ts3client.android.ChannelItem
|
||||||
|
import com.ts3client.android.ClientItem
|
||||||
|
import com.ts3client.contacts.ContactType
|
||||||
|
import com.ts3client.net.Details
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
/** What can be done with a client: the private chat, a poke, a move, their volume, filing them as a contact. */
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ClientSheet(
|
||||||
|
client: ClientItem,
|
||||||
|
details: Details?,
|
||||||
|
onLink: (String) -> Unit,
|
||||||
|
away: Boolean,
|
||||||
|
initialVolume: Double,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onMessage: () -> Unit,
|
||||||
|
onPoke: (String) -> Unit,
|
||||||
|
onVolume: (Double) -> Unit,
|
||||||
|
onAway: (Boolean) -> Unit,
|
||||||
|
onAddContact: (ContactType) -> Unit,
|
||||||
|
onEditContact: () -> Unit,
|
||||||
|
/** Null when the client cannot be moved. */
|
||||||
|
onMove: (() -> Unit)?,
|
||||||
|
/** Null when the client cannot be moved, or is in our channel already. */
|
||||||
|
onMoveToOwnChannel: (() -> Unit)?,
|
||||||
|
/** Null when the sheet was opened from the tree. */
|
||||||
|
onFindInTree: (() -> Unit)?,
|
||||||
|
/** Kicks, bans and groups, as far as we may use them. */
|
||||||
|
moderation: @Composable () -> Unit = {},
|
||||||
|
) {
|
||||||
|
var poking by remember { mutableStateOf(false) }
|
||||||
|
ModalBottomSheet(onDismissRequest = onDismiss) {
|
||||||
|
Column(Modifier.verticalScroll(rememberScrollState()).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(Modifier.weight(1f)) {
|
||||||
|
// The country's flag goes before the name, as in TeamSpeak's info frame.
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
CountryFlag(client.country)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
val viewer = LocalPictureViewer.current
|
||||||
|
val images = LocalServerImages.current
|
||||||
|
Avatar(client.uniqueId, client.avatarFlag, Modifier.clickable(enabled = viewer != null && images != null) {
|
||||||
|
viewer?.invoke(ViewedPicture("avatar_${client.nickname}", ownAvatar = client.self) {
|
||||||
|
images?.avatarData(client.uniqueId, client.avatarFlag)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
details?.let { DetailsSection(it, onLink, Modifier.padding(bottom = 8.dp)) }
|
||||||
|
onFindInTree?.let { Action("CHANNEL_SWITCH", "Find client in channel tree", onClick = it) }
|
||||||
|
if (client.self) {
|
||||||
|
Action("AWAY", if (away) "Back" else "Away") { onAway(!away) }
|
||||||
|
moderation()
|
||||||
|
} else {
|
||||||
|
Action("PLAYER_CHAT", "Send message", onClick = onMessage)
|
||||||
|
Action("POKE", "Poke") { poking = true }
|
||||||
|
onMove?.let { Action("CHANNEL_SWITCH", "Move to channel…", onClick = it) }
|
||||||
|
onMoveToOwnChannel?.let { Action("MOVE_CLIENT_TO_OWN_CHANNEL", "Move to own channel", onClick = it) }
|
||||||
|
moderation()
|
||||||
|
if (client.contactType == null) {
|
||||||
|
Row {
|
||||||
|
Action("ADD_FRIEND", "Add as friend", Modifier.weight(1f)) { onAddContact(ContactType.FRIEND) }
|
||||||
|
Action("ADD_FOE", "Block", Modifier.weight(1f)) { onAddContact(ContactType.BLOCKED) }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Action("EDIT_FRIEND_FOE_STATUS", "Edit contact", onClick = onEditContact)
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
details: Details?,
|
||||||
|
onLink: (String) -> Unit,
|
||||||
|
current: Boolean,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onJoin: (String?) -> Unit,
|
||||||
|
onSubscribe: (Boolean) -> Unit,
|
||||||
|
) {
|
||||||
|
var askPassword by remember { mutableStateOf(false) }
|
||||||
|
ModalBottomSheet(onDismissRequest = onDismiss) {
|
||||||
|
Column(Modifier.verticalScroll(rememberScrollState()).navigationBarsPadding().padding(bottom = 16.dp)) {
|
||||||
|
Text(channel.name, Modifier.padding(horizontal = 24.dp, vertical = 8.dp),
|
||||||
|
style = MaterialTheme.typography.titleLarge)
|
||||||
|
details?.let { DetailsSection(it, onLink, Modifier.padding(bottom = 8.dp)) }
|
||||||
|
if (!current) {
|
||||||
|
Action("CHANNEL_SWITCH", "Join") {
|
||||||
|
if (channel.locked) askPassword = true else onJoin(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (channel.subscribed) {
|
||||||
|
Action("UNSUBSCRIBE_FROM_CHANNEL", "Unsubscribe") { onSubscribe(false) }
|
||||||
|
} else {
|
||||||
|
Action("SUBSCRIBE_TO_CHANNEL", "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) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The server's details: its version, who is on it, its welcome message. */
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ServerSheet(details: Details, onLink: (String) -> Unit, onDismiss: () -> Unit, onBanList: (() -> Unit)?) {
|
||||||
|
ModalBottomSheet(onDismissRequest = onDismiss) {
|
||||||
|
Column(Modifier.verticalScroll(rememberScrollState()).navigationBarsPadding().padding(bottom = 16.dp)) {
|
||||||
|
Row(Modifier.padding(horizontal = 24.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
PackIcon("SERVER_GREEN", null)
|
||||||
|
Text(details.title(), style = MaterialTheme.typography.titleLarge)
|
||||||
|
}
|
||||||
|
DetailsSection(details, onLink)
|
||||||
|
onBanList?.let { Action("BAN_LIST", "Ban list", onClick = it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun Action(iconKey: String, label: String, modifier: Modifier = Modifier.fillMaxWidth(), onClick: () -> Unit) {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text(label) },
|
||||||
|
leadingContent = { PackIcon(iconKey, null) },
|
||||||
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
modifier = modifier.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") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
package com.ts3client.android.ui
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.provider.Settings.ACTION_MANAGE_OVERLAY_PERMISSION
|
||||||
|
import android.provider.Settings.canDrawOverlays
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
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.Modifier
|
||||||
|
import androidx.compose.ui.geometry.CornerRadius
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.LifecycleStartEffect
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.ts3client.android.MicLevel
|
||||||
|
import com.ts3client.android.SessionController
|
||||||
|
import com.ts3client.android.audio.AudioRoute
|
||||||
|
import com.ts3client.audio.AudioDevice
|
||||||
|
import com.ts3client.audio.InputLevel
|
||||||
|
import com.ts3client.config.Settings
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
private val DENOISE_LEVELS = listOf("Off", "6 dB", "12 dB", "18 dB", "21 dB")
|
||||||
|
|
||||||
|
/** Audio devices, transmission, voice activation, processing and playback. */
|
||||||
|
@Composable
|
||||||
|
fun SoundSettingsScreen(vm: SessionController, onBack: () -> Unit) {
|
||||||
|
val settings = vm.settings
|
||||||
|
fun change(edit: Settings.() -> Unit) {
|
||||||
|
settings.edit()
|
||||||
|
vm.applySettings()
|
||||||
|
}
|
||||||
|
var inputMode by remember { mutableStateOf(settings.inputMode) }
|
||||||
|
var vadMode by remember { mutableStateOf(settings.vadMode) }
|
||||||
|
var agc by remember { mutableStateOf(settings.agc) }
|
||||||
|
var mutedTalkWarning by remember { mutableStateOf(settings.mutedTalkWarning) }
|
||||||
|
var threshold by remember { mutableFloatStateOf(settings.vadThresholdDb.toFloat()) }
|
||||||
|
var inputDevice by remember { mutableStateOf(settings.inputDevice) }
|
||||||
|
var outputDevice by remember { mutableStateOf(settings.outputDevice) }
|
||||||
|
val inputs by vm.inputDevices.collectAsStateWithLifecycle()
|
||||||
|
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) }
|
||||||
|
}
|
||||||
|
|
||||||
|
SubScreen("Sound Settings", onBack) { padding ->
|
||||||
|
Column(Modifier.padding(padding).verticalScroll(rememberScrollState()).padding(bottom = 8.dp)) {
|
||||||
|
Section("Devices")
|
||||||
|
DevicePicker("Microphone", inputs, inputDevice) {
|
||||||
|
inputDevice = it
|
||||||
|
change { this.inputDevice = it }
|
||||||
|
}
|
||||||
|
DevicePicker("Speaker", outputs, outputDevice) {
|
||||||
|
outputDevice = it
|
||||||
|
change { this.outputDevice = it }
|
||||||
|
}
|
||||||
|
Picker(
|
||||||
|
"Microphone preset",
|
||||||
|
INPUT_PRESETS.map { (preset, label) ->
|
||||||
|
val unsupported = preset == AudioRoute.InputPreset.UNPROCESSED && !vm.unprocessedSupported
|
||||||
|
preset to if (unsupported) "$label (not supported here)" else label
|
||||||
|
},
|
||||||
|
route.inputPreset(),
|
||||||
|
) { vm.setRoute(AudioRoute(it, route.usage())) }
|
||||||
|
Hint(PRESET_HINTS.getValue(route.inputPreset()))
|
||||||
|
Text("Play voices", Modifier.padding(start = 16.dp, top = 8.dp))
|
||||||
|
for ((usage, label) in USAGES) {
|
||||||
|
Choice(label, route.usage() == usage) { vm.setRoute(AudioRoute(route.inputPreset(), usage)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Transmission")
|
||||||
|
for ((mode, label) in INPUT_MODES) {
|
||||||
|
Choice(label, inputMode == mode) {
|
||||||
|
inputMode = mode
|
||||||
|
change { this.inputMode = mode }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (inputMode == Settings.InputMode.PUSH_TO_TALK) FloatingButtonToggle(vm)
|
||||||
|
val gated = inputMode == Settings.InputMode.VOICE_ACTIVATION && vadMode != Settings.VadMode.AUTOMATIC
|
||||||
|
LevelMeter(level, threshold.takeIf { gated })
|
||||||
|
if (!level.live) {
|
||||||
|
Hint(if (vm.hasMicPermission()) "The microphone is not available right now." else "Allow the microphone to see its level.")
|
||||||
|
}
|
||||||
|
|
||||||
|
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()}" },
|
||||||
|
onChange = { threshold = it }) {
|
||||||
|
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")
|
||||||
|
val suppression = if (settings.denoise) settings.denoiserLevel + 1 else 0
|
||||||
|
Level("Noise suppression", suppression.toFloat(), 0f..DENOISE_LEVELS.lastIndex.toFloat(),
|
||||||
|
{ DENOISE_LEVELS[it.roundToInt()] }, steps = DENOISE_LEVELS.lastIndex - 1) {
|
||||||
|
val step = it.roundToInt()
|
||||||
|
change {
|
||||||
|
denoise = step > 0
|
||||||
|
if (step > 0) denoiserLevel = step - 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Toggle("Automatic gain control", agc) {
|
||||||
|
agc = it
|
||||||
|
change { this.agc = it }
|
||||||
|
}
|
||||||
|
if (agc) {
|
||||||
|
Level("Boost quiet speech", settings.agcBoostDb.toFloat(), 0f..Settings.MAX_AGC_BOOST_DB.toFloat(),
|
||||||
|
{ "+${it.roundToInt()} dB" }, steps = Settings.MAX_AGC_BOOST_DB - 1) {
|
||||||
|
change { agcBoostDb = it.roundToInt() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Playback")
|
||||||
|
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() }
|
||||||
|
}
|
||||||
|
Toggle("Beep when talking while muted", mutedTalkWarning) {
|
||||||
|
mutedTalkWarning = it
|
||||||
|
change { this.mutedTalkWarning = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 INPUT_PRESETS = listOf(
|
||||||
|
AudioRoute.InputPreset.VOICE_RECOGNITION to "Voice recognition",
|
||||||
|
AudioRoute.InputPreset.VOICE_COMMUNICATION to "Voice communication",
|
||||||
|
AudioRoute.InputPreset.UNPROCESSED to "Unprocessed",
|
||||||
|
AudioRoute.InputPreset.VOICE_PERFORMANCE to "Voice performance",
|
||||||
|
AudioRoute.InputPreset.GENERIC to "Generic",
|
||||||
|
AudioRoute.InputPreset.CAMCORDER to "Camcorder",
|
||||||
|
)
|
||||||
|
|
||||||
|
private val PRESET_HINTS = mapOf(
|
||||||
|
AudioRoute.InputPreset.VOICE_RECOGNITION to "Little or no processing by the phone; the client's own does the rest.",
|
||||||
|
AudioRoute.InputPreset.VOICE_COMMUNICATION to
|
||||||
|
"The phone's call processing: echo cancellation, noise suppression and gain. Works best with voices played as a call.",
|
||||||
|
AudioRoute.InputPreset.UNPROCESSED to "The raw microphone signal.",
|
||||||
|
AudioRoute.InputPreset.VOICE_PERFORMANCE to "Low latency and no processing, meant for live performance.",
|
||||||
|
AudioRoute.InputPreset.GENERIC to "The phone's default microphone source, with whatever it applies to it.",
|
||||||
|
AudioRoute.InputPreset.CAMCORDER to "Tuned for recording video, often with the rear microphone.",
|
||||||
|
)
|
||||||
|
|
||||||
|
private val USAGES = listOf(
|
||||||
|
AudioRoute.Usage.MEDIA to "As media (Bluetooth stays in high quality)",
|
||||||
|
AudioRoute.Usage.VOICE_COMMUNICATION to "As a call (lets the phone cancel echo)",
|
||||||
|
)
|
||||||
|
|
||||||
|
private val VAD_MODES = listOf(
|
||||||
|
Settings.VadMode.AUTOMATIC to "Automatic",
|
||||||
|
Settings.VadMode.VOLUME_GATE to "Volume gate",
|
||||||
|
Settings.VadMode.HYBRID to "Hybrid",
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The push-to-talk button over other apps, which first needs the user to allow the app to draw there. */
|
||||||
|
@Composable
|
||||||
|
private fun FloatingButtonToggle(vm: SessionController) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val enabled by vm.pttOverlay.collectAsStateWithLifecycle()
|
||||||
|
val allow = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
||||||
|
if (canDrawOverlays(context)) vm.setPttOverlay(true)
|
||||||
|
}
|
||||||
|
Toggle("Floating button over other apps", enabled && canDrawOverlays(context)) { on ->
|
||||||
|
if (!on || canDrawOverlays(context)) vm.setPttOverlay(on)
|
||||||
|
else allow.launch(Intent(ACTION_MANAGE_OVERLAY_PERMISSION, Uri.parse("package:${context.packageName}")))
|
||||||
|
}
|
||||||
|
Hint("Hold it to talk. Double-tap and keep holding to drag it elsewhere.")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Picks a device by its settings id; one that is not plugged in now still shows as chosen. */
|
||||||
|
@Composable
|
||||||
|
private fun DevicePicker(label: String, devices: List<AudioDevice>, selected: String, onSelect: (String) -> Unit) {
|
||||||
|
val options = devices.map { it.id() to it.label() }.let { list ->
|
||||||
|
if (list.any { it.first == selected }) list else list + (selected to "Not connected")
|
||||||
|
}
|
||||||
|
Picker(label, options, selected, onSelect)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The microphone level, lit while it would be sent, with the volume gate's threshold marked. */
|
||||||
|
@Composable
|
||||||
|
private fun LevelMeter(level: MicLevel, thresholdDb: Float?) {
|
||||||
|
val track = MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
val fill = if (level.transmitting) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline
|
||||||
|
val marker = MaterialTheme.colorScheme.onSurface
|
||||||
|
fun fraction(db: Double) =
|
||||||
|
((db - InputLevel.MIN_DB) / (InputLevel.MAX_DB - InputLevel.MIN_DB)).toFloat().coerceIn(0f, 1f)
|
||||||
|
Column(Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
|
||||||
|
Row {
|
||||||
|
Text("Microphone level", Modifier.weight(1f))
|
||||||
|
if (level.live) Text("${level.db.roundToInt()}", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
Canvas(Modifier.fillMaxWidth().padding(top = 6.dp).height(12.dp)) {
|
||||||
|
val radius = CornerRadius(size.height / 2)
|
||||||
|
drawRoundRect(track, cornerRadius = radius)
|
||||||
|
if (level.live) {
|
||||||
|
drawRoundRect(fill, size = Size(size.width * fraction(level.db), size.height), cornerRadius = radius)
|
||||||
|
}
|
||||||
|
if (thresholdDb != null) {
|
||||||
|
val x = size.width * fraction(thresholdDb.toDouble())
|
||||||
|
drawLine(marker, Offset(x, -2.dp.toPx()), Offset(x, size.height + 2.dp.toPx()), strokeWidth = 2.dp.toPx())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
38
android/app/src/main/java/com/ts3client/android/ui/Theme.kt
Normal file
38
android/app/src/main/java/com/ts3client/android/ui/Theme.kt
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Links in chat and descriptions: blue, as links are, whatever the wallpaper's colours. */
|
||||||
|
@Composable
|
||||||
|
fun linkColor(): Color = if (isSystemInDarkTheme()) Color(0xFF7AB4FF) else Color(0xFF1A62D0)
|
||||||
|
|
||||||
|
/** Behind every match of the find bar's query, as on the desktop. */
|
||||||
|
@Composable
|
||||||
|
fun findMatchColor(): Color = if (isSystemInDarkTheme()) Color(0xFF5C4E1A) else Color(0xFFFFEBA3)
|
||||||
|
|
||||||
|
/** Behind the match the find bar is on. */
|
||||||
|
@Composable
|
||||||
|
fun findCurrentColor(): Color = if (isSystemInDarkTheme()) Color(0xFF9C6A12) else Color(0xFFFFB84D)
|
||||||
|
|
||||||
|
/** 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)
|
||||||
|
}
|
||||||
7
android/app/src/main/res/values/strings.xml
Normal file
7
android/app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<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>
|
||||||
42
android/audio/build.gradle.kts
Normal file
42
android/audio/build.gradle.kts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.library)
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.ts3client.android.audio"
|
||||||
|
compileSdk = 37
|
||||||
|
ndkVersion = "30.0.16248370"
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
minSdk = 33
|
||||||
|
ndk {
|
||||||
|
abiFilters += listOf("arm64-v8a", "x86_64")
|
||||||
|
}
|
||||||
|
externalNativeBuild {
|
||||||
|
cmake {
|
||||||
|
arguments += "-DANDROID_STL=c++_shared"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildFeatures {
|
||||||
|
prefab = true
|
||||||
|
}
|
||||||
|
|
||||||
|
externalNativeBuild {
|
||||||
|
cmake {
|
||||||
|
path = file("src/main/cpp/CMakeLists.txt")
|
||||||
|
version = "4.1.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
api(project(":core"))
|
||||||
|
implementation(libs.oboe)
|
||||||
|
}
|
||||||
22
android/audio/src/main/cpp/CMakeLists.txt
Normal file
22
android/audio/src/main/cpp/CMakeLists.txt
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
cmake_minimum_required(VERSION 3.22)
|
||||||
|
project(ts3audio LANGUAGES C CXX)
|
||||||
|
|
||||||
|
set(CMAKE_CXX_STANDARD 20)
|
||||||
|
|
||||||
|
find_package(oboe REQUIRED CONFIG)
|
||||||
|
|
||||||
|
# libopus from its release tarball, built static into our one library.
|
||||||
|
include(FetchContent)
|
||||||
|
FetchContent_Declare(opus
|
||||||
|
URL https://github.com/xiph/opus/releases/download/v1.5.2/opus-1.5.2.tar.gz
|
||||||
|
URL_HASH SHA256=65c1d2f78b9f2fb20082c38cbe47c951ad5839345876e46941612ee87f9a7ce1)
|
||||||
|
set(OPUS_BUILD_PROGRAMS OFF CACHE BOOL "" FORCE)
|
||||||
|
set(OPUS_BUILD_TESTING OFF CACHE BOOL "" FORCE)
|
||||||
|
set(OPUS_BUILD_SHARED_LIBRARY OFF CACHE BOOL "" FORCE)
|
||||||
|
set(OPUS_INSTALL_PKG_CONFIG_MODULE OFF CACHE BOOL "" FORCE)
|
||||||
|
set(OPUS_INSTALL_CMAKE_CONFIG_MODULE OFF CACHE BOOL "" FORCE)
|
||||||
|
FetchContent_MakeAvailable(opus)
|
||||||
|
|
||||||
|
add_library(ts3audio SHARED audio_line.cpp opus_jni.cpp)
|
||||||
|
target_link_libraries(ts3audio PRIVATE oboe::oboe opus log)
|
||||||
|
target_compile_options(ts3audio PRIVATE -Wall -Wextra)
|
||||||
274
android/audio/src/main/cpp/audio_line.cpp
Normal file
274
android/audio/src/main/cpp/audio_line.cpp
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
// An Oboe stream behind the blocking read/write interface of AudioCapture and AudioPlayback.
|
||||||
|
//
|
||||||
|
// The real-time callback never waits and never calls into Java: it only moves samples
|
||||||
|
// between the device and a lock-free ring, which the Java side fills or drains at its own
|
||||||
|
// pace, blocking while the ring is full (playback) or short (capture).
|
||||||
|
|
||||||
|
#include <jni.h>
|
||||||
|
#include <oboe/Oboe.h>
|
||||||
|
#include <android/log.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <atomic>
|
||||||
|
#include <chrono>
|
||||||
|
#include <cstring>
|
||||||
|
#include <memory>
|
||||||
|
#include <mutex>
|
||||||
|
#include <thread>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
constexpr int32_t kSampleRate = 48000;
|
||||||
|
constexpr int32_t kFrameSize = 960; // 20 ms
|
||||||
|
// Capture keeps some slack for a Java thread that is briefly late; playback keeps little,
|
||||||
|
// since everything queued is latency.
|
||||||
|
constexpr int32_t kCaptureFrames = 8;
|
||||||
|
constexpr int32_t kPlaybackFrames = 3;
|
||||||
|
constexpr auto kPollInterval = std::chrono::milliseconds(1);
|
||||||
|
|
||||||
|
const char *kTag = "ts3audio";
|
||||||
|
|
||||||
|
/** Single-producer, single-consumer ring of 16-bit samples. */
|
||||||
|
class SampleRing {
|
||||||
|
public:
|
||||||
|
explicit SampleRing(size_t capacity) : buffer_(capacity) {}
|
||||||
|
|
||||||
|
size_t available() const {
|
||||||
|
return head_.load(std::memory_order_acquire) - tail_.load(std::memory_order_acquire);
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t space() const { return buffer_.size() - available(); }
|
||||||
|
|
||||||
|
/** Producer side: stores as much of {@code data} as fits. */
|
||||||
|
size_t write(const int16_t *data, size_t count) {
|
||||||
|
size_t head = head_.load(std::memory_order_relaxed);
|
||||||
|
size_t tail = tail_.load(std::memory_order_acquire);
|
||||||
|
size_t n = std::min(count, buffer_.size() - (head - tail));
|
||||||
|
for (size_t i = 0; i < n; i++) buffer_[(head + i) % buffer_.size()] = data[i];
|
||||||
|
head_.store(head + n, std::memory_order_release);
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Consumer side: takes up to {@code count} samples. */
|
||||||
|
size_t read(int16_t *data, size_t count) {
|
||||||
|
size_t tail = tail_.load(std::memory_order_relaxed);
|
||||||
|
size_t head = head_.load(std::memory_order_acquire);
|
||||||
|
size_t n = std::min(count, head - tail);
|
||||||
|
for (size_t i = 0; i < n; i++) data[i] = buffer_[(tail + i) % buffer_.size()];
|
||||||
|
tail_.store(tail + n, std::memory_order_release);
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::vector<int16_t> buffer_;
|
||||||
|
std::atomic<size_t> head_{0};
|
||||||
|
std::atomic<size_t> tail_{0};
|
||||||
|
};
|
||||||
|
|
||||||
|
class AudioLine : public oboe::AudioStreamDataCallback, public oboe::AudioStreamErrorCallback {
|
||||||
|
public:
|
||||||
|
/** {@code preset} is the input preset of a capture line, {@code usage} a playback line's usage. */
|
||||||
|
AudioLine(bool input, int32_t deviceId, int32_t channels, int32_t preset, int32_t usage)
|
||||||
|
: input_(input), deviceId_(deviceId), channels_(channels), preset_(preset), usage_(usage),
|
||||||
|
ring_(static_cast<size_t>(kFrameSize) * channels * (input ? kCaptureFrames : kPlaybackFrames)) {}
|
||||||
|
|
||||||
|
int32_t channels() const { return channels_; }
|
||||||
|
|
||||||
|
oboe::Result open() {
|
||||||
|
oboe::AudioStreamBuilder builder;
|
||||||
|
builder.setDirection(input_ ? oboe::Direction::Input : oboe::Direction::Output)
|
||||||
|
->setSharingMode(oboe::SharingMode::Shared)
|
||||||
|
->setPerformanceMode(oboe::PerformanceMode::LowLatency)
|
||||||
|
->setFormat(oboe::AudioFormat::I16)
|
||||||
|
->setFormatConversionAllowed(true)
|
||||||
|
->setChannelCount(channels_)
|
||||||
|
->setChannelConversionAllowed(true)
|
||||||
|
->setSampleRate(kSampleRate)
|
||||||
|
->setSampleRateConversionQuality(oboe::SampleRateConversionQuality::Medium)
|
||||||
|
->setDataCallback(this)
|
||||||
|
->setErrorCallback(this);
|
||||||
|
int32_t deviceId = deviceId_.load();
|
||||||
|
if (deviceId > 0) builder.setDeviceId(deviceId);
|
||||||
|
if (input_) {
|
||||||
|
builder.setInputPreset(static_cast<oboe::InputPreset>(preset_.load()));
|
||||||
|
} else {
|
||||||
|
builder.setUsage(static_cast<oboe::Usage>(usage_.load()))->setContentType(oboe::ContentType::Speech);
|
||||||
|
}
|
||||||
|
std::shared_ptr<oboe::AudioStream> stream;
|
||||||
|
oboe::Result result = builder.openStream(stream);
|
||||||
|
if (result != oboe::Result::OK) return result;
|
||||||
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
|
if (closing_.load()) { // closed while a reopen was under way
|
||||||
|
stream->close();
|
||||||
|
return oboe::Result::ErrorClosed;
|
||||||
|
}
|
||||||
|
if (stream_) stream_->close(); // a concurrent reopen got there first
|
||||||
|
stream_ = stream;
|
||||||
|
if (started_) result = stream_->requestStart();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
oboe::Result start() {
|
||||||
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
|
started_ = true;
|
||||||
|
return stream_ ? stream_->requestStart() : oboe::Result::ErrorClosed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Moves the line to another device or configuration; the ring and whoever blocks on it stay. */
|
||||||
|
oboe::Result reopen(int32_t deviceId, int32_t preset, int32_t usage) {
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
|
if (closing_.load()) return oboe::Result::ErrorClosed;
|
||||||
|
if (stream_) {
|
||||||
|
stream_->stop();
|
||||||
|
stream_->close();
|
||||||
|
stream_.reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
deviceId_.store(deviceId);
|
||||||
|
preset_.store(preset);
|
||||||
|
usage_.store(usage);
|
||||||
|
return open();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Blocks until {@code count} samples are captured; fewer only once closing. */
|
||||||
|
size_t read(int16_t *data, size_t count) {
|
||||||
|
size_t done = 0;
|
||||||
|
while (done < count && !closing_.load()) {
|
||||||
|
done += ring_.read(data + done, count - done);
|
||||||
|
if (done < count) std::this_thread::sleep_for(kPollInterval);
|
||||||
|
}
|
||||||
|
return done;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Blocks until all samples are queued for playback, or the line closes. */
|
||||||
|
void write(const int16_t *data, size_t count) {
|
||||||
|
size_t done = 0;
|
||||||
|
while (done < count && !closing_.load()) {
|
||||||
|
done += ring_.write(data + done, count - done);
|
||||||
|
if (done < count) std::this_thread::sleep_for(kPollInterval);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void close() {
|
||||||
|
closing_.store(true);
|
||||||
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
|
if (stream_) {
|
||||||
|
stream_->stop();
|
||||||
|
stream_->close();
|
||||||
|
stream_.reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
oboe::DataCallbackResult onAudioReady(oboe::AudioStream *, void *audioData, int32_t numFrames) override {
|
||||||
|
auto *samples = static_cast<int16_t *>(audioData);
|
||||||
|
size_t count = static_cast<size_t>(numFrames) * channels_;
|
||||||
|
if (input_) {
|
||||||
|
ring_.write(samples, count); // a reader that fell behind loses the newest audio
|
||||||
|
} else {
|
||||||
|
size_t played = ring_.read(samples, count);
|
||||||
|
if (played < count) std::memset(samples + played, 0, (count - played) * sizeof(int16_t));
|
||||||
|
}
|
||||||
|
return oboe::DataCallbackResult::Continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The device went away (headset unplugged, route changed): follow onto the new one. */
|
||||||
|
void onErrorAfterClose(oboe::AudioStream *, oboe::Result error) override {
|
||||||
|
if (closing_.load()) return;
|
||||||
|
__android_log_print(ANDROID_LOG_INFO, kTag, "stream closed (%s), reopening",
|
||||||
|
oboe::convertToText(error));
|
||||||
|
deviceId_.store(0);
|
||||||
|
open();
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
const bool input_;
|
||||||
|
std::atomic<int32_t> deviceId_;
|
||||||
|
const int32_t channels_;
|
||||||
|
std::atomic<int32_t> preset_;
|
||||||
|
std::atomic<int32_t> usage_;
|
||||||
|
SampleRing ring_;
|
||||||
|
std::atomic<bool> closing_{false};
|
||||||
|
std::mutex mutex_;
|
||||||
|
std::shared_ptr<oboe::AudioStream> stream_;
|
||||||
|
bool started_ = false; // guarded by mutex_
|
||||||
|
};
|
||||||
|
|
||||||
|
AudioLine *line(jlong handle) {
|
||||||
|
return reinterpret_cast<AudioLine *>(handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
thread_local std::vector<int16_t> scratch;
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
extern "C" {
|
||||||
|
|
||||||
|
JNIEXPORT jlong JNICALL
|
||||||
|
Java_com_ts3client_android_audio_OboeLine_nativeOpen(JNIEnv *env, jclass, jboolean input, jint deviceId,
|
||||||
|
jint channels, jint preset, jint usage) {
|
||||||
|
auto *created = new AudioLine(input, deviceId, channels, preset, usage);
|
||||||
|
oboe::Result result = created->open();
|
||||||
|
if (result != oboe::Result::OK) {
|
||||||
|
delete created;
|
||||||
|
env->ThrowNew(env->FindClass("java/lang/IllegalStateException"), oboe::convertToText(result));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return reinterpret_cast<jlong>(created);
|
||||||
|
}
|
||||||
|
|
||||||
|
JNIEXPORT jint JNICALL
|
||||||
|
Java_com_ts3client_android_audio_OboeLine_nativeChannels(JNIEnv *, jclass, jlong handle) {
|
||||||
|
return line(handle)->channels();
|
||||||
|
}
|
||||||
|
|
||||||
|
JNIEXPORT void JNICALL
|
||||||
|
Java_com_ts3client_android_audio_OboeLine_nativeStart(JNIEnv *env, jclass, jlong handle) {
|
||||||
|
oboe::Result result = line(handle)->start();
|
||||||
|
if (result != oboe::Result::OK) {
|
||||||
|
env->ThrowNew(env->FindClass("java/lang/IllegalStateException"), oboe::convertToText(result));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
JNIEXPORT void JNICALL
|
||||||
|
Java_com_ts3client_android_audio_OboeLine_nativeReopen(JNIEnv *env, jclass, jlong handle, jint deviceId,
|
||||||
|
jint preset, jint usage) {
|
||||||
|
oboe::Result result = line(handle)->reopen(deviceId, preset, usage);
|
||||||
|
if (result != oboe::Result::OK && result != oboe::Result::ErrorClosed) {
|
||||||
|
env->ThrowNew(env->FindClass("java/lang/IllegalStateException"), oboe::convertToText(result));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
JNIEXPORT jint JNICALL
|
||||||
|
Java_com_ts3client_android_audio_OboeLine_nativeRead(JNIEnv *env, jclass, jlong handle, jbyteArray buffer,
|
||||||
|
jint offset, jint length) {
|
||||||
|
size_t samples = static_cast<size_t>(length) / sizeof(int16_t);
|
||||||
|
scratch.resize(samples);
|
||||||
|
size_t read = line(handle)->read(scratch.data(), samples);
|
||||||
|
env->SetByteArrayRegion(buffer, offset, static_cast<jsize>(read * sizeof(int16_t)),
|
||||||
|
reinterpret_cast<const jbyte *>(scratch.data()));
|
||||||
|
return static_cast<jint>(read * sizeof(int16_t));
|
||||||
|
}
|
||||||
|
|
||||||
|
JNIEXPORT void JNICALL
|
||||||
|
Java_com_ts3client_android_audio_OboeLine_nativeWrite(JNIEnv *env, jclass, jlong handle, jbyteArray buffer,
|
||||||
|
jint offset, jint length) {
|
||||||
|
size_t samples = static_cast<size_t>(length) / sizeof(int16_t);
|
||||||
|
scratch.resize(samples);
|
||||||
|
env->GetByteArrayRegion(buffer, offset, static_cast<jsize>(samples * sizeof(int16_t)),
|
||||||
|
reinterpret_cast<jbyte *>(scratch.data()));
|
||||||
|
line(handle)->write(scratch.data(), samples);
|
||||||
|
}
|
||||||
|
|
||||||
|
JNIEXPORT void JNICALL
|
||||||
|
Java_com_ts3client_android_audio_OboeLine_nativeClose(JNIEnv *, jclass, jlong handle) {
|
||||||
|
line(handle)->close();
|
||||||
|
}
|
||||||
|
|
||||||
|
JNIEXPORT void JNICALL
|
||||||
|
Java_com_ts3client_android_audio_OboeLine_nativeDelete(JNIEnv *, jclass, jlong handle) {
|
||||||
|
delete line(handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // extern "C"
|
||||||
109
android/audio/src/main/cpp/opus_jni.cpp
Normal file
109
android/audio/src/main/cpp/opus_jni.cpp
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
// libopus for NativeOpus: handles are the raw encoder/decoder pointers.
|
||||||
|
|
||||||
|
#include <jni.h>
|
||||||
|
#include <opus.h>
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
constexpr int kMaxPacketBytes = 4096;
|
||||||
|
|
||||||
|
void throwOpus(JNIEnv *env, const char *call, int error) {
|
||||||
|
std::string message = std::string(call) + " failed: " + opus_strerror(error);
|
||||||
|
env->ThrowNew(env->FindClass("java/lang/IllegalStateException"), message.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
OpusEncoder *encoder(jlong handle) { return reinterpret_cast<OpusEncoder *>(handle); }
|
||||||
|
|
||||||
|
OpusDecoder *decoder(jlong handle) { return reinterpret_cast<OpusDecoder *>(handle); }
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
extern "C" {
|
||||||
|
|
||||||
|
JNIEXPORT jlong JNICALL
|
||||||
|
Java_com_ts3client_android_audio_NativeOpus_encoderCreate(JNIEnv *env, jclass, jint sampleRate, jint channels,
|
||||||
|
jint application) {
|
||||||
|
int error = OPUS_OK;
|
||||||
|
OpusEncoder *created = opus_encoder_create(sampleRate, channels, application, &error);
|
||||||
|
if (error != OPUS_OK) {
|
||||||
|
throwOpus(env, "opus_encoder_create", error);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return reinterpret_cast<jlong>(created);
|
||||||
|
}
|
||||||
|
|
||||||
|
JNIEXPORT void JNICALL
|
||||||
|
Java_com_ts3client_android_audio_NativeOpus_encoderCtl(JNIEnv *env, jclass, jlong handle, jint request,
|
||||||
|
jint value) {
|
||||||
|
int result = opus_encoder_ctl(encoder(handle), request, value);
|
||||||
|
if (result < 0) throwOpus(env, "opus_encoder_ctl", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
JNIEXPORT jbyteArray JNICALL
|
||||||
|
Java_com_ts3client_android_audio_NativeOpus_encode(JNIEnv *env, jclass, jlong handle, jfloatArray pcm,
|
||||||
|
jint frameSize) {
|
||||||
|
unsigned char packet[kMaxPacketBytes];
|
||||||
|
jfloat *samples = env->GetFloatArrayElements(pcm, nullptr);
|
||||||
|
int length = opus_encode_float(encoder(handle), samples, frameSize, packet, kMaxPacketBytes);
|
||||||
|
env->ReleaseFloatArrayElements(pcm, samples, JNI_ABORT);
|
||||||
|
if (length < 0) {
|
||||||
|
throwOpus(env, "opus_encode_float", length);
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
jbyteArray out = env->NewByteArray(length);
|
||||||
|
env->SetByteArrayRegion(out, 0, length, reinterpret_cast<const jbyte *>(packet));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
JNIEXPORT void JNICALL
|
||||||
|
Java_com_ts3client_android_audio_NativeOpus_encoderDestroy(JNIEnv *, jclass, jlong handle) {
|
||||||
|
opus_encoder_destroy(encoder(handle));
|
||||||
|
}
|
||||||
|
|
||||||
|
JNIEXPORT jlong JNICALL
|
||||||
|
Java_com_ts3client_android_audio_NativeOpus_decoderCreate(JNIEnv *env, jclass, jint sampleRate, jint channels) {
|
||||||
|
int error = OPUS_OK;
|
||||||
|
OpusDecoder *created = opus_decoder_create(sampleRate, channels, &error);
|
||||||
|
if (error != OPUS_OK) {
|
||||||
|
throwOpus(env, "opus_decoder_create", error);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return reinterpret_cast<jlong>(created);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Decodes {@code packet}, or conceals {@code frameSize} samples when it is null. */
|
||||||
|
JNIEXPORT jint JNICALL
|
||||||
|
Java_com_ts3client_android_audio_NativeOpus_decode(JNIEnv *env, jclass, jlong handle, jbyteArray packet,
|
||||||
|
jfloatArray out, jint frameSize) {
|
||||||
|
std::vector<unsigned char> data;
|
||||||
|
if (packet != nullptr) {
|
||||||
|
data.resize(static_cast<size_t>(env->GetArrayLength(packet)));
|
||||||
|
env->GetByteArrayRegion(packet, 0, static_cast<jsize>(data.size()), reinterpret_cast<jbyte *>(data.data()));
|
||||||
|
}
|
||||||
|
jfloat *samples = env->GetFloatArrayElements(out, nullptr);
|
||||||
|
int decoded = opus_decode_float(decoder(handle), packet != nullptr ? data.data() : nullptr,
|
||||||
|
static_cast<opus_int32>(data.size()), samples, frameSize, 0);
|
||||||
|
env->ReleaseFloatArrayElements(out, samples, decoded < 0 ? JNI_ABORT : 0);
|
||||||
|
if (decoded < 0) throwOpus(env, "opus_decode_float", decoded);
|
||||||
|
return decoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
JNIEXPORT void JNICALL
|
||||||
|
Java_com_ts3client_android_audio_NativeOpus_decoderReset(JNIEnv *, jclass, jlong handle) {
|
||||||
|
opus_decoder_ctl(decoder(handle), OPUS_RESET_STATE);
|
||||||
|
}
|
||||||
|
|
||||||
|
JNIEXPORT void JNICALL
|
||||||
|
Java_com_ts3client_android_audio_NativeOpus_decoderDestroy(JNIEnv *, jclass, jlong handle) {
|
||||||
|
opus_decoder_destroy(decoder(handle));
|
||||||
|
}
|
||||||
|
|
||||||
|
JNIEXPORT jstring JNICALL
|
||||||
|
Java_com_ts3client_android_audio_NativeOpus_version0(JNIEnv *env, jclass) {
|
||||||
|
return env->NewStringUTF(opus_get_version_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
} // extern "C"
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package com.ts3client.android.audio;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
|
||||||
|
import com.ts3client.audio.AudioBackend;
|
||||||
|
import com.ts3client.audio.AudioIo;
|
||||||
|
import com.ts3client.audio.StreamingVoiceOutput;
|
||||||
|
import com.ts3client.audio.opus.OpusCodec;
|
||||||
|
|
||||||
|
/** Android's audio stack: Oboe for the devices, libopus built with the NDK. */
|
||||||
|
public final class AndroidAudioBackend implements AudioBackend {
|
||||||
|
|
||||||
|
private final AndroidAudioIo io;
|
||||||
|
private final OpusCodec opus = new NativeOpus();
|
||||||
|
|
||||||
|
public AndroidAudioBackend(Context context) {
|
||||||
|
this.io = new AndroidAudioIo(context.getApplicationContext());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public AudioIo io() {
|
||||||
|
return io;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How the lines meet Android's audio: microphone preset, and media or call playback. */
|
||||||
|
public AudioRoute route() {
|
||||||
|
return io.route();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Applies at once to the lines that are open. */
|
||||||
|
public void setRoute(AudioRoute route) {
|
||||||
|
io.setRoute(route);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the device offers {@link AudioRoute.InputPreset#UNPROCESSED} truly unprocessed. */
|
||||||
|
public boolean unprocessedSupported() {
|
||||||
|
return io.unprocessedSupported();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs on the main thread whenever an audio device comes or goes. */
|
||||||
|
public void addDeviceListener(Runnable listener) {
|
||||||
|
io.addDeviceListener(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public OpusCodec opus() {
|
||||||
|
return opus;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One low-latency stream for everyone, as mobile audio wants. */
|
||||||
|
@Override
|
||||||
|
public StreamingVoiceOutput.Lines outputLines() {
|
||||||
|
return StreamingVoiceOutput.Lines.SHARED;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String description() {
|
||||||
|
return "Opus " + opus.version() + ", Oboe";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
package com.ts3client.android.audio;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.media.AudioDeviceCallback;
|
||||||
|
import android.media.AudioDeviceInfo;
|
||||||
|
import android.media.AudioManager;
|
||||||
|
import android.os.Handler;
|
||||||
|
import android.os.Looper;
|
||||||
|
|
||||||
|
import com.ts3client.audio.AudioCapture;
|
||||||
|
import com.ts3client.audio.AudioDevice;
|
||||||
|
import com.ts3client.audio.AudioIo;
|
||||||
|
import com.ts3client.audio.AudioPlayback;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The device's microphones and speakers, opened through Oboe.
|
||||||
|
*
|
||||||
|
* <p>Android's device ids are handed out anew whenever a device appears, so a Bluetooth
|
||||||
|
* headset has another one each time it reconnects. What the settings keep is the device's
|
||||||
|
* type and address instead, which stay the same, and each open looks the id up.
|
||||||
|
*
|
||||||
|
* <p>Also owns the phone's call state: communication mode and the communication device are
|
||||||
|
* only set while a line needs them, i.e. playback as a call or a headset microphone.
|
||||||
|
*/
|
||||||
|
final class AndroidAudioIo implements AudioIo {
|
||||||
|
|
||||||
|
/** AudioDeviceInfo.TYPE_ECHO_REFERENCE, which the SDK keeps as a system API. */
|
||||||
|
private static final int TYPE_ECHO_REFERENCE = 28;
|
||||||
|
|
||||||
|
private final AudioManager audioManager;
|
||||||
|
private final Set<OboeLine> open = ConcurrentHashMap.newKeySet();
|
||||||
|
/** Reopening streams takes a moment; it happens off the caller's thread, one at a time. */
|
||||||
|
private final ExecutorService reopener = Executors.newSingleThreadExecutor(r -> {
|
||||||
|
Thread t = new Thread(r, "audio-reopen");
|
||||||
|
t.setDaemon(true);
|
||||||
|
return t;
|
||||||
|
});
|
||||||
|
private final List<Runnable> deviceListeners = new ArrayList<>();
|
||||||
|
private volatile AudioRoute route = AudioRoute.DEFAULT;
|
||||||
|
/** Whether this class put the phone into communication mode, and so must take it out. */
|
||||||
|
private boolean inCall;
|
||||||
|
/** The line being opened, which counts towards the call before it exists; null when none is. */
|
||||||
|
private String openingDevice;
|
||||||
|
private boolean openingInput;
|
||||||
|
|
||||||
|
AndroidAudioIo(Context context) {
|
||||||
|
this.audioManager = context.getSystemService(AudioManager.class);
|
||||||
|
audioManager.registerAudioDeviceCallback(new AudioDeviceCallback() {
|
||||||
|
@Override
|
||||||
|
public void onAudioDevicesAdded(AudioDeviceInfo[] added) {
|
||||||
|
returnToDevices(added);
|
||||||
|
notifyDevices();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onAudioDevicesRemoved(AudioDeviceInfo[] removed) {
|
||||||
|
notifyDevices();
|
||||||
|
}
|
||||||
|
}, new Handler(Looper.getMainLooper()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- devices ----
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<AudioDevice> inputDevices() {
|
||||||
|
return devices(AudioManager.GET_DEVICES_INPUTS, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<AudioDevice> outputDevices() {
|
||||||
|
return devices(AudioManager.GET_DEVICES_OUTPUTS, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs on the main thread whenever a device comes or goes. */
|
||||||
|
void addDeviceListener(Runnable listener) {
|
||||||
|
synchronized (deviceListeners) {
|
||||||
|
deviceListeners.add(listener);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void notifyDevices() {
|
||||||
|
synchronized (deviceListeners) {
|
||||||
|
for (Runnable listener : deviceListeners) listener.run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<AudioDevice> devices(int flags, boolean input) {
|
||||||
|
Map<String, AudioDevice> out = new LinkedHashMap<>();
|
||||||
|
out.put(AudioDevice.DEFAULT.id(), AudioDevice.DEFAULT);
|
||||||
|
for (AudioDeviceInfo info : audioManager.getDevices(flags)) {
|
||||||
|
String label = label(info, input);
|
||||||
|
if (label != null) out.putIfAbsent(key(info), new AudioDevice(key(info), label));
|
||||||
|
}
|
||||||
|
return new ArrayList<>(out.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the settings store for a device. */
|
||||||
|
private static String key(AudioDeviceInfo info) {
|
||||||
|
return info.getType() + ":" + info.getAddress();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The device's current id, or 0 to let the system choose when it is not there. */
|
||||||
|
private int resolve(String device, int flags) {
|
||||||
|
if (device == null || device.isEmpty()) return 0;
|
||||||
|
for (AudioDeviceInfo info : audioManager.getDevices(flags)) {
|
||||||
|
if (key(info).equals(device)) return info.getId();
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int flags(OboeLine line) {
|
||||||
|
return line.isInput() ? AudioManager.GET_DEVICES_INPUTS : AudioManager.GET_DEVICES_OUTPUTS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A picked device that dropped out took its lines to the default; bring them back. */
|
||||||
|
private void returnToDevices(AudioDeviceInfo[] added) {
|
||||||
|
for (AudioDeviceInfo info : added) {
|
||||||
|
for (OboeLine line : open) {
|
||||||
|
if (key(info).equals(line.device) && info.isSource() == line.isInput()) {
|
||||||
|
reopen(line, info.getId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- lines ----
|
||||||
|
|
||||||
|
AudioRoute route() {
|
||||||
|
return route;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Takes on a new route, moving the lines it concerns over at once. */
|
||||||
|
void setRoute(AudioRoute newRoute) {
|
||||||
|
AudioRoute old = route;
|
||||||
|
route = newRoute;
|
||||||
|
synchronized (this) {
|
||||||
|
updateCall();
|
||||||
|
}
|
||||||
|
for (OboeLine line : open) {
|
||||||
|
boolean affected = line.isInput()
|
||||||
|
? old.inputPreset() != newRoute.inputPreset()
|
||||||
|
: old.usage() != newRoute.usage();
|
||||||
|
if (affected) reopen(line, resolve(line.device, flags(line)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void reopen(OboeLine line, int deviceId) {
|
||||||
|
reopener.execute(() -> {
|
||||||
|
try {
|
||||||
|
line.reopen(deviceId, route);
|
||||||
|
} catch (IllegalStateException ignored) {
|
||||||
|
// The stream could not come back on those terms; the line reads silence until closed.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public AudioCapture openCapture(String deviceId, int preferredChannels) {
|
||||||
|
opening(true, deviceId);
|
||||||
|
try {
|
||||||
|
return register(OboeLine.capture(deviceId, resolve(deviceId, AudioManager.GET_DEVICES_INPUTS),
|
||||||
|
preferredChannels, route, this::closed));
|
||||||
|
} finally {
|
||||||
|
opened();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public AudioPlayback openPlayback(String deviceId, int preferredChannels) {
|
||||||
|
opening(false, deviceId);
|
||||||
|
try {
|
||||||
|
return register(OboeLine.playback(deviceId, resolve(deviceId, AudioManager.GET_DEVICES_OUTPUTS),
|
||||||
|
preferredChannels, route, this::closed));
|
||||||
|
} finally {
|
||||||
|
opened();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A stream opens on the call as it will be, so the call is set up for it first. */
|
||||||
|
private synchronized void opening(boolean input, String device) {
|
||||||
|
openingInput = input;
|
||||||
|
openingDevice = device == null ? "" : device;
|
||||||
|
updateCall();
|
||||||
|
}
|
||||||
|
|
||||||
|
private synchronized void opened() {
|
||||||
|
openingDevice = null;
|
||||||
|
updateCall();
|
||||||
|
}
|
||||||
|
|
||||||
|
private synchronized OboeLine register(OboeLine line) {
|
||||||
|
open.add(line);
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
|
||||||
|
private synchronized void closed() {
|
||||||
|
open.removeIf(line -> !line.isOpen());
|
||||||
|
updateCall();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Puts the phone into communication mode while a line needs it, pointing the call at
|
||||||
|
* the picked device, and leaves it again once none does.
|
||||||
|
*/
|
||||||
|
private void updateCall() {
|
||||||
|
AudioDeviceInfo headsetMic = null;
|
||||||
|
String output = null;
|
||||||
|
List<String> inputs = new ArrayList<>();
|
||||||
|
for (OboeLine line : open) {
|
||||||
|
if (line.isInput()) inputs.add(line.device);
|
||||||
|
else if (output == null) output = line.device;
|
||||||
|
}
|
||||||
|
if (openingDevice != null) {
|
||||||
|
if (openingInput) inputs.add(openingDevice);
|
||||||
|
else if (output == null) output = openingDevice;
|
||||||
|
}
|
||||||
|
for (String input : inputs) {
|
||||||
|
AudioDeviceInfo mic = find(input, AudioManager.GET_DEVICES_INPUTS);
|
||||||
|
if (mic != null && isHeadsetMic(mic)) headsetMic = mic;
|
||||||
|
}
|
||||||
|
// Held while anything is open, the microphone included: a notification sound coming
|
||||||
|
// and going must not take the phone in and out of a call.
|
||||||
|
boolean call = route.usage() == AudioRoute.Usage.VOICE_COMMUNICATION && (!open.isEmpty() || openingDevice != null);
|
||||||
|
boolean needed = headsetMic != null || call;
|
||||||
|
if (needed && !inCall) {
|
||||||
|
audioManager.setMode(AudioManager.MODE_IN_COMMUNICATION);
|
||||||
|
inCall = true;
|
||||||
|
} else if (!needed && inCall) {
|
||||||
|
audioManager.clearCommunicationDevice();
|
||||||
|
audioManager.setMode(AudioManager.MODE_NORMAL);
|
||||||
|
inCall = false;
|
||||||
|
}
|
||||||
|
if (!needed) return;
|
||||||
|
|
||||||
|
// A headset microphone only works over the headset's call link, so it decides.
|
||||||
|
AudioDeviceInfo target = headsetMic != null
|
||||||
|
? communicationDevice(headsetMic.getAddress(), headsetMic.getType())
|
||||||
|
: communicationDevice(find(output, AudioManager.GET_DEVICES_OUTPUTS));
|
||||||
|
if (target != null) audioManager.setCommunicationDevice(target);
|
||||||
|
else audioManager.clearCommunicationDevice();
|
||||||
|
}
|
||||||
|
|
||||||
|
private AudioDeviceInfo find(String device, int flags) {
|
||||||
|
if (device == null || device.isEmpty()) return null;
|
||||||
|
for (AudioDeviceInfo info : audioManager.getDevices(flags)) {
|
||||||
|
if (key(info).equals(device)) return info;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isHeadsetMic(AudioDeviceInfo info) {
|
||||||
|
return info.getType() == AudioDeviceInfo.TYPE_BLUETOOTH_SCO || info.getType() == AudioDeviceInfo.TYPE_BLE_HEADSET;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The communication device that is {@code output}, if calls can go there. */
|
||||||
|
private AudioDeviceInfo communicationDevice(AudioDeviceInfo output) {
|
||||||
|
if (output == null) return null;
|
||||||
|
// Calls cannot go over A2DP; the same headset's call profile can.
|
||||||
|
int type = output.getType() == AudioDeviceInfo.TYPE_BLUETOOTH_A2DP ? AudioDeviceInfo.TYPE_BLUETOOTH_SCO : output.getType();
|
||||||
|
return communicationDevice(output.getAddress(), type);
|
||||||
|
}
|
||||||
|
|
||||||
|
private AudioDeviceInfo communicationDevice(String address, int inputType) {
|
||||||
|
for (AudioDeviceInfo info : audioManager.getAvailableCommunicationDevices()) {
|
||||||
|
if (info.getType() == inputType && info.getAddress().equals(address)) return info;
|
||||||
|
}
|
||||||
|
// Wired and USB headsets, and the built-in microphone, have no address in common with an output.
|
||||||
|
for (AudioDeviceInfo info : audioManager.getAvailableCommunicationDevices()) {
|
||||||
|
if (info.getType() == inputType) return info;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean unprocessedSupported() {
|
||||||
|
return "true".equals(audioManager.getProperty(AudioManager.PROPERTY_SUPPORT_AUDIO_SOURCE_UNPROCESSED));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How a device is offered in the settings, or null for one no user would pick. */
|
||||||
|
private static String label(AudioDeviceInfo info, boolean input) {
|
||||||
|
String product = info.getProductName() == null ? "" : info.getProductName().toString();
|
||||||
|
String address = info.getAddress();
|
||||||
|
switch (info.getType()) {
|
||||||
|
case AudioDeviceInfo.TYPE_BUILTIN_MIC:
|
||||||
|
return "Phone microphone" + (address.isEmpty() ? "" : " (" + address + ")");
|
||||||
|
case AudioDeviceInfo.TYPE_BUILTIN_SPEAKER:
|
||||||
|
return "Phone speaker";
|
||||||
|
case AudioDeviceInfo.TYPE_BUILTIN_EARPIECE:
|
||||||
|
return "Earpiece";
|
||||||
|
case AudioDeviceInfo.TYPE_WIRED_HEADSET:
|
||||||
|
return input ? "Wired headset microphone" : "Wired headset";
|
||||||
|
case AudioDeviceInfo.TYPE_WIRED_HEADPHONES:
|
||||||
|
return "Wired headphones";
|
||||||
|
case AudioDeviceInfo.TYPE_USB_HEADSET:
|
||||||
|
case AudioDeviceInfo.TYPE_USB_DEVICE:
|
||||||
|
case AudioDeviceInfo.TYPE_USB_ACCESSORY:
|
||||||
|
return product + " (USB)";
|
||||||
|
case AudioDeviceInfo.TYPE_BLUETOOTH_A2DP:
|
||||||
|
return product + " (Bluetooth, media quality)";
|
||||||
|
case AudioDeviceInfo.TYPE_BLUETOOTH_SCO:
|
||||||
|
return product + " (Bluetooth, call quality)";
|
||||||
|
case AudioDeviceInfo.TYPE_BLE_HEADSET:
|
||||||
|
case AudioDeviceInfo.TYPE_BLE_SPEAKER:
|
||||||
|
return product + " (Bluetooth LE)";
|
||||||
|
case AudioDeviceInfo.TYPE_HEARING_AID:
|
||||||
|
return product + " (hearing aid)";
|
||||||
|
case AudioDeviceInfo.TYPE_HDMI:
|
||||||
|
case AudioDeviceInfo.TYPE_HDMI_ARC:
|
||||||
|
case AudioDeviceInfo.TYPE_HDMI_EARC:
|
||||||
|
return "HDMI";
|
||||||
|
case AudioDeviceInfo.TYPE_TELEPHONY:
|
||||||
|
case AudioDeviceInfo.TYPE_REMOTE_SUBMIX:
|
||||||
|
case AudioDeviceInfo.TYPE_BUILTIN_SPEAKER_SAFE:
|
||||||
|
case TYPE_ECHO_REFERENCE:
|
||||||
|
case AudioDeviceInfo.TYPE_FM_TUNER:
|
||||||
|
case AudioDeviceInfo.TYPE_TV_TUNER:
|
||||||
|
case AudioDeviceInfo.TYPE_BUS:
|
||||||
|
case AudioDeviceInfo.TYPE_BLE_BROADCAST:
|
||||||
|
return null;
|
||||||
|
default:
|
||||||
|
return product.isEmpty() ? "Device " + info.getId() : product;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package com.ts3client.android.audio;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How the app's audio meets Android's: which processing the platform applies to the
|
||||||
|
* microphone, and whether playback is media or a call.
|
||||||
|
*/
|
||||||
|
public record AudioRoute(InputPreset inputPreset, Usage usage) {
|
||||||
|
|
||||||
|
/** The client runs TS3's own processing, so by default the platform adds as little as it can. */
|
||||||
|
public static final AudioRoute DEFAULT = new AudioRoute(InputPreset.VOICE_RECOGNITION, Usage.MEDIA);
|
||||||
|
|
||||||
|
/** Oboe's input presets, which are Android's audio sources. */
|
||||||
|
public enum InputPreset {
|
||||||
|
/** Tuned for speech with light or no processing; what the client expects. */
|
||||||
|
VOICE_RECOGNITION(6),
|
||||||
|
/** The platform's call processing: echo cancellation, noise suppression, gain control. */
|
||||||
|
VOICE_COMMUNICATION(7),
|
||||||
|
/** No processing at all, where the device supports it. */
|
||||||
|
UNPROCESSED(9),
|
||||||
|
/** Low latency and no processing, meant for live performance. */
|
||||||
|
VOICE_PERFORMANCE(10),
|
||||||
|
/** The plain microphone source, with whatever the vendor applies to it. */
|
||||||
|
GENERIC(1),
|
||||||
|
CAMCORDER(5);
|
||||||
|
|
||||||
|
final int oboeValue;
|
||||||
|
|
||||||
|
InputPreset(int oboeValue) {
|
||||||
|
this.oboeValue = oboeValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Oboe's playback usages. */
|
||||||
|
public enum Usage {
|
||||||
|
/** Plays like music: stays on A2DP headphones and leaves the phone out of call mode. */
|
||||||
|
MEDIA(1),
|
||||||
|
/**
|
||||||
|
* Plays as a call: Android goes into communication mode, which is what its echo
|
||||||
|
* canceller works against, and Bluetooth headsets switch to their headset profile.
|
||||||
|
*/
|
||||||
|
VOICE_COMMUNICATION(2);
|
||||||
|
|
||||||
|
final int oboeValue;
|
||||||
|
|
||||||
|
Usage(int oboeValue) {
|
||||||
|
this.oboeValue = oboeValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
package com.ts3client.android.audio;
|
||||||
|
|
||||||
|
import com.ts3client.audio.opus.CtlOpusEncoder;
|
||||||
|
import com.ts3client.audio.opus.LibOpus;
|
||||||
|
import com.ts3client.audio.opus.OpusCodec;
|
||||||
|
import com.ts3client.audio.opus.OpusDecoder;
|
||||||
|
import com.ts3client.audio.opus.OpusEncoder;
|
||||||
|
|
||||||
|
/** libopus, built into the app with the NDK. */
|
||||||
|
public final class NativeOpus implements OpusCodec {
|
||||||
|
|
||||||
|
static {
|
||||||
|
System.loadLibrary("ts3audio");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public OpusEncoder createEncoder(int sampleRate, int frameSize, int channels, Application application) {
|
||||||
|
return new Encoder(encoderCreate(sampleRate, channels, LibOpus.application(application)), frameSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public OpusDecoder createDecoder(int sampleRate, int maxFrameSize, int channels) {
|
||||||
|
return new Decoder(decoderCreate(sampleRate, channels), maxFrameSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String version() {
|
||||||
|
return version0();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Methods may come from different threads, one at a time, so they only share the handle. */
|
||||||
|
private static final class Encoder extends CtlOpusEncoder {
|
||||||
|
private final int frameSize;
|
||||||
|
private long handle;
|
||||||
|
|
||||||
|
Encoder(long handle, int frameSize) {
|
||||||
|
this.handle = handle;
|
||||||
|
this.frameSize = frameSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected synchronized void ctl(int request, int value) {
|
||||||
|
if (handle != 0) encoderCtl(handle, request, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public synchronized byte[] encode(float[] pcm) {
|
||||||
|
if (handle == 0) throw new IllegalStateException("encoder closed");
|
||||||
|
return NativeOpus.encode(handle, pcm, frameSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public synchronized void close() {
|
||||||
|
if (handle == 0) return;
|
||||||
|
encoderDestroy(handle);
|
||||||
|
handle = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class Decoder implements OpusDecoder {
|
||||||
|
private final int maxFrameSize;
|
||||||
|
private long handle;
|
||||||
|
|
||||||
|
Decoder(long handle, int maxFrameSize) {
|
||||||
|
this.handle = handle;
|
||||||
|
this.maxFrameSize = maxFrameSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int decode(byte[] packet, float[] out) {
|
||||||
|
return NativeOpus.decode(live(), packet, out, maxFrameSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int conceal(float[] out, int frameSize) {
|
||||||
|
return NativeOpus.decode(live(), null, out, Math.min(frameSize, maxFrameSize));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void reset() {
|
||||||
|
if (handle != 0) decoderReset(handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() {
|
||||||
|
if (handle == 0) return;
|
||||||
|
decoderDestroy(handle);
|
||||||
|
handle = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private long live() {
|
||||||
|
if (handle == 0) throw new IllegalStateException("decoder closed");
|
||||||
|
return handle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static native long encoderCreate(int sampleRate, int channels, int application);
|
||||||
|
|
||||||
|
private static native void encoderCtl(long handle, int request, int value);
|
||||||
|
|
||||||
|
private static native byte[] encode(long handle, float[] pcm, int frameSize);
|
||||||
|
|
||||||
|
private static native void encoderDestroy(long handle);
|
||||||
|
|
||||||
|
private static native long decoderCreate(int sampleRate, int channels);
|
||||||
|
|
||||||
|
private static native int decode(long handle, byte[] packet, float[] out, int frameSize);
|
||||||
|
|
||||||
|
private static native void decoderReset(long handle);
|
||||||
|
|
||||||
|
private static native void decoderDestroy(long handle);
|
||||||
|
|
||||||
|
private static native String version0();
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package com.ts3client.android.audio;
|
||||||
|
|
||||||
|
import com.ts3client.audio.AudioCapture;
|
||||||
|
import com.ts3client.audio.AudioPlayback;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An Oboe stream, as either an {@link AudioCapture} or an {@link AudioPlayback}. The native
|
||||||
|
* side keeps a ring between the real-time callback and these blocking calls.
|
||||||
|
*/
|
||||||
|
final class OboeLine implements AudioCapture, AudioPlayback {
|
||||||
|
|
||||||
|
static {
|
||||||
|
System.loadLibrary("ts3audio");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Held across native reads and writes, so {@link #close} never frees a line in use. */
|
||||||
|
private final Object io = new Object();
|
||||||
|
private final int channels;
|
||||||
|
private final boolean input;
|
||||||
|
/** The device as the settings name it, to find it again when the line is reopened. */
|
||||||
|
final String device;
|
||||||
|
private final Runnable onClose;
|
||||||
|
private volatile long handle;
|
||||||
|
|
||||||
|
private OboeLine(boolean input, String device, int deviceId, int channels, AudioRoute route, Runnable onClose) {
|
||||||
|
this.input = input;
|
||||||
|
this.device = device;
|
||||||
|
this.onClose = onClose;
|
||||||
|
this.handle = nativeOpen(input, deviceId, channels, route.inputPreset().oboeValue, route.usage().oboeValue);
|
||||||
|
this.channels = nativeChannels(handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
static OboeLine capture(String device, int deviceId, int channels, AudioRoute route, Runnable onClose) {
|
||||||
|
return new OboeLine(true, device, deviceId, channels, route, onClose);
|
||||||
|
}
|
||||||
|
|
||||||
|
static OboeLine playback(String device, int deviceId, int channels, AudioRoute route, Runnable onClose) {
|
||||||
|
return new OboeLine(false, device, deviceId, channels, route, onClose);
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean isInput() {
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean isOpen() {
|
||||||
|
return handle != 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Moves the open stream onto {@code deviceId} with {@code route}; readers and writers carry on. */
|
||||||
|
void reopen(int deviceId, AudioRoute route) {
|
||||||
|
long h = handle;
|
||||||
|
if (h != 0) nativeReopen(h, deviceId, route.inputPreset().oboeValue, route.usage().oboeValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int channels() {
|
||||||
|
return channels;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void start() {
|
||||||
|
long h = handle;
|
||||||
|
if (h != 0) nativeStart(h);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int read(byte[] buffer, int offset, int length) {
|
||||||
|
synchronized (io) {
|
||||||
|
long h = handle;
|
||||||
|
return h == 0 ? 0 : nativeRead(h, buffer, offset, length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void write(byte[] buffer, int offset, int length) {
|
||||||
|
synchronized (io) {
|
||||||
|
long h = handle;
|
||||||
|
if (h != 0) nativeWrite(h, buffer, offset, length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Oboe has no drain; what is queued is at most a few frames and simply plays out. */
|
||||||
|
@Override
|
||||||
|
public void drain() {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() {
|
||||||
|
long h = handle;
|
||||||
|
if (h == 0) return;
|
||||||
|
nativeClose(h); // wakes a blocked read or write
|
||||||
|
synchronized (io) {
|
||||||
|
if (handle == 0) return;
|
||||||
|
handle = 0;
|
||||||
|
nativeDelete(h);
|
||||||
|
}
|
||||||
|
onClose.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static native long nativeOpen(boolean input, int deviceId, int channels, int preset, int usage);
|
||||||
|
|
||||||
|
private static native void nativeReopen(long handle, int deviceId, int preset, int usage);
|
||||||
|
|
||||||
|
private static native int nativeChannels(long handle);
|
||||||
|
|
||||||
|
private static native void nativeStart(long handle);
|
||||||
|
|
||||||
|
private static native int nativeRead(long handle, byte[] buffer, int offset, int length);
|
||||||
|
|
||||||
|
private static native void nativeWrite(long handle, byte[] buffer, int offset, int length);
|
||||||
|
|
||||||
|
private static native void nativeClose(long handle);
|
||||||
|
|
||||||
|
private static native void nativeDelete(long handle);
|
||||||
|
}
|
||||||
5
android/build.gradle.kts
Normal file
5
android/build.gradle.kts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.application) apply false
|
||||||
|
alias(libs.plugins.android.library) apply false
|
||||||
|
alias(libs.plugins.kotlin.compose) apply false
|
||||||
|
}
|
||||||
31
android/core/build.gradle.kts
Normal file
31
android/core/build.gradle.kts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
plugins {
|
||||||
|
`java-library`
|
||||||
|
}
|
||||||
|
|
||||||
|
val ts3ClientDir: String = providers.gradleProperty("ts3.client.dir")
|
||||||
|
.orElse(providers.environmentVariable("TS3_CLIENT_DIR"))
|
||||||
|
.getOrElse(rootDir.resolve("../TeamSpeak3-Client-linux_amd64").path)
|
||||||
|
|
||||||
|
/** TeamSpeak's own icon packs and country flags, proprietary and so taken from an install, as the desktop's build does. */
|
||||||
|
val teamspeakIconPacks = tasks.register<Sync>("teamspeakIconPacks") {
|
||||||
|
into(layout.buildDirectory.dir("generated/iconPacks"))
|
||||||
|
from(File(ts3ClientDir, "gfx")) {
|
||||||
|
include("default.zip", "default_colored_2014.zip", "default_mono_2014.zip", "countries.zip")
|
||||||
|
into("gfx/teamspeak")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
java {
|
||||||
|
sourceSets["main"].java.setSrcDirs(listOf(rootDir.resolve("../ts3-client/core/src/main/java")))
|
||||||
|
sourceSets["main"].resources.setSrcDirs(listOf(rootDir.resolve("../ts3-client/core/src/main/resources")))
|
||||||
|
sourceSets["main"].resources.srcDir(teamspeakIconPacks)
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.withType<JavaCompile>().configureEach {
|
||||||
|
options.release = 17
|
||||||
|
options.encoding = "UTF-8"
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
api(project(":ts3j"))
|
||||||
|
}
|
||||||
20
android/desktop/build.gradle.kts
Normal file
20
android/desktop/build.gradle.kts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
plugins {
|
||||||
|
`java-library`
|
||||||
|
}
|
||||||
|
|
||||||
|
// The desktop client's platform code, run on the build machine only: its libopus
|
||||||
|
// encodes the sound packs the app ships (see :app's bundleSoundPacks).
|
||||||
|
java {
|
||||||
|
sourceSets["main"].java.setSrcDirs(listOf(rootDir.resolve("../ts3-client/desktop/src/main/java")))
|
||||||
|
sourceSets["main"].resources.setSrcDirs(listOf(rootDir.resolve("../ts3-client/desktop/src/main/resources")))
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.withType<JavaCompile>().configureEach {
|
||||||
|
// The FFM API it reaches libopus through; the build has to run on JDK 25 or newer.
|
||||||
|
options.release = 25
|
||||||
|
options.encoding = "UTF-8"
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(project(":core"))
|
||||||
|
}
|
||||||
4
android/gradle.properties
Normal file
4
android/gradle.properties
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8
|
||||||
|
org.gradle.parallel=true
|
||||||
|
org.gradle.configuration-cache=true
|
||||||
|
android.useAndroidX=true
|
||||||
28
android/gradle/libs.versions.toml
Normal file
28
android/gradle/libs.versions.toml
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
[versions]
|
||||||
|
agp = "9.4.1"
|
||||||
|
kotlin = "2.4.20"
|
||||||
|
composeBom = "2026.09.00"
|
||||||
|
activityCompose = "1.13.0"
|
||||||
|
lifecycle = "2.11.0"
|
||||||
|
bouncycastle = "1.86"
|
||||||
|
ini4j = "0.5.1"
|
||||||
|
oboe = "1.11.0"
|
||||||
|
androidsvg = "1.4"
|
||||||
|
|
||||||
|
[libraries]
|
||||||
|
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" }
|
||||||
|
androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" }
|
||||||
|
androidx-compose-material3 = { module = "androidx.compose.material3:material3" }
|
||||||
|
androidx-compose-material-icons = { module = "androidx.compose.material:material-icons-extended" }
|
||||||
|
androidx-compose-ui = { module = "androidx.compose.ui:ui" }
|
||||||
|
androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" }
|
||||||
|
androidx-lifecycle-service = { module = "androidx.lifecycle:lifecycle-service", version.ref = "lifecycle" }
|
||||||
|
androidsvg = { module = "com.caverock:androidsvg-aar", version.ref = "androidsvg" }
|
||||||
|
bouncycastle = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "bouncycastle" }
|
||||||
|
ini4j = { module = "org.ini4j:ini4j", version.ref = "ini4j" }
|
||||||
|
oboe = { module = "com.google.oboe:oboe", version.ref = "oboe" }
|
||||||
|
|
||||||
|
[plugins]
|
||||||
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
android-library = { id = "com.android.library", version.ref = "agp" }
|
||||||
|
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||||
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
9
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
9
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.0-bin.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
retries=0
|
||||||
|
retryBackOffMs=500
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
248
android/gradlew
vendored
Executable file
248
android/gradlew
vendored
Executable file
@@ -0,0 +1,248 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright © 2015 the original authors.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
#
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
#
|
||||||
|
# gradlew start up script for POSIX generated by Gradle.
|
||||||
|
#
|
||||||
|
# Important for running:
|
||||||
|
#
|
||||||
|
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||||
|
# noncompliant, but you have some other compliant shell such as ksh or
|
||||||
|
# bash, then to run this script, type that shell name before the whole
|
||||||
|
# command line, like:
|
||||||
|
#
|
||||||
|
# ksh gradlew
|
||||||
|
#
|
||||||
|
# Busybox and similar reduced shells will NOT work, because this script
|
||||||
|
# requires all of these POSIX shell features:
|
||||||
|
# * functions;
|
||||||
|
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||||
|
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||||
|
# * compound commands having a testable exit status, especially «case»;
|
||||||
|
# * various built-in commands including «command», «set», and «ulimit».
|
||||||
|
#
|
||||||
|
# Important for patching:
|
||||||
|
#
|
||||||
|
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||||
|
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||||
|
#
|
||||||
|
# The "traditional" practice of packing multiple parameters into a
|
||||||
|
# space-separated string is a well documented source of bugs and security
|
||||||
|
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||||
|
# options in "$@", and eventually passing that to Java.
|
||||||
|
#
|
||||||
|
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||||
|
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||||
|
# see the in-line comments for details.
|
||||||
|
#
|
||||||
|
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||||
|
# Darwin, MinGW, and NonStop.
|
||||||
|
#
|
||||||
|
# (3) This script is generated from the Groovy template
|
||||||
|
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||||
|
# within the Gradle project.
|
||||||
|
#
|
||||||
|
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||||
|
#
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
app_path=$0
|
||||||
|
|
||||||
|
# Need this for daisy-chained symlinks.
|
||||||
|
while
|
||||||
|
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||||
|
[ -h "$app_path" ]
|
||||||
|
do
|
||||||
|
ls=$( ls -ld "$app_path" )
|
||||||
|
link=${ls#*' -> '}
|
||||||
|
case $link in #(
|
||||||
|
/*) app_path=$link ;; #(
|
||||||
|
*) app_path=$APP_HOME$link ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# This is normally unused
|
||||||
|
# shellcheck disable=SC2034
|
||||||
|
APP_BASE_NAME=${0##*/}
|
||||||
|
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||||
|
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD=maximum
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "$( uname )" in #(
|
||||||
|
CYGWIN* ) cygwin=true ;; #(
|
||||||
|
Darwin* ) darwin=true ;; #(
|
||||||
|
MSYS* | MINGW* ) msys=true ;; #(
|
||||||
|
NONSTOP* ) nonstop=true ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||||
|
else
|
||||||
|
JAVACMD=$JAVA_HOME/bin/java
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD=java
|
||||||
|
if ! command -v java >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||||
|
case $MAX_FD in #(
|
||||||
|
max*)
|
||||||
|
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
MAX_FD=$( ulimit -H -n ) ||
|
||||||
|
warn "Could not query maximum file descriptor limit"
|
||||||
|
esac
|
||||||
|
case $MAX_FD in #(
|
||||||
|
'' | soft) :;; #(
|
||||||
|
*)
|
||||||
|
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
ulimit -n "$MAX_FD" ||
|
||||||
|
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, stacking in reverse order:
|
||||||
|
# * args from the command line
|
||||||
|
# * the main class name
|
||||||
|
# * -classpath
|
||||||
|
# * -D...appname settings
|
||||||
|
# * --module-path (only if needed)
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||||
|
|
||||||
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
|
if "$cygwin" || "$msys" ; then
|
||||||
|
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||||
|
|
||||||
|
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||||
|
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
for arg do
|
||||||
|
if
|
||||||
|
case $arg in #(
|
||||||
|
-*) false ;; # don't mess with options #(
|
||||||
|
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||||
|
[ -e "$t" ] ;; #(
|
||||||
|
*) false ;;
|
||||||
|
esac
|
||||||
|
then
|
||||||
|
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||||
|
fi
|
||||||
|
# Roll the args list around exactly as many times as the number of
|
||||||
|
# args, so each arg winds up back in the position where it started, but
|
||||||
|
# possibly modified.
|
||||||
|
#
|
||||||
|
# NB: a `for` loop captures its iteration list before it begins, so
|
||||||
|
# changing the positional parameters here affects neither the number of
|
||||||
|
# iterations, nor the values presented in `arg`.
|
||||||
|
shift # remove old arg
|
||||||
|
set -- "$@" "$arg" # push replacement arg
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
|
# Collect all arguments for the java command:
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||||
|
# and any embedded shellness will be escaped.
|
||||||
|
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||||
|
# treated as '${Hostname}' itself on the command line.
|
||||||
|
|
||||||
|
set -- \
|
||||||
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
|
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
# Stop when "xargs" is not available.
|
||||||
|
if ! command -v xargs >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "xargs is not available"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use "xargs" to parse quoted args.
|
||||||
|
#
|
||||||
|
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||||
|
#
|
||||||
|
# In Bash we could simply go:
|
||||||
|
#
|
||||||
|
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||||
|
# set -- "${ARGS[@]}" "$@"
|
||||||
|
#
|
||||||
|
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||||
|
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||||
|
# character that might be a shell metacharacter, then use eval to reverse
|
||||||
|
# that process (while maintaining the separation between arguments), and wrap
|
||||||
|
# the whole thing up as a single "set" statement.
|
||||||
|
#
|
||||||
|
# This will of course break if any of these variables contains a newline or
|
||||||
|
# an unmatched quote.
|
||||||
|
#
|
||||||
|
|
||||||
|
eval "set -- $(
|
||||||
|
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||||
|
xargs -n1 |
|
||||||
|
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||||
|
tr '\n' ' '
|
||||||
|
)" '"$@"'
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
112
android/gradlew.bat
vendored
Normal file
112
android/gradlew.bat
vendored
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
@rem
|
||||||
|
@rem Copyright 2015 the original author or authors.
|
||||||
|
@rem
|
||||||
|
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
@rem you may not use this file except in compliance with the License.
|
||||||
|
@rem You may obtain a copy of the License at
|
||||||
|
@rem
|
||||||
|
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@rem
|
||||||
|
@rem Unless required by applicable law or agreed to in writing, software
|
||||||
|
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
@rem See the License for the specific language governing permissions and
|
||||||
|
@rem limitations under the License.
|
||||||
|
@rem
|
||||||
|
@rem SPDX-License-Identifier: Apache-2.0
|
||||||
|
@rem
|
||||||
|
|
||||||
|
@if "%DEBUG%"=="" @echo off
|
||||||
|
@rem ##########################################################################
|
||||||
|
@rem
|
||||||
|
@rem gradlew startup script for Windows
|
||||||
|
@rem
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables, and ensure extensions are enabled
|
||||||
|
setlocal EnableExtensions
|
||||||
|
|
||||||
|
@rem Catch executions from older scripts and ensure they exit cleanly.
|
||||||
|
@rem This can be removed once we can be reasonably confident that few people
|
||||||
|
@rem will be migrating directly to this new wrapper.
|
||||||
|
goto afterSafetyNet
|
||||||
|
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||||
|
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||||
|
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||||
|
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||||
|
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||||
|
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||||
|
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||||
|
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||||
|
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||||
|
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||||
|
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||||
|
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||||
|
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||||
|
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||||
|
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||||
|
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||||
|
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||||
|
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||||
|
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||||
|
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||||
|
goto exitWithErrorLevel
|
||||||
|
:afterSafetyNet
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
|
@rem This is normally unused
|
||||||
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
|
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||||
|
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||||
|
|
||||||
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||||
|
|
||||||
|
@rem Find java.exe
|
||||||
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
|
||||||
|
1>&2 echo.
|
||||||
|
1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
1>&2 echo.
|
||||||
|
1>&2 echo Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
1>&2 echo location of your Java installation.
|
||||||
|
|
||||||
|
"%COMSPEC%" /c exit 1
|
||||||
|
goto exitWithErrorLevel
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
|
1>&2 echo.
|
||||||
|
1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||||
|
1>&2 echo.
|
||||||
|
1>&2 echo Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
1>&2 echo location of your Java installation.
|
||||||
|
|
||||||
|
"%COMSPEC%" /c exit 1
|
||||||
|
goto exitWithErrorLevel
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@rem Execute gradlew
|
||||||
|
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
|
||||||
|
@rem which allows us to clear the local environment before executing the java command
|
||||||
|
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel & goto exitWithErrorLevel
|
||||||
|
|
||||||
|
@rem This label must not be changed. We rely on old scripts being able to jump to this point.
|
||||||
|
:exitWithErrorLevel
|
||||||
|
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
|
||||||
|
"%COMSPEC%" /c exit %ERRORLEVEL%
|
||||||
20
android/settings.gradle.kts
Normal file
20
android/settings.gradle.kts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.name = "ts3-android"
|
||||||
|
|
||||||
|
// core, ts3j and desktop compile the very sources the desktop's Maven build does.
|
||||||
|
include(":ts3j", ":core", ":desktop", ":audio", ":app")
|
||||||
18
android/ts3j/build.gradle.kts
Normal file
18
android/ts3j/build.gradle.kts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
plugins {
|
||||||
|
`java-library`
|
||||||
|
}
|
||||||
|
|
||||||
|
java {
|
||||||
|
sourceSets["main"].java.setSrcDirs(listOf(rootDir.resolve("../ts3j/src/main/java")))
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.withType<JavaCompile>().configureEach {
|
||||||
|
options.release = 17
|
||||||
|
options.encoding = "UTF-8"
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
// Android ships a BouncyCastle of its own, but under a different package, so they never meet.
|
||||||
|
api(libs.bouncycastle)
|
||||||
|
api(libs.ini4j)
|
||||||
|
}
|
||||||
79
ts3-client/AUDIT.md
Normal file
79
ts3-client/AUDIT.md
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
# Codebase audit — ts3-client
|
||||||
|
|
||||||
|
First audit: 2026-09-11. Revisited and worked through: 2026-09-25. Every finding below was
|
||||||
|
either fixed (commit given) or closed with a reason. Logging (§3) was left out on purpose.
|
||||||
|
|
||||||
|
State after the fixes: `mvn -o test` passes (177 tests: 25 in ts3j, 135 core, 11 desktop,
|
||||||
|
6 swing), on Java 25 and 26. The Android release build (R8 over core and ts3j) passes. The
|
||||||
|
Swing client was run against the test server on Java 25: it connected, loaded the tree and
|
||||||
|
chat logs, and wrote its profile owner-only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Fixed
|
||||||
|
|
||||||
|
### Tests and build
|
||||||
|
| Finding | Commit |
|
||||||
|
|---|---|
|
||||||
|
| The importer test failed on every run after the first. The cause was not the real profile: the POM already points `user.home` at `target/test-home`, but that folder outlives a run, so the first run's identity was still there | `ba5de5a` |
|
||||||
|
| The ts3j DNS test resolved `voice.teamspeak.com` for real and failed when DNS did not answer | ts3j `Test SRV lookup…`, `acb3efa` |
|
||||||
|
| README architecture and features out of date | `51f19c2` |
|
||||||
|
| Java 26 required for no reason; it now needs 25 (LTS), and builds, tests and runs on it | `85c3ae6` |
|
||||||
|
| Missing tests: channel tree, event handler, settings round-trip and migration | `71ac2c8` |
|
||||||
|
|
||||||
|
### Connection lifecycle and threading
|
||||||
|
| Finding | Commit |
|
||||||
|
|---|---|
|
||||||
|
| An action racing a disconnect showed `NullPointerException` as the error; 20 raw threads. Actions now capture the socket and run on a pool that lives as long as the connection | `f350b19` |
|
||||||
|
| `connect()` on a live instance leaked socket and audio; fields shared across threads were not volatile | `d28bb57` |
|
||||||
|
| Permission errors were recognised by substring; now by the server's error id | `d074ffa` |
|
||||||
|
| `HotkeyEngine` locked on another class's list; `Hotkeys.all()` now returns a snapshot | `5b1809c` |
|
||||||
|
| Two quick tab switches could interleave the microphone hand-over | `665316d` |
|
||||||
|
| `MainFrame` was also the app controller: microphone ownership, push-to-talk, away and nickname fan-out moved to `core/session/Sessions` | `bf07f0b` |
|
||||||
|
|
||||||
|
### Model and events
|
||||||
|
| Finding | Commit |
|
||||||
|
|---|---|
|
||||||
|
| `buildTree()` refilled lists shared with the UI; node fields not volatile. Lists are now published whole and unmodifiable. Whether a channel is full is counted from the model | `67f8f46` |
|
||||||
|
| `selfPermissionValue` scanned every permission; `primaryServerGroupName` unused | `8c735a0` |
|
||||||
|
| A cleared avatar, description or away message was ignored | `7cb4f47` |
|
||||||
|
|
||||||
|
### Voice
|
||||||
|
| Finding | Commit |
|
||||||
|
|---|---|
|
||||||
|
| Fixed 60 ms jitter delay instead of TS3's adaptive buffer. The stashed libspeex port is now in `VoiceStream`, driven as TS3 drives it. A simulated 0–60 ms jitter stops concealing after about 3 s | `0083dc8` |
|
||||||
|
| A playback line kept open for every client that ever spoke | `6234ec6` |
|
||||||
|
| A dead playback line silently muted its speaker for the whole session | `12ca585` |
|
||||||
|
| `CaptureVoiceInput.stop()` tore down while the capture loop was still running | `8e9c461` |
|
||||||
|
|
||||||
|
### Chat logs
|
||||||
|
| Finding | Commit |
|
||||||
|
|---|---|
|
||||||
|
| Multi-line messages lost their continuation lines in history | `f9b2f4d` |
|
||||||
|
| History read whole, ever-growing logs; now the last 4 MiB | `a5c4ba1` |
|
||||||
|
| `appendPrivateMessage` duplicated `appendMessage` | `1a3a3e3` |
|
||||||
|
| Logs could not be turned off or moved (new Options → Chat page) | `d7261c9` |
|
||||||
|
| Chat logs only worked with admin rights: the server id came from `serverinfo`, which guests may not use. It turned out to be base64(SHA-1(the server's handshake key)), as the official client derives it. Found while checking the "`serverUniqueId` fallbacks can go" finding | ts3j `Derive the server's unique id…`, `eeccb49` |
|
||||||
|
|
||||||
|
### Persistence, file transfers, UI
|
||||||
|
| Finding | Commit |
|
||||||
|
|---|---|
|
||||||
|
| Profile files (private keys, server passwords) readable by everyone, and truncated by a crash mid-write. Now written privately and atomically, and the profile folder is owner-only | `3cdade9` |
|
||||||
|
| Three copies of the enum parser in `Settings` | `48a0032` |
|
||||||
|
| A stalled file server hung a download forever | `ae8d1f7` |
|
||||||
|
| Collapsed channels reopened on every tree rebuild | `f30cecf` |
|
||||||
|
|
||||||
|
## 2. Closed without a change, or still open
|
||||||
|
|
||||||
|
- **Chat-log folders "may contain `/`"**: they cannot. The value encoded is itself Base64 text,
|
||||||
|
and no 6-bit group of such bytes reaches 62 or 63, the codes for `+` and `/`.
|
||||||
|
- **`rootMessage()` hides the command**: every caller names the action in front of it.
|
||||||
|
- **Not tested:** `TeamspeakConnection`'s lifecycle against a fake socket (the smoke test
|
||||||
|
above covers connect only); the idle release and dead-line replacement in
|
||||||
|
`PerSpeakerPlayout`; the jitter buffer with real voice from another client.
|
||||||
|
- **`git stash@{0}`** ("jitter buffer test") is now fully ported, except FEC decoding (TS3
|
||||||
|
does not use FEC either). It can be dropped.
|
||||||
|
|
||||||
|
## 3. Left out on purpose
|
||||||
|
|
||||||
|
- **Logging.** There are still 60 swallowed exceptions and no log output.
|
||||||
@@ -10,13 +10,14 @@ TeaVM/CheerpJ) without touching the library:
|
|||||||
|
|
||||||
| Module | Artifact | Responsibility |
|
| Module | Artifact | Responsibility |
|
||||||
|--------|----------|----------------|
|
|--------|----------|----------------|
|
||||||
| `core` | `ts3-client-core` | Frontend-agnostic library: protocol integration, server model, connection orchestration, audio **abstractions**. No UI, no platform audio, no native code. |
|
| `core` | `ts3-client-core` | Frontend-agnostic library: protocol integration, server model, connection orchestration, the voice pipelines and their DSP. No UI, no platform audio API, no native code. |
|
||||||
| `desktop` | `ts3-client-desktop` | Desktop audio backend: Java Sound capture/playback + native Opus via the FFM API (project Panama), implementing the core audio interfaces. |
|
| `desktop` | `ts3-client-desktop` | Desktop audio backend: PipeWire (or Java Sound) capture/playback + native Opus via the FFM API (project Panama), plus the global hotkey hooks. |
|
||||||
| `swing` | `ts3-client-swing` | Swing desktop UI + entry point. Depends on `core` and `desktop`. |
|
| `swing` | `ts3-client-swing` | Swing desktop UI + entry point. Depends on `core` and `desktop`. |
|
||||||
|
|
||||||
The core exposes `AudioBackend` / `VoiceInput` / `VoiceOutput`; the frontend injects
|
The core exposes `AudioBackend` / `VoiceInput` / `VoiceOutput`; the frontend injects
|
||||||
a concrete backend (`JavaSoundAudioBackend`) into `TeamspeakConnection`. A different
|
a concrete backend (`DesktopAudioBackend`) into `TeamspeakConnection`. A different
|
||||||
frontend supplies its own UI and audio backend while reusing `core` unchanged.
|
frontend supplies its own UI and audio backend while reusing `core` unchanged, as the
|
||||||
|
Android app in [`../android`](../android) does.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
@@ -28,26 +29,26 @@ frontend supplies its own UI and audio backend while reusing `core` unchanged.
|
|||||||
elsewhere install libopus from your package manager. Encoding at 48 kHz, 20 ms frames.
|
elsewhere install libopus from your package manager. Encoding at 48 kHz, 20 ms frames.
|
||||||
- **Voice Activation Detection (VAD)** with the same three modes as the TS3 client:
|
- **Voice Activation Detection (VAD)** with the same three modes as the TS3 client:
|
||||||
- **Volume Gate** — RMS/dBFS threshold with a live input meter and hangover.
|
- **Volume Gate** — RMS/dBFS threshold with a live input meter and hangover.
|
||||||
- **Automatic** — a dependency-free speech detector (short-term energy + spectral
|
- **Automatic** — a Java port of WebRTC's RNN speech detector (`rnn_vad`), the one the
|
||||||
flatness + dominant frequency against an adaptive noise floor).
|
TS3 client uses, with the same weights.
|
||||||
- **Hybrid** — transmit only when loud enough **and** detected as speech.
|
- **Hybrid** — transmit only when loud enough **and** detected as speech.
|
||||||
- Optional **VAD over Push-To-Talk** (keep detecting voice while PTT is available).
|
- Optional **VAD over Push-To-Talk** (keep detecting voice while PTT is available).
|
||||||
- **Capture pre-processing** — a mini audio-processing chain in the same order as the
|
- **Capture pre-processing** — a Java port of the WebRTC audio-processing stages the TS3
|
||||||
TS3 client's WebRTC APM, applied before activation and encoding (and feeding the VAD):
|
client enables, in its order: high-pass filter → **noise suppression** (WebRTC NS, TS3's
|
||||||
input gain → **high-pass filter** (80 Hz, always-on rumble/DC removal) → **noise
|
four levels) → **typing attenuation** (the transient suppressor, told about key presses)
|
||||||
suppression** → **typing attenuation** → **AGC**:
|
→ **automatic gain control** (AGC2, WebRTC's successor to the AGC1 TS3 uses). Checked
|
||||||
- **Remove background noise** — spectral denoiser (decision-directed Wiener with
|
against WebRTC built from source.
|
||||||
minimum-statistics noise tracking), with an adjustable removal level.
|
- **Boost quiet speech** (0–20 dB) raises AGC2's cap on the amplified noise floor, which
|
||||||
- **Typing attenuation** — detects impulsive keystroke transients (short, broadband,
|
otherwise holds back a distant voice that is barely above the background noise.
|
||||||
high-frequency bursts) and ducks them while leaving sustained speech intact.
|
|
||||||
- **Automatic gain control (AGC)** — normalises mic loudness to a target level
|
|
||||||
(fast attack / slow release, noise-gated so silence is never amplified).
|
|
||||||
- Echo cancellation (WebRTC AEC3) is intentionally omitted — it needs the loudspeaker
|
- Echo cancellation (WebRTC AEC3) is intentionally omitted — it needs the loudspeaker
|
||||||
reference signal and matters mainly for open speakers, not the typical headset.
|
reference signal and matters mainly for open speakers, not the typical headset.
|
||||||
- **Push-to-Talk** — bind any key; transmits only while held (while the app is focused).
|
- **Push-to-Talk** — bind any key or mouse button as a global hotkey; transmits only while held.
|
||||||
- **Continuous** transmission mode.
|
- **Continuous** transmission mode.
|
||||||
- **Playback mixing** — each speaker gets its own Opus decoder and audio line, so
|
- **Adaptive jitter buffer** — a port of the libspeex jitter buffer the TS3 client uses,
|
||||||
multiple simultaneous talkers are mixed and one slow decode never blocks others.
|
set up and driven as it does: the delay follows the measured network jitter, and what it
|
||||||
|
has learned carries over between talk bursts.
|
||||||
|
- **Playback mixing** — each speaker gets their own Opus decoder and audio line (a stream
|
||||||
|
of their own in the PipeWire mixer), so one slow decode never blocks the others.
|
||||||
- **Whisper receive** — targeted voice is decoded and played like normal voice.
|
- **Whisper receive** — targeted voice is decoded and played like normal voice.
|
||||||
- Per-client **mute**, master **deafen**, adjustable mic gain / playback volume.
|
- Per-client **mute**, master **deafen**, adjustable mic gain / playback volume.
|
||||||
- **On-the-fly Opus tuning** — bitrate, complexity, VBR, FEC and voice/music codec
|
- **On-the-fly Opus tuning** — bitrate, complexity, VBR, FEC and voice/music codec
|
||||||
@@ -71,15 +72,76 @@ frontend supplies its own UI and audio backend while reusing `core` unchanged.
|
|||||||
**private chat**, or **locally mute** them.
|
**private chat**, or **locally mute** them.
|
||||||
- **Chat** to the current channel or the whole server; receive channel/server/private
|
- **Chat** to the current channel or the whole server; receive channel/server/private
|
||||||
messages and **pokes**.
|
messages and **pokes**.
|
||||||
|
- **Avatars** — a selected client's avatar is shown in the info panel, fetched from the
|
||||||
|
server's file repository (`/avatar_<id>` in channel 0, verified against the client's
|
||||||
|
`client_flag_avatar` MD5) and cached on disk; **animated GIFs play**. Self → Set
|
||||||
|
avatar… uploads your own picture (PNG/JPEG/GIF/BMP, within the server's
|
||||||
|
`i_client_max_avatar_filesize`) or removes it.
|
||||||
- **Server bookmarks** — quick-connect menu with add/edit/remove management, each
|
- **Server bookmarks** — quick-connect menu with add/edit/remove management, each
|
||||||
optionally pinned to a specific identity.
|
optionally pinned to a specific identity.
|
||||||
- **Identity management** (Tools → Identities) — keep several identities, mark one
|
- **Identity management** (Tools → Identities) — keep several identities, mark one
|
||||||
as the default, pick one per server or per bookmark, rename, raise an identity's
|
as the default, pick one per server or per bookmark, rename, raise an identity's
|
||||||
security level, and import/export TeamSpeak-compatible `.ini` identity files.
|
security level, and import/export TeamSpeak-compatible `.ini` identity files.
|
||||||
|
- **Import from TeamSpeak 3 / myTeamSpeak** — the official client keeps its bookmarks
|
||||||
|
and identities (the data a myTeamSpeak account synchronises) as protobuf
|
||||||
|
`Item_Data` blobs in the `ProtobufItems` table of its `settings.db`. On first run,
|
||||||
|
when this client has no identity of its own yet, it adopts all of them — identities,
|
||||||
|
the default identity and its nickname, and the bookmarks with the identities they
|
||||||
|
connect with; "Import from TeamSpeak 3…" in Manage Bookmarks and in Identities
|
||||||
|
re-imports at any time (additive: known identities and bookmarks are left alone).
|
||||||
|
The schema was recovered from the descriptors embedded in the client binary.
|
||||||
|
- **Contacts** (Tools → Contacts) — TeamSpeak's friend/blocked list: mark a client as
|
||||||
|
**Friend**, **Blocked** or **Neutral** from its context menu (or drag it into the
|
||||||
|
window), give it a custom and a phonetic nickname, choose how the name is shown
|
||||||
|
(custom name and nickname / only custom / only nickname), and per contact **mute
|
||||||
|
automatically**, **ignore** server/channel chat, private chat, pokes and away
|
||||||
|
messages, and **allow or deny whispers**. Friends are coloured green and blocked
|
||||||
|
clients red in the tree (optional), per-type defaults for new contacts are
|
||||||
|
configurable ("Set Defaults"), and the global whisper policy lives in Options →
|
||||||
|
Contacts. The list is stored in `~/.ts3jclient/contacts.txt` in the *exact* record
|
||||||
|
format the official client keeps in the `Contacts` table of its `settings.db`; on
|
||||||
|
first run it is seeded from an installed TeamSpeak 3 client's database (read with a
|
||||||
|
small built-in SQLite reader — no driver needed), and can be re-imported any time.
|
||||||
|
- **Per-client volume** ("Change Volume…") — a dB gain on top of the master volume,
|
||||||
|
remembered in the client's contact entry.
|
||||||
|
- **Ban List** (Tools → Ban List) — the server's bans in a searchable table (Add /
|
||||||
|
Remove / Edit, own-ban filter and highlighting, choosable columns), with the
|
||||||
|
official Add dialog: IP and nickname patterns read as IPv4/IPv6 wildcards, fixed
|
||||||
|
strings or regular expressions, unique id, myTeamSpeak id, reason presets and a
|
||||||
|
duration capped by `i_client_ban_max_bantime`.
|
||||||
- **Self status** — Away (with message) and Channel Commander toggles.
|
- **Self status** — Away (with message) and Channel Commander toggles.
|
||||||
- **Status bar** shows the server name, user count and live ping.
|
- **Status bar** shows the server name, user count and live ping.
|
||||||
- Change your nickname, mute/deafen from the toolbar.
|
- Change your nickname, mute/deafen from the toolbar.
|
||||||
|
|
||||||
|
### Hotkeys
|
||||||
|
- **Global hotkeys**, as in TeamSpeak 3: single keys or combinations (any key can act
|
||||||
|
as the modifier) and mouse buttons including *Mouse 4* / *Mouse 5*, working
|
||||||
|
system-wide rather than only while the window has focus. Configure them in
|
||||||
|
**Options → Hotkeys**.
|
||||||
|
- Per binding you choose whether it **triggers on key down or on key up**, and whether
|
||||||
|
it applies **on the active server** only or to every connected one (checkbox off).
|
||||||
|
Push-to-talk style actions ignore the trigger setting and simply last while held.
|
||||||
|
- The action list is TeamSpeak's own, reverse-engineered from its hotkey dialog:
|
||||||
|
three categories (*Server*, *Self*, *Misc*) and a **"Show advanced actions"**
|
||||||
|
checkbox that reveals the rest, exactly as the original hides all but the everyday
|
||||||
|
actions. Actions this client does not implement are listed but greyed out.
|
||||||
|
- Push-to-talk is one of those hotkeys; the button in **Options → Voice Activation**
|
||||||
|
edits that binding.
|
||||||
|
- On Linux, capture uses **XInput2 raw events**, as TeamSpeak's own client does: no
|
||||||
|
privileges needed and the keystroke is not swallowed. X11's RECORD extension is kept
|
||||||
|
as a fallback for servers without XInput2.
|
||||||
|
- On **Windows**, capture uses **raw input** (`RIDEV_INPUTSINK`) through a message-only
|
||||||
|
window: it observes rather than intercepts, so unlike a low-level keyboard hook it
|
||||||
|
cannot swallow a keystroke or stall the system input queue, and it reports the side
|
||||||
|
buttons and both edges of every key. Bindings are stored as scan codes, so they follow
|
||||||
|
the physical key rather than the layout.
|
||||||
|
- On Wayland the hooks run inside Xwayland, so they see every X11 application, and keys
|
||||||
|
aimed at native Wayland windows only where the compositor forwards them — under KWin
|
||||||
|
that is *System Settings → Window Management → Legacy X11 App Support*, which is why
|
||||||
|
TeamSpeak's hotkeys work there. Where it forwards nothing, the client reads
|
||||||
|
`/dev/input/event*` instead, for which your user has to be in the `input` group.
|
||||||
|
The Hotkeys tab says which backend is in use, or why none is.
|
||||||
|
|
||||||
### Notification sounds
|
### Notification sounds
|
||||||
- **Sound packs** in TeamSpeak's own format: a folder of waves plus a `settings.ini`
|
- **Sound packs** in TeamSpeak's own format: a folder of waves plus a `settings.ini`
|
||||||
mapping actions (`CONNECTION_CONNECTED`, `CLIENT_MOVED_TO_CURRENT_CHANNEL_STAYS`, …)
|
mapping actions (`CONNECTION_CONNECTED`, `CLIENT_MOVED_TO_CURRENT_CHANNEL_STAYS`, …)
|
||||||
@@ -100,6 +162,11 @@ frontend supplies its own UI and audio backend while reusing `core` unchanged.
|
|||||||
TeamSpeak 3 client (`$TS3_CLIENT_DIR` or the usual install paths) and a folder of
|
TeamSpeak 3 client (`$TS3_CLIENT_DIR` or the usual install paths) and a folder of
|
||||||
your choosing, so the official packs (`default_colored_2014.zip`,
|
your choosing, so the official packs (`default_colored_2014.zip`,
|
||||||
`default_mono_2014.zip`, the legacy `default.zip`) work unchanged.
|
`default_mono_2014.zip`, the legacy `default.zip`) work unchanged.
|
||||||
|
- **Material**, a pack in the same format built from Google's Material icons, ships
|
||||||
|
with the client (`core/src/main/resources/gfx/material`, generated by
|
||||||
|
`tools/material-icon-pack.py`). Its monochrome icons paint with `currentColor`, which
|
||||||
|
follows the theme's text colour; it covers every icon TeamSpeak's own packs do. It is
|
||||||
|
the Android app's default, and the Android app draws TeamSpeak packs too — import one (e.g. `default_colored_2014.zip`) in its settings.
|
||||||
- The pack draws the tree (server, channel, client state), the toolbar, the menus,
|
- The pack draws the tree (server, channel, client state), the toolbar, the menus,
|
||||||
the context menus, the file browser and TeamSpeak's default group icons. Vector
|
the context menus, the file browser and TeamSpeak's default group icons. Vector
|
||||||
art is rasterised at the size it is drawn at; a pack's `FALLBACK` option decides
|
art is rasterised at the size it is drawn at; a pack's `FALLBACK` option decides
|
||||||
@@ -108,19 +175,31 @@ frontend supplies its own UI and audio backend while reusing `core` unchanged.
|
|||||||
- **Pick one in Options → Design**, which also shows a viewer of everything the pack
|
- **Pick one in Options → Design**, which also shows a viewer of everything the pack
|
||||||
contains; switching redraws the window right away.
|
contains; switching redraws the window right away.
|
||||||
|
|
||||||
|
### Display scaling (Linux)
|
||||||
|
- Java2D on X11 (and so under XWayland) only scales by whole numbers, so 125 % or
|
||||||
|
150 % comes out at 100 %. On Linux the client pins Java2D to 1 and hands the full
|
||||||
|
factor to FlatLaf instead, scaling its own sizes and icons to match.
|
||||||
|
- The factor follows the desktop: XSETTINGS `Xft/DPI` (GNOME, Plasma), else KWin's
|
||||||
|
XWayland scale, else `GDK_SCALE`. For crisp text on Wayland, let X11 apps scale
|
||||||
|
themselves (Plasma: *Display Configuration → Legacy Applications → Apply scaling
|
||||||
|
themselves*; GNOME: the `xwayland-native-scaling` mutter feature), otherwise the
|
||||||
|
compositor upscales the window.
|
||||||
|
- **Options → Design → Interface scale** overrides it (after a restart);
|
||||||
|
`-Dflatlaf.uiScale=` or `-Dsun.java2d.uiScale=` on the command line bypasses all this.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
- Java 26+ (developed/tested on Temurin 26)
|
- Java 25+ (the LTS; developed on Temurin 26)
|
||||||
- The native Opus library on the system:
|
- The native Opus library on the system:
|
||||||
- Debian/Ubuntu: `sudo apt install libopus0`
|
- Debian/Ubuntu: `sudo apt install libopus0`
|
||||||
- Arch: `sudo pacman -S opus`
|
- Arch: `sudo pacman -S opus`
|
||||||
- macOS: `brew install opus`
|
- macOS: `brew install opus`
|
||||||
|
|
||||||
## Building
|
## Building
|
||||||
The client depends on `ts3j`, so install that into your local Maven repo first:
|
The build compiles `ts3j` from `../ts3j` alongside the client, so make sure the submodule is
|
||||||
|
checked out (`git submodule update --init`):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd ../ts3j && mvn -DskipTests install
|
mvn -DskipTests package
|
||||||
cd ../ts3-client && mvn -DskipTests package
|
|
||||||
```
|
```
|
||||||
|
|
||||||
This produces a runnable fat-jar at `swing/target/ts3-client.jar`.
|
This produces a runnable fat-jar at `swing/target/ts3-client.jar`.
|
||||||
@@ -139,39 +218,71 @@ voice activation while watching the live meter.
|
|||||||
## Architecture
|
## Architecture
|
||||||
```
|
```
|
||||||
core/ com.ts3client
|
core/ com.ts3client
|
||||||
├── config.Settings persisted prefs (~/.ts3jclient/settings.properties)
|
├── config the profile (~/.ts3jclient): Settings, Bookmarks, IdentityStore,
|
||||||
├── config.Bookmarks persisted server bookmarks (with per-server identity)
|
│ AwayMessages, …; AppDirs finds it, ProfileFiles writes it
|
||||||
├── config.IdentityStore managed identities (~/.ts3jclient/identities/*.ini)
|
│ privately and all at once
|
||||||
├── audio abstractions + reusable DSP (no platform code)
|
├── session
|
||||||
│ ├── AudioBackend factory for a platform's VoiceInput/VoiceOutput
|
│ ├── ServerSession one server tab's state and actions, whatever the frontend
|
||||||
│ ├── VoiceInput capture source (extends ts3j Microphone) + gating controls
|
│ └── Sessions what the servers share: the microphone, away state, nickname
|
||||||
│ ├── VoiceOutput voice-packet playback sink
|
├── net
|
||||||
│ ├── OpusParameters live-tunable encoder settings
|
│ ├── TeamspeakConnection ties socket + audio + model; runs requests on its own pool
|
||||||
│ └── SpeechDetector feature-based speech-probability VAD (Automatic/Hybrid)
|
│ ├── ConnectionEventHandler server events → model changes, sounds and log lines
|
||||||
|
│ ├── ServerModel channel/client state; ChannelNode/ClientEntry view models
|
||||||
|
│ ├── ChannelAdmin/BanAdmin/AvatarAdmin channel editing, the ban list, our own avatar
|
||||||
|
│ ├── IconRepository server icons (avatar/ holds the client avatar cache)
|
||||||
|
│ ├── filetransfer channel file browsing, up- and downloads
|
||||||
|
│ └── ConnectionListener frontend callbacks
|
||||||
|
├── audio the voice pipelines every platform shares
|
||||||
|
│ ├── AudioBackend/AudioIo what a platform supplies: devices, lines, Opus
|
||||||
|
│ ├── CaptureVoiceInput capture → processing → VAD/PTT gate → Opus encode
|
||||||
|
│ ├── StreamingVoiceOutput per-speaker decode, each through a VoiceStream
|
||||||
|
│ ├── JitterBuffer port of the libspeex jitter buffer TS3 uses
|
||||||
|
│ ├── PerSpeakerPlayout a line per speaker (desktop); MixedPlayout mixes onto one (Android)
|
||||||
|
│ ├── processing port of TS3's WebRTC capture chain: HPF, NS, transients, AGC2
|
||||||
|
│ ├── vad port of WebRTC's rnn_vad speech detector
|
||||||
|
│ └── opus codec interfaces
|
||||||
|
├── chatlog TS3-compatible chat logs, shared with the TS3 client by default
|
||||||
|
├── contacts friends and blocked clients, in TS3's own record format
|
||||||
|
├── text BBCode, TeamSpeak links, chat search
|
||||||
├── gfx
|
├── gfx
|
||||||
│ ├── IconPack an icon pack zip/folder + its settings.ini mapping
|
│ ├── IconPack an icon pack zip/folder/classpath folder + its settings.ini mapping
|
||||||
│ └── IconPacks discovery of installed packs
|
│ ├── IconPacks discovery of installed packs, the shipped Material one, lookup order
|
||||||
|
│ └── IconKeys the icon keys the channel tree asks for
|
||||||
|
├── hotkey
|
||||||
|
│ ├── HotkeyAction catalogue of TeamSpeak's hotkey actions (RE'd from its binary)
|
||||||
|
│ ├── Hotkey/HotkeyCombo one binding: keys, trigger edge, server scope, argument
|
||||||
|
│ ├── Hotkeys persisted bindings (~/.ts3jclient/hotkeys.properties)
|
||||||
|
│ ├── GlobalInputHook platform hook interface: system-wide key/button events
|
||||||
|
│ └── HotkeyEngine held-key tracking, combination matching, recording
|
||||||
├── sound
|
├── sound
|
||||||
│ ├── SoundEvent catalogue of actions (TeamSpeak's own event ids)
|
│ ├── SoundEvent catalogue of actions (TeamSpeak's own event ids)
|
||||||
│ ├── SoundPack a pack folder + its settings.ini mapping
|
│ ├── SoundPack a pack folder + its settings.ini mapping
|
||||||
│ ├── SoundPacks discovery of installed packs
|
│ ├── SoundPacks discovery of installed packs
|
||||||
│ ├── NotificationSettings per-action enabled / important flags
|
│ ├── NotificationSettings per-action enabled / important flags
|
||||||
│ ├── SoundNotifier decides what is heard (pack + config + mute state)
|
│ ├── SoundNotifier decides what is heard (pack + config + mute state)
|
||||||
│ └── SoundPlayer platform hook for rendering a sound
|
│ ├── SoundPlayer renders a sound
|
||||||
└── net
|
│ └── PackSoundPlayer decodes, resamples and mixes sounds onto one line
|
||||||
├── TeamspeakConnection ties socket + audio backend + model, translates events
|
└── teamspeak the official client's own data files
|
||||||
├── ServerModel thread-safe channel/client state
|
├── TeamSpeakSettingsDb locates settings.db; reads Contacts and the ProtobufItems sync store
|
||||||
├── ChannelNode/ClientEntry view models
|
├── SqliteReader minimal read-only SQLite 3 file reader (no driver)
|
||||||
└── ConnectionListener frontend callbacks
|
├── SyncItem a synced bookmark / identity / folder (Item_Data)
|
||||||
|
├── SyncItemDecoder protobuf wire decoding of Item_Data, via ProtobufReader
|
||||||
|
└── TeamSpeakImporter merges synced items into IdentityStore / Bookmarks
|
||||||
|
|
||||||
desktop/ com.ts3client.audio.desktop
|
desktop/ com.ts3client.audio.desktop + com.ts3client.hotkey.desktop
|
||||||
├── Opus Panama (FFM) binding to native libopus
|
├── DesktopAudioBackend PipeWire where it runs, Java Sound elsewhere, and native Opus
|
||||||
├── OpusEncoder/OpusDecoder thin codec wrappers
|
├── Opus/NativeOpusCodec Panama (FFM) binding to native libopus
|
||||||
├── AudioDevices device enumeration + line opening (48 kHz/16-bit)
|
├── AudioDevices device enumeration + line opening (48 kHz/16-bit)
|
||||||
├── JavaSoundVoiceInput capture + VAD/PTT gating + Opus encode
|
├── JavaSoundCapture/JavaSoundPlayback Java Sound lines
|
||||||
├── JavaSoundVoiceOutput per-client Opus decode + playback + mixing
|
├── pipewire PipeWire streams through FFM
|
||||||
├── WavSoundPlayer sound-pack playback: decode, resample and mix on one line
|
└── hotkey.desktop
|
||||||
└── JavaSoundAudioBackend wires the above into the core AudioBackend
|
├── XInput2InputHook global key/button capture via XInput2 raw events
|
||||||
|
├── XRecordInputHook the same via X11's RECORD extension, as a fallback
|
||||||
|
├── EvdevInputHook /dev/input fallback for Wayland sessions
|
||||||
|
├── WindowsInputHook the same on Windows: raw input into a message-only window
|
||||||
|
├── RawInput RAWINPUT layout and decoding, split out to be testable
|
||||||
|
├── X11KeyNamer/WindowsKeyNamer layout-aware key labels per platform
|
||||||
|
└── DesktopInputHooks picks the backend that suits the session
|
||||||
|
|
||||||
swing/ com.ts3client
|
swing/ com.ts3client
|
||||||
├── Main entry point (look & feel, settings, backend injection)
|
├── Main entry point (look & feel, settings, backend injection)
|
||||||
@@ -181,9 +292,14 @@ swing/ com.ts3client
|
|||||||
├── Spacers TS3 spacer-channel name parsing/rendering
|
├── Spacers TS3 spacer-channel name parsing/rendering
|
||||||
├── InfoPanel channel description / client group + details view
|
├── InfoPanel channel description / client group + details view
|
||||||
├── ChatPanel chat log + input
|
├── ChatPanel chat log + input
|
||||||
├── SettingsDialog audio + VAD options with live meter
|
├── SettingsDialog the Options dialog: TS3-style page sidebar; one panel per page (ApplicationPanel, …, ChatLogsPanel)
|
||||||
|
├── HotkeysPanel hotkey list (Options → Hotkeys)
|
||||||
|
├── HotkeyDialog add/edit one hotkey: action, combination, trigger, scope
|
||||||
|
├── HotkeyService bindings + engine + input hook, for the dialogs
|
||||||
|
├── HotkeyActions carries a fired hotkey out on the client
|
||||||
├── NotificationsPanel sound pack + per-action sound/important configuration
|
├── NotificationsPanel sound pack + per-action sound/important configuration
|
||||||
├── IconPackPanel icon pack chooser + icon viewer (Options → Design)
|
├── IconPackPanel icon pack chooser + icon viewer (Options → Design)
|
||||||
|
├── DisplayScale interface scale on Linux (fractional, via FlatLaf)
|
||||||
├── ConnectDialog connect form
|
├── ConnectDialog connect form
|
||||||
├── BookmarksDialog manage saved servers
|
├── BookmarksDialog manage saved servers
|
||||||
├── IdentitiesDialog manage identities (new/import/export/improve)
|
├── IdentitiesDialog manage identities (new/import/export/improve)
|
||||||
@@ -195,11 +311,15 @@ swing/ com.ts3client
|
|||||||
```
|
```
|
||||||
|
|
||||||
## Known limitations / next steps
|
## Known limitations / next steps
|
||||||
- The **Automatic/Hybrid** VAD uses a lightweight energy/spectral detector rather
|
|
||||||
than the WebRTC GMM model the official client ships; it is intentionally
|
|
||||||
dependency-free and reusable in the core library.
|
|
||||||
- Whisper is received/played but not yet **sendable** from the UI.
|
- Whisper is received/played but not yet **sendable** from the UI.
|
||||||
- Playback decodes streams as mono; stereo music-bot audio is down-mixed.
|
- Hotkey actions TeamSpeak offers but this client does not perform yet (listed but
|
||||||
- Push-to-talk is captured via Swing key events, so it only works while the app
|
greyed out in the dialog): capture/playback/hotkey profiles and sound packs,
|
||||||
window has focus (no global hotkey).
|
whisper and push-to-whisper, recording, plugins, server groups, talk power, 3D
|
||||||
- No file transfer, avatars, or server/channel administration UI yet.
|
sound, hardware ("local") microphone mute and the channel-traversal variants
|
||||||
|
beyond "Switch to Channel".
|
||||||
|
- Global hotkeys cover Linux and Windows; **macOS has no backend**, so hotkeys there are
|
||||||
|
inert (a `CGEventTap`, which needs Accessibility permission, is the way in). The
|
||||||
|
Windows backend is written but has not been run on Windows — only its decoding is
|
||||||
|
covered by tests.
|
||||||
|
- When no backend can start, push-to-talk does not work at all, not even with the window
|
||||||
|
focused; a focus-scoped fallback hook would restore the pre-hotkey behaviour.
|
||||||
|
|||||||
@@ -14,6 +14,13 @@
|
|||||||
<name>TS3J Client Core</name>
|
<name>TS3J Client Core</name>
|
||||||
<description>Frontend-agnostic library: protocol integration, model, audio abstractions</description>
|
<description>Frontend-agnostic library: protocol integration, model, audio abstractions</description>
|
||||||
|
|
||||||
|
<properties>
|
||||||
|
<!-- Android runs core too, and it only knows Java 17. -->
|
||||||
|
<maven.compiler.release>17</maven.compiler.release>
|
||||||
|
<!-- The TeamSpeak 3 client whose icon packs ship (-Dts3.client.dir=…); without one, none do. -->
|
||||||
|
<ts3.client.dir>${project.basedir}/../../TeamSpeak3-Client-linux_amd64</ts3.client.dir>
|
||||||
|
</properties>
|
||||||
|
|
||||||
<dependencies>
|
<dependencies>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.github.manevolent</groupId>
|
<groupId>com.github.manevolent</groupId>
|
||||||
@@ -26,4 +33,23 @@
|
|||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<resources>
|
||||||
|
<resource>
|
||||||
|
<directory>src/main/resources</directory>
|
||||||
|
</resource>
|
||||||
|
<!-- TeamSpeak's own icon packs and country flags, proprietary and so taken from an install, not the repository. -->
|
||||||
|
<resource>
|
||||||
|
<directory>${ts3.client.dir}/gfx</directory>
|
||||||
|
<targetPath>gfx/teamspeak</targetPath>
|
||||||
|
<includes>
|
||||||
|
<include>default.zip</include>
|
||||||
|
<include>default_colored_2014.zip</include>
|
||||||
|
<include>default_mono_2014.zip</include>
|
||||||
|
<include>countries.zip</include>
|
||||||
|
</includes>
|
||||||
|
</resource>
|
||||||
|
</resources>
|
||||||
|
</build>
|
||||||
</project>
|
</project>
|
||||||
|
|||||||
@@ -1,20 +1,36 @@
|
|||||||
package com.ts3client.audio;
|
package com.ts3client.audio;
|
||||||
|
|
||||||
|
import com.ts3client.audio.opus.OpusCodec;
|
||||||
import com.ts3client.config.Settings;
|
import com.ts3client.config.Settings;
|
||||||
import com.ts3client.sound.SoundPlayer;
|
import com.ts3client.sound.SoundPlayer;
|
||||||
|
import com.ts3client.sound.PackSoundPlayer;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Factory for a platform's voice capture and playback. Injected into the
|
* A platform's audio stack: its devices and its libopus. Injected into the connection
|
||||||
* connection layer so the core stays independent of any concrete audio stack.
|
* layer so the core stays independent of any concrete audio stack; the voice pipelines
|
||||||
|
* and the sound player on top are the same everywhere.
|
||||||
*/
|
*/
|
||||||
public interface AudioBackend {
|
public interface AudioBackend {
|
||||||
|
|
||||||
VoiceInput createInput(Settings settings);
|
AudioIo io();
|
||||||
|
|
||||||
VoiceOutput createOutput(Settings settings);
|
OpusCodec opus();
|
||||||
|
|
||||||
|
/** How incoming voice reaches this platform's devices. */
|
||||||
|
StreamingVoiceOutput.Lines outputLines();
|
||||||
|
|
||||||
|
default VoiceInput createInput(Settings settings) {
|
||||||
|
return new CaptureVoiceInput(settings, io(), opus());
|
||||||
|
}
|
||||||
|
|
||||||
|
default VoiceOutput createOutput(Settings settings) {
|
||||||
|
return new StreamingVoiceOutput(io(), opus(), settings.outputDevice, outputLines());
|
||||||
|
}
|
||||||
|
|
||||||
/** Player for notification sounds (sound packs); shared by all connections. */
|
/** Player for notification sounds (sound packs); shared by all connections. */
|
||||||
SoundPlayer createSoundPlayer(Settings settings);
|
default SoundPlayer createSoundPlayer(Settings settings) {
|
||||||
|
return new PackSoundPlayer(io(), opus(), settings.outputDevice);
|
||||||
|
}
|
||||||
|
|
||||||
/** Human-readable codec/backend description, e.g. for an "about" line. */
|
/** Human-readable codec/backend description, e.g. for an "about" line. */
|
||||||
String description();
|
String description();
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
package com.ts3client.audio.desktop;
|
package com.ts3client.audio;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An open microphone line, delivering 16-bit little-endian PCM at
|
* An open microphone line, delivering 16-bit little-endian PCM at
|
||||||
* {@link AudioDevices#SAMPLE_RATE}.
|
* {@link VoiceFormat#SAMPLE_RATE}.
|
||||||
*
|
*
|
||||||
* <p>Modelled on {@link javax.sound.sampled.TargetDataLine} so the capture pipeline does
|
* <p>Modelled on Java Sound's {@code TargetDataLine} so the capture pipeline does
|
||||||
* not care whether the audio comes from PipeWire or Java Sound.
|
* not care which platform audio API delivers it.
|
||||||
*/
|
*/
|
||||||
public interface AudioCapture extends AutoCloseable {
|
public interface AudioCapture extends AutoCloseable {
|
||||||
|
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.ts3client.audio;
|
||||||
|
|
||||||
|
/** A selectable audio device. {@code id} is what gets persisted in the settings. */
|
||||||
|
public record AudioDevice(String id, String label) {
|
||||||
|
|
||||||
|
/** The implicit entry that lets the platform choose. */
|
||||||
|
public static final AudioDevice DEFAULT = new AudioDevice("", "(System default)");
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,238 +0,0 @@
|
|||||||
package com.ts3client.audio;
|
|
||||||
|
|
||||||
import java.util.Arrays;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Microphone pre-processing chain applied before voice activation and Opus encoding,
|
|
||||||
* mirroring the capture-side denoise/typing filters of the TeamSpeak 3 client.
|
|
||||||
*
|
|
||||||
* <p>The stages run in the same order as the TeamSpeak client's WebRTC capture chain:
|
|
||||||
* a {@link HighPassFilter} (always-on rumble/DC removal), then a streaming short-time
|
|
||||||
* Fourier transform (square-root Hann window, 50% overlap-add) carrying a
|
|
||||||
* {@link NoiseSuppressor} ("Remove background noise") and a {@link TypingAttenuator}
|
|
||||||
* ("Typing attenuation") — sharing one FFT/IFFT per hop — and finally an
|
|
||||||
* {@link AutomaticGainControl} ("AGC"). Echo cancellation (WebRTC AEC3) is omitted as
|
|
||||||
* it requires the loudspeaker reference signal.
|
|
||||||
*
|
|
||||||
* <p>Input frames of any length are decoupled from the STFT hop by internal ring
|
|
||||||
* buffers; when noise/typing suppression is active the output is delayed by one hop
|
|
||||||
* (~5 ms). The high-pass filter and AGC are zero-latency. When every stage is
|
|
||||||
* disabled the chain is fully bypassed and audio passes through untouched.
|
|
||||||
*
|
|
||||||
* <p>Pure DSP with no platform dependencies, so any frontend/backend can reuse it.
|
|
||||||
* Not thread-safe: drive it from a single capture thread; the enable/level setters
|
|
||||||
* are cheap volatiles safe to call from the UI thread.
|
|
||||||
*/
|
|
||||||
public final class AudioEnhancer {
|
|
||||||
|
|
||||||
private static final int FFT_SIZE = 512; // power of two -> 10.7 ms @ 48 kHz
|
|
||||||
private static final int HOP = FFT_SIZE / 2; // 50% overlap
|
|
||||||
private static final int BINS = FFT_SIZE / 2 + 1;
|
|
||||||
|
|
||||||
private final double[] window = new double[FFT_SIZE];
|
|
||||||
private final double[] re = new double[FFT_SIZE];
|
|
||||||
private final double[] im = new double[FFT_SIZE];
|
|
||||||
private final double[] power = new double[BINS];
|
|
||||||
private final double[] gain = new double[BINS];
|
|
||||||
|
|
||||||
private final double[] frame = new double[FFT_SIZE]; // sliding analysis frame
|
|
||||||
private final double[] ola = new double[FFT_SIZE]; // overlap-add accumulator
|
|
||||||
|
|
||||||
private final FloatRing input = new FloatRing(FFT_SIZE * 4);
|
|
||||||
private final FloatRing output = new FloatRing(FFT_SIZE * 4);
|
|
||||||
private final float[] hopIn = new float[HOP];
|
|
||||||
|
|
||||||
private final NoiseSuppressor noiseSuppressor = new NoiseSuppressor(BINS);
|
|
||||||
private final TypingAttenuator typingAttenuator;
|
|
||||||
private final HighPassFilter highPass;
|
|
||||||
private final AutomaticGainControl agc;
|
|
||||||
|
|
||||||
private volatile boolean noiseEnabled;
|
|
||||||
private volatile boolean typingEnabled;
|
|
||||||
private volatile boolean agcEnabled;
|
|
||||||
private boolean active; // any stage on: HPF + AGC state is live
|
|
||||||
private boolean stftRunning; // noise/typing on: STFT rings are live
|
|
||||||
|
|
||||||
public AudioEnhancer(int sampleRate) {
|
|
||||||
for (int i = 0; i < FFT_SIZE; i++) {
|
|
||||||
// sqrt(Hann): analysis*synthesis = Hann, which is COLA at 50% overlap.
|
|
||||||
window[i] = Math.sqrt(0.5 * (1 - Math.cos(2 * Math.PI * i / FFT_SIZE)));
|
|
||||||
}
|
|
||||||
this.typingAttenuator = new TypingAttenuator(BINS, sampleRate, FFT_SIZE);
|
|
||||||
this.highPass = new HighPassFilter(sampleRate);
|
|
||||||
this.agc = new AutomaticGainControl(sampleRate);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setNoiseSuppression(boolean enabled) {
|
|
||||||
this.noiseEnabled = enabled;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setDenoiserLevel(double level) {
|
|
||||||
noiseSuppressor.setLevel(level);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setTypingAttenuation(boolean enabled) {
|
|
||||||
this.typingEnabled = enabled;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setAgc(boolean enabled) {
|
|
||||||
this.agcEnabled = enabled;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Clears all filter state; call when (re)starting capture. */
|
|
||||||
public void reset() {
|
|
||||||
resetStft();
|
|
||||||
highPass.reset();
|
|
||||||
agc.reset();
|
|
||||||
active = false;
|
|
||||||
stftRunning = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void resetStft() {
|
|
||||||
input.clear();
|
|
||||||
output.clear();
|
|
||||||
Arrays.fill(frame, 0);
|
|
||||||
Arrays.fill(ola, 0);
|
|
||||||
noiseSuppressor.reset();
|
|
||||||
typingAttenuator.reset();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Enhances one frame of mono PCM in place. {@code buf[0..len)} is overwritten with
|
|
||||||
* the processed (one-hop-delayed when noise/typing suppression is on) signal.
|
|
||||||
* Returns immediately if every stage is disabled.
|
|
||||||
*/
|
|
||||||
public void process(float[] buf, int len) {
|
|
||||||
boolean stft = noiseEnabled || typingEnabled;
|
|
||||||
boolean anyStage = stft || agcEnabled;
|
|
||||||
if (!anyStage) {
|
|
||||||
if (active) reset(); // drop stale filter/delay state on full disable
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!active) {
|
|
||||||
reset();
|
|
||||||
active = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1) High-pass filter (always-on part of the active chain).
|
|
||||||
highPass.process(buf, len);
|
|
||||||
|
|
||||||
// 2) STFT noise + typing suppression (only when either is enabled).
|
|
||||||
if (stft) {
|
|
||||||
if (!stftRunning) {
|
|
||||||
resetStft();
|
|
||||||
stftRunning = true;
|
|
||||||
}
|
|
||||||
runStft(buf, len);
|
|
||||||
} else if (stftRunning) {
|
|
||||||
stftRunning = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3) Automatic gain control (last, on the cleaned signal).
|
|
||||||
if (agcEnabled) {
|
|
||||||
agc.process(buf, len);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void runStft(float[] buf, int len) {
|
|
||||||
input.write(buf, len);
|
|
||||||
while (input.available() >= HOP) {
|
|
||||||
System.arraycopy(frame, HOP, frame, 0, FFT_SIZE - HOP);
|
|
||||||
input.read(hopIn, HOP);
|
|
||||||
for (int i = 0; i < HOP; i++) {
|
|
||||||
frame[FFT_SIZE - HOP + i] = hopIn[i];
|
|
||||||
}
|
|
||||||
processBlock();
|
|
||||||
}
|
|
||||||
|
|
||||||
// During the initial one-hop priming the output ring is short; pad with zeros.
|
|
||||||
int ready = output.available();
|
|
||||||
if (ready < len) {
|
|
||||||
for (int i = 0; i < len - ready; i++) buf[i] = 0f;
|
|
||||||
output.read(buf, len - ready, ready);
|
|
||||||
} else {
|
|
||||||
output.read(buf, 0, len);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void processBlock() {
|
|
||||||
for (int i = 0; i < FFT_SIZE; i++) {
|
|
||||||
re[i] = frame[i] * window[i];
|
|
||||||
im[i] = 0;
|
|
||||||
}
|
|
||||||
Fft.forward(re, im);
|
|
||||||
|
|
||||||
for (int k = 0; k < BINS; k++) {
|
|
||||||
power[k] = re[k] * re[k] + im[k] * im[k];
|
|
||||||
gain[k] = 1.0;
|
|
||||||
}
|
|
||||||
if (noiseEnabled) noiseSuppressor.apply(power, gain);
|
|
||||||
if (typingEnabled) typingAttenuator.apply(power, gain);
|
|
||||||
|
|
||||||
// Apply the real-valued gain to each bin and its conjugate mirror.
|
|
||||||
for (int k = 0; k < BINS; k++) {
|
|
||||||
double g = gain[k];
|
|
||||||
re[k] *= g;
|
|
||||||
im[k] *= g;
|
|
||||||
if (k > 0 && k < FFT_SIZE - k) {
|
|
||||||
int m = FFT_SIZE - k;
|
|
||||||
re[m] *= g;
|
|
||||||
im[m] *= g;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Fft.inverse(re, im);
|
|
||||||
|
|
||||||
for (int i = 0; i < FFT_SIZE; i++) {
|
|
||||||
ola[i] += re[i] * window[i];
|
|
||||||
}
|
|
||||||
output.write(ola, HOP);
|
|
||||||
System.arraycopy(ola, HOP, ola, 0, FFT_SIZE - HOP);
|
|
||||||
Arrays.fill(ola, FFT_SIZE - HOP, FFT_SIZE, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Minimal single-producer/single-consumer float ring buffer. */
|
|
||||||
private static final class FloatRing {
|
|
||||||
private final float[] buf;
|
|
||||||
private int head, tail, size;
|
|
||||||
|
|
||||||
FloatRing(int capacity) {
|
|
||||||
this.buf = new float[capacity];
|
|
||||||
}
|
|
||||||
|
|
||||||
int available() {
|
|
||||||
return size;
|
|
||||||
}
|
|
||||||
|
|
||||||
void clear() {
|
|
||||||
head = tail = size = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
void write(float[] src, int len) {
|
|
||||||
for (int i = 0; i < len; i++) {
|
|
||||||
buf[tail] = src[i];
|
|
||||||
tail = (tail + 1) % buf.length;
|
|
||||||
}
|
|
||||||
size += len;
|
|
||||||
}
|
|
||||||
|
|
||||||
void write(double[] src, int len) {
|
|
||||||
for (int i = 0; i < len; i++) {
|
|
||||||
buf[tail] = (float) src[i];
|
|
||||||
tail = (tail + 1) % buf.length;
|
|
||||||
}
|
|
||||||
size += len;
|
|
||||||
}
|
|
||||||
|
|
||||||
void read(float[] dst, int len) {
|
|
||||||
read(dst, 0, len);
|
|
||||||
}
|
|
||||||
|
|
||||||
void read(float[] dst, int offset, int len) {
|
|
||||||
for (int i = 0; i < len; i++) {
|
|
||||||
dst[offset + i] = buf[head];
|
|
||||||
head = (head + 1) % buf.length;
|
|
||||||
}
|
|
||||||
size -= len;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.ts3client.audio;
|
||||||
|
|
||||||
|
/** Receives frames of PCM audio as they pass through the capture or playback chain. */
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface AudioFrameListener {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called with one frame of interleaved samples in [-1, 1].
|
||||||
|
*
|
||||||
|
* <p>The array is reused between calls, so copy anything you need to keep. Called from
|
||||||
|
* the audio thread: do not block.
|
||||||
|
*
|
||||||
|
* @param interleaved samples, {@code channels} per sample position
|
||||||
|
* @param channels number of interleaved channels
|
||||||
|
*/
|
||||||
|
void onFrame(float[] interleaved, int channels);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.ts3client.audio;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** The platform's audio devices: what can be picked, and opening lines on them. */
|
||||||
|
public interface AudioIo {
|
||||||
|
|
||||||
|
/** Devices that can provide microphone lines, {@link AudioDevice#DEFAULT} first. */
|
||||||
|
List<AudioDevice> inputDevices();
|
||||||
|
|
||||||
|
/** Devices that can provide speaker lines, {@link AudioDevice#DEFAULT} first. */
|
||||||
|
List<AudioDevice> outputDevices();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens a capture line at {@link VoiceFormat#SAMPLE_RATE}, preferring
|
||||||
|
* {@code preferredChannels} but falling back to what the device takes; inspect
|
||||||
|
* {@link AudioCapture#channels()} for the result. An empty id means the default device.
|
||||||
|
*/
|
||||||
|
AudioCapture openCapture(String deviceId, int preferredChannels) throws Exception;
|
||||||
|
|
||||||
|
/** Opens a playback line; see {@link #openCapture} for the channel negotiation. */
|
||||||
|
AudioPlayback openPlayback(String deviceId, int preferredChannels) throws Exception;
|
||||||
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
package com.ts3client.audio.desktop;
|
package com.ts3client.audio;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An open speaker line, accepting 16-bit little-endian PCM at
|
* An open speaker line, accepting 16-bit little-endian PCM at
|
||||||
* {@link AudioDevices#SAMPLE_RATE}.
|
* {@link VoiceFormat#SAMPLE_RATE}.
|
||||||
*
|
*
|
||||||
* <p>Modelled on {@link javax.sound.sampled.SourceDataLine} so the playback pipeline does
|
* <p>Modelled on Java Sound's {@code SourceDataLine} so the playback pipeline does
|
||||||
* not care whether the audio goes to PipeWire or Java Sound.
|
* not care which platform audio API plays it.
|
||||||
*/
|
*/
|
||||||
public interface AudioPlayback extends AutoCloseable {
|
public interface AudioPlayback extends AutoCloseable {
|
||||||
|
|
||||||
@@ -15,7 +15,12 @@ public interface AudioPlayback extends AutoCloseable {
|
|||||||
/** Begins playing whatever is written from now on. */
|
/** Begins playing whatever is written from now on. */
|
||||||
void start();
|
void start();
|
||||||
|
|
||||||
/** Blocks until the audio has been queued for playback. */
|
/**
|
||||||
|
* Blocks until the audio has been queued for playback.
|
||||||
|
*
|
||||||
|
* @throws IllegalStateException once the line is gone for good (its device went away,
|
||||||
|
* say), so the caller can open another
|
||||||
|
*/
|
||||||
void write(byte[] buffer, int offset, int length);
|
void write(byte[] buffer, int offset, int length);
|
||||||
|
|
||||||
/** Blocks until everything already written has been played out. */
|
/** Blocks until everything already written has been played out. */
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
package com.ts3client.audio;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Automatic gain control — WebRTC APM's {@code gain_controller} (AGC2 adaptive
|
|
||||||
* digital) / Speex {@code AGC} stage. It normalises voice loudness toward a target
|
|
||||||
* level so quiet microphones are boosted and loud ones tamed, keeping perceived volume
|
|
||||||
* consistent across speakers.
|
|
||||||
*
|
|
||||||
* <p>Placed last in the capture chain (after noise suppression), it tracks the frame
|
|
||||||
* level and moves an applied gain toward {@code target / level}: it attenuates quickly
|
|
||||||
* to head off clipping and boosts slowly to avoid pumping. A noise gate freezes the
|
|
||||||
* gain while the input is near silence, so background noise between words is never
|
|
||||||
* amplified; the per-sample gain ramp avoids zipper artefacts and a final clamp guards
|
|
||||||
* against overshoot.
|
|
||||||
*/
|
|
||||||
final class AutomaticGainControl {
|
|
||||||
|
|
||||||
private static final double TARGET_RMS = 0.12; // ~ -18.4 dBFS
|
|
||||||
private static final double MAX_GAIN = dbToGain(30); // up to +30 dB boost
|
|
||||||
private static final double MIN_GAIN = dbToGain(-20); // down to -20 dB
|
|
||||||
private static final double NOISE_GATE_RMS = dbToGain(-55); // freeze below this level
|
|
||||||
|
|
||||||
private final double attackCoeff; // gain decreasing (signal too loud): fast
|
|
||||||
private final double releaseCoeff; // gain increasing (too quiet): slow
|
|
||||||
|
|
||||||
private double gain = 1.0;
|
|
||||||
|
|
||||||
AutomaticGainControl(int sampleRate) {
|
|
||||||
this.attackCoeff = 1 - Math.exp(-1.0 / (0.005 * sampleRate)); // ~5 ms
|
|
||||||
this.releaseCoeff = 1 - Math.exp(-1.0 / (0.300 * sampleRate)); // ~300 ms
|
|
||||||
}
|
|
||||||
|
|
||||||
void reset() {
|
|
||||||
gain = 1.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Applies gain normalisation to one mono frame in place. */
|
|
||||||
void process(float[] buf, int len) {
|
|
||||||
double sumSq = 0;
|
|
||||||
for (int i = 0; i < len; i++) {
|
|
||||||
sumSq += (double) buf[i] * buf[i];
|
|
||||||
}
|
|
||||||
double rms = Math.sqrt(sumSq / len);
|
|
||||||
|
|
||||||
double desired = gain;
|
|
||||||
if (rms >= NOISE_GATE_RMS) {
|
|
||||||
desired = TARGET_RMS / rms;
|
|
||||||
if (desired > MAX_GAIN) desired = MAX_GAIN;
|
|
||||||
else if (desired < MIN_GAIN) desired = MIN_GAIN;
|
|
||||||
}
|
|
||||||
// Boost slowly, attenuate quickly.
|
|
||||||
double coeff = desired < gain ? attackCoeff : releaseCoeff;
|
|
||||||
|
|
||||||
for (int i = 0; i < len; i++) {
|
|
||||||
gain += (desired - gain) * coeff;
|
|
||||||
double y = buf[i] * gain;
|
|
||||||
if (y > 1.0) y = 1.0;
|
|
||||||
else if (y < -1.0) y = -1.0;
|
|
||||||
buf[i] = (float) y;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static double dbToGain(double db) {
|
|
||||||
return Math.pow(10.0, db / 20.0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
package com.ts3client.audio.desktop;
|
package com.ts3client.audio;
|
||||||
|
|
||||||
import com.github.manevolent.ts3j.enums.CodecType;
|
import com.github.manevolent.ts3j.enums.CodecType;
|
||||||
import com.ts3client.audio.AudioEnhancer;
|
import com.ts3client.audio.opus.OpusCodec;
|
||||||
import com.ts3client.audio.OpusParameters;
|
import com.ts3client.audio.opus.OpusEncoder;
|
||||||
import com.ts3client.audio.SpeechDetector;
|
import com.ts3client.audio.processing.AudioProcessor;
|
||||||
import com.ts3client.audio.VoiceInput;
|
import com.ts3client.audio.processing.ns.SuppressionLevel;
|
||||||
|
import com.ts3client.audio.vad.RnnSpeechDetector;
|
||||||
import com.ts3client.config.Settings;
|
import com.ts3client.config.Settings;
|
||||||
|
|
||||||
import java.util.concurrent.ConcurrentLinkedQueue;
|
import java.util.concurrent.ConcurrentLinkedQueue;
|
||||||
@@ -12,11 +13,11 @@ import java.util.concurrent.atomic.AtomicBoolean;
|
|||||||
import java.util.function.Consumer;
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Desktop {@link VoiceInput}: captures the microphone, applies voice-activation
|
* The {@link VoiceInput} every platform shares: captures the microphone, applies voice-activation
|
||||||
* or push-to-talk gating, and Opus-encodes 20 ms frames.
|
* or push-to-talk gating, and Opus-encodes 20 ms frames.
|
||||||
*
|
*
|
||||||
* <p>The capture line comes from {@link AudioDevices}, which picks PipeWire or Java
|
* <p>The capture line comes from the platform's {@link AudioIo}; it is opened in stereo
|
||||||
* Sound; it is opened in stereo when the device offers it. Voice
|
* when the device offers it. Voice
|
||||||
* ({@code OPUS_VOICE}) is transmitted mono, from the downmix, so the pre-processing
|
* ({@code OPUS_VOICE}) is transmitted mono, from the downmix, so the pre-processing
|
||||||
* chain and VAD see a single channel; the music codec ({@code OPUS_MUSIC}) transmits
|
* chain and VAD see a single channel; the music codec ({@code OPUS_MUSIC}) transmits
|
||||||
* the stereo capture as-is.
|
* the stereo capture as-is.
|
||||||
@@ -26,12 +27,31 @@ import java.util.function.Consumer;
|
|||||||
* network sending. When the gate closes and the queue drains {@link #isReady()}
|
* network sending. When the gate closes and the queue drains {@link #isReady()}
|
||||||
* returns {@code false}, prompting ts3j to emit the terminating empty voice packet.
|
* returns {@code false}, prompting ts3j to emit the terminating empty voice packet.
|
||||||
*/
|
*/
|
||||||
public final class DesktopVoiceInput implements VoiceInput {
|
public final class CaptureVoiceInput implements VoiceInput {
|
||||||
|
|
||||||
private static final int HANGOVER_FRAMES = 15; // ~300 ms of tail after level drops
|
/**
|
||||||
|
* How long the gate stays open after the last active frame. TS3 counts 64 of its
|
||||||
|
* 10 ms preprocessor frames before closing, so the tail of a word is never clipped.
|
||||||
|
*/
|
||||||
|
private static final int HANGOVER_MS = 640;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Audio replayed when the gate opens, so a word's onset is not swallowed by the frame
|
||||||
|
* that detected it. TS3 keeps up to three 10 ms buffers ({@code vad_extrabuffersize}
|
||||||
|
* defaults to 2, plus one).
|
||||||
|
*/
|
||||||
|
private static final int PREROLL_MS = 30;
|
||||||
|
|
||||||
|
private static final int HANGOVER_FRAMES =
|
||||||
|
Math.max(1, HANGOVER_MS * VoiceFormat.SAMPLE_RATE / 1000 / VoiceFormat.FRAME_SIZE);
|
||||||
|
private static final int PREROLL_FRAMES =
|
||||||
|
Math.max(1, PREROLL_MS * VoiceFormat.SAMPLE_RATE / 1000 / VoiceFormat.FRAME_SIZE);
|
||||||
|
/** How long stop() waits for the capture loop to finish its frame. */
|
||||||
|
private static final long STOP_TIMEOUT_MS = 500;
|
||||||
|
|
||||||
private final ConcurrentLinkedQueue<byte[]> queue = new ConcurrentLinkedQueue<>();
|
private final ConcurrentLinkedQueue<byte[]> queue = new ConcurrentLinkedQueue<>();
|
||||||
private final AtomicBoolean muted = new AtomicBoolean(false);
|
private final AtomicBoolean muted = new AtomicBoolean(false);
|
||||||
|
private final AtomicBoolean localMuted = new AtomicBoolean(false);
|
||||||
private final AtomicBoolean transmitting = new AtomicBoolean(false);
|
private final AtomicBoolean transmitting = new AtomicBoolean(false);
|
||||||
private final AtomicBoolean pttDown = new AtomicBoolean(false);
|
private final AtomicBoolean pttDown = new AtomicBoolean(false);
|
||||||
private final AtomicBoolean running = new AtomicBoolean(false);
|
private final AtomicBoolean running = new AtomicBoolean(false);
|
||||||
@@ -44,20 +64,24 @@ public final class DesktopVoiceInput implements VoiceInput {
|
|||||||
private volatile double inputGain;
|
private volatile double inputGain;
|
||||||
private volatile CodecType codec = CodecType.OPUS_VOICE;
|
private volatile CodecType codec = CodecType.OPUS_VOICE;
|
||||||
|
|
||||||
private final SpeechDetector speechDetector = new SpeechDetector(AudioDevices.SAMPLE_RATE);
|
private final SpeechProbabilityDetector speechDetector =
|
||||||
private final AudioEnhancer enhancer = new AudioEnhancer(AudioDevices.SAMPLE_RATE);
|
new RnnSpeechDetector(VoiceFormat.SAMPLE_RATE);
|
||||||
|
private final AudioProcessor processor = new AudioProcessor();
|
||||||
|
|
||||||
private volatile Consumer<Double> levelListener; // input level in dBFS
|
private volatile Consumer<Double> levelListener; // input level, InputLevel scale
|
||||||
private volatile Consumer<Boolean> talkListener; // local talk-state changes
|
private volatile Consumer<Boolean> talkListener; // local talk-state changes
|
||||||
private volatile Runnable mutedTalkListener; // speech detected while muted
|
private volatile Runnable mutedTalkListener; // speech detected while muted
|
||||||
|
private volatile AudioFrameListener monitorListener; // local monitoring of what is sent
|
||||||
private boolean mutedTalking;
|
private boolean mutedTalking;
|
||||||
|
|
||||||
private final String deviceName;
|
private final AudioIo io;
|
||||||
|
private final OpusCodec opus;
|
||||||
|
private volatile String deviceName;
|
||||||
|
|
||||||
private final Object encoderLock = new Object();
|
private final Object encoderLock = new Object();
|
||||||
private volatile OpusParameters params;
|
private volatile OpusParameters params;
|
||||||
private OpusParameters appliedParams;
|
private OpusParameters appliedParams;
|
||||||
private int encoderApplication = -1;
|
private OpusCodec.Application encoderApplication;
|
||||||
private volatile int encoderChannels = 1;
|
private volatile int encoderChannels = 1;
|
||||||
private volatile int captureChannels = 1;
|
private volatile int captureChannels = 1;
|
||||||
|
|
||||||
@@ -68,7 +92,14 @@ public final class DesktopVoiceInput implements VoiceInput {
|
|||||||
private int hangover;
|
private int hangover;
|
||||||
private boolean lastTransmitting;
|
private boolean lastTransmitting;
|
||||||
|
|
||||||
public DesktopVoiceInput(Settings settings) {
|
// Ring of recent frames captured while the gate was shut.
|
||||||
|
private final float[][] preroll = new float[PREROLL_FRAMES][];
|
||||||
|
private int prerollTail;
|
||||||
|
private int prerollCount;
|
||||||
|
|
||||||
|
public CaptureVoiceInput(Settings settings, AudioIo io, OpusCodec opus) {
|
||||||
|
this.io = io;
|
||||||
|
this.opus = opus;
|
||||||
this.deviceName = settings.inputDevice;
|
this.deviceName = settings.inputDevice;
|
||||||
this.mode = settings.inputMode;
|
this.mode = settings.inputMode;
|
||||||
this.vadMode = settings.vadMode;
|
this.vadMode = settings.vadMode;
|
||||||
@@ -78,14 +109,26 @@ public final class DesktopVoiceInput implements VoiceInput {
|
|||||||
this.inputGain = settings.inputVolume;
|
this.inputGain = settings.inputVolume;
|
||||||
this.params = OpusParameters.from(settings);
|
this.params = OpusParameters.from(settings);
|
||||||
this.codec = codecFor(params);
|
this.codec = codecFor(params);
|
||||||
enhancer.setNoiseSuppression(settings.denoise);
|
processor.setNoiseSuppression(settings.denoise);
|
||||||
enhancer.setDenoiserLevel(settings.denoiserLevel);
|
processor.setSuppressionLevel(SuppressionLevel.fromDenoiserLevel(settings.denoiserLevel));
|
||||||
enhancer.setTypingAttenuation(settings.typingAttenuation);
|
processor.setTransientSuppression(settings.typingAttenuation);
|
||||||
enhancer.setAgc(settings.agc);
|
processor.setGainControl(settings.agc);
|
||||||
|
processor.setGainBoost(settings.agcBoostDb);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- live configuration (safe to call from the UI thread) ----
|
// ---- live configuration (safe to call from the UI thread) ----
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public synchronized void setInputDevice(String deviceId) {
|
||||||
|
String device = deviceId == null ? "" : deviceId;
|
||||||
|
if (device.equals(deviceName)) return;
|
||||||
|
deviceName = device;
|
||||||
|
if (running.get()) {
|
||||||
|
stop();
|
||||||
|
start();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public void setMode(Settings.InputMode mode) {
|
public void setMode(Settings.InputMode mode) {
|
||||||
this.mode = mode;
|
this.mode = mode;
|
||||||
}
|
}
|
||||||
@@ -112,19 +155,28 @@ public final class DesktopVoiceInput implements VoiceInput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void setNoiseSuppression(boolean enabled) {
|
public void setNoiseSuppression(boolean enabled) {
|
||||||
enhancer.setNoiseSuppression(enabled);
|
processor.setNoiseSuppression(enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setDenoiserLevel(double level) {
|
public void setDenoiserLevel(int level) {
|
||||||
enhancer.setDenoiserLevel(level);
|
processor.setSuppressionLevel(SuppressionLevel.fromDenoiserLevel(level));
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setTypingAttenuation(boolean enabled) {
|
public void setTypingAttenuation(boolean enabled) {
|
||||||
enhancer.setTypingAttenuation(enabled);
|
processor.setTransientSuppression(enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setAgc(boolean enabled) {
|
public void setAgc(boolean enabled) {
|
||||||
enhancer.setAgc(enabled);
|
processor.setGainControl(enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAgcBoost(int db) {
|
||||||
|
processor.setGainBoost(db);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void keyPressed() {
|
||||||
|
processor.keyPressed();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -140,8 +192,8 @@ public final class DesktopVoiceInput implements VoiceInput {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static int applicationFor(OpusParameters p) {
|
private static OpusCodec.Application applicationFor(OpusParameters p) {
|
||||||
return p.music ? Opus.OPUS_APPLICATION_AUDIO : Opus.OPUS_APPLICATION_VOIP;
|
return p.music ? OpusCodec.Application.AUDIO : OpusCodec.Application.VOIP;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static CodecType codecFor(OpusParameters p) {
|
private static CodecType codecFor(OpusParameters p) {
|
||||||
@@ -158,13 +210,13 @@ public final class DesktopVoiceInput implements VoiceInput {
|
|||||||
|
|
||||||
/** Creates or reconfigures the encoder to match {@code p}. Call under {@link #encoderLock}. */
|
/** Creates or reconfigures the encoder to match {@code p}. Call under {@link #encoderLock}. */
|
||||||
private void applyParams(OpusParameters p) {
|
private void applyParams(OpusParameters p) {
|
||||||
int application = applicationFor(p);
|
OpusCodec.Application application = applicationFor(p);
|
||||||
int channels = channelsFor(p);
|
int channels = channelsFor(p);
|
||||||
// Application (VOIP vs AUDIO) and channel count are fixed at creation, so
|
// Application (VOIP vs AUDIO) and channel count are fixed at creation, so
|
||||||
// switching voice<->music means building a new encoder.
|
// switching voice<->music means building a new encoder.
|
||||||
if (encoder == null || application != encoderApplication || channels != encoderChannels) {
|
if (encoder == null || application != encoderApplication || channels != encoderChannels) {
|
||||||
OpusEncoder replacement = new OpusEncoder(
|
OpusEncoder replacement = opus.createEncoder(
|
||||||
AudioDevices.SAMPLE_RATE, AudioDevices.FRAME_SIZE, channels, application);
|
VoiceFormat.SAMPLE_RATE, VoiceFormat.FRAME_SIZE, channels, application);
|
||||||
configureEncoder(replacement, p);
|
configureEncoder(replacement, p);
|
||||||
OpusEncoder previous = encoder;
|
OpusEncoder previous = encoder;
|
||||||
encoder = replacement;
|
encoder = replacement;
|
||||||
@@ -184,7 +236,7 @@ public final class DesktopVoiceInput implements VoiceInput {
|
|||||||
enc.setVbr(p.vbr);
|
enc.setVbr(p.vbr);
|
||||||
enc.setInbandFec(p.fec);
|
enc.setInbandFec(p.fec);
|
||||||
enc.setExpectedPacketLoss(p.expectedPacketLoss);
|
enc.setExpectedPacketLoss(p.expectedPacketLoss);
|
||||||
enc.setSignal(p.music ? Opus.OPUS_SIGNAL_MUSIC : Opus.OPUS_SIGNAL_VOICE);
|
enc.setSignal(p.music ? OpusEncoder.Signal.MUSIC : OpusEncoder.Signal.VOICE);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setLevelListener(Consumer<Double> l) {
|
public void setLevelListener(Consumer<Double> l) {
|
||||||
@@ -195,6 +247,11 @@ public final class DesktopVoiceInput implements VoiceInput {
|
|||||||
this.talkListener = l;
|
this.talkListener = l;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setMonitorListener(AudioFrameListener l) {
|
||||||
|
this.monitorListener = l;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void setMutedTalkListener(Runnable l) {
|
public void setMutedTalkListener(Runnable l) {
|
||||||
this.mutedTalkListener = l;
|
this.mutedTalkListener = l;
|
||||||
@@ -208,12 +265,22 @@ public final class DesktopVoiceInput implements VoiceInput {
|
|||||||
this.muted.set(m);
|
this.muted.set(m);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setLocalMuted(boolean m) {
|
||||||
|
this.localMuted.set(m);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isLocalMuted() {
|
||||||
|
return localMuted.get();
|
||||||
|
}
|
||||||
|
|
||||||
// ---- lifecycle ----
|
// ---- lifecycle ----
|
||||||
|
|
||||||
public synchronized void start() {
|
public synchronized void start() {
|
||||||
if (running.get()) return;
|
if (running.get()) return;
|
||||||
try {
|
try {
|
||||||
line = AudioDevices.openCapture(deviceName, AudioDevices.MAX_CHANNELS);
|
line = io.openCapture(deviceName, VoiceFormat.MAX_CHANNELS);
|
||||||
captureChannels = line.channels();
|
captureChannels = line.channels();
|
||||||
synchronized (encoderLock) {
|
synchronized (encoderLock) {
|
||||||
applyParams(params);
|
applyParams(params);
|
||||||
@@ -223,7 +290,7 @@ public final class DesktopVoiceInput implements VoiceInput {
|
|||||||
throw new RuntimeException("Could not start microphone: " + t.getMessage(), t);
|
throw new RuntimeException("Could not start microphone: " + t.getMessage(), t);
|
||||||
}
|
}
|
||||||
speechDetector.reset();
|
speechDetector.reset();
|
||||||
enhancer.reset();
|
processor.reset();
|
||||||
running.set(true);
|
running.set(true);
|
||||||
captureThread = new Thread(this::captureLoop, "ts3j-mic-capture");
|
captureThread = new Thread(this::captureLoop, "ts3j-mic-capture");
|
||||||
captureThread.setDaemon(true);
|
captureThread.setDaemon(true);
|
||||||
@@ -232,9 +299,20 @@ public final class DesktopVoiceInput implements VoiceInput {
|
|||||||
|
|
||||||
public synchronized void stop() {
|
public synchronized void stop() {
|
||||||
running.set(false);
|
running.set(false);
|
||||||
if (captureThread != null) {
|
Thread capture = captureThread;
|
||||||
captureThread.interrupt();
|
|
||||||
captureThread = null;
|
captureThread = null;
|
||||||
|
if (line != null) {
|
||||||
|
line.close(); // wakes the capture loop's blocking read
|
||||||
|
line = null;
|
||||||
|
}
|
||||||
|
if (capture != null) {
|
||||||
|
// Let the loop finish its frame, so it cannot reopen the gate or overlap a restart.
|
||||||
|
capture.interrupt();
|
||||||
|
try {
|
||||||
|
capture.join(STOP_TIMEOUT_MS);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
cleanup();
|
cleanup();
|
||||||
queue.clear();
|
queue.clear();
|
||||||
@@ -253,14 +331,14 @@ public final class DesktopVoiceInput implements VoiceInput {
|
|||||||
} catch (Exception ignored) {
|
} catch (Exception ignored) {
|
||||||
}
|
}
|
||||||
encoder = null;
|
encoder = null;
|
||||||
encoderApplication = -1;
|
encoderApplication = null;
|
||||||
appliedParams = null;
|
appliedParams = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void captureLoop() {
|
private void captureLoop() {
|
||||||
final int frameSamples = AudioDevices.FRAME_SIZE;
|
final int frameSamples = VoiceFormat.FRAME_SIZE;
|
||||||
final int channels = captureChannels;
|
final int channels = captureChannels;
|
||||||
final byte[] buf = new byte[frameSamples * 2 * channels];
|
final byte[] buf = new byte[frameSamples * 2 * channels];
|
||||||
final float[] pcm = new float[frameSamples * channels]; // interleaved capture
|
final float[] pcm = new float[frameSamples * channels]; // interleaved capture
|
||||||
@@ -300,41 +378,92 @@ public final class DesktopVoiceInput implements VoiceInput {
|
|||||||
if (p != appliedParams && encoder != null) applyParams(p);
|
if (p != appliedParams && encoder != null) applyParams(p);
|
||||||
boolean stereo = encoderChannels == 2;
|
boolean stereo = encoderChannels == 2;
|
||||||
|
|
||||||
// Denoise / typing attenuation are voice-chain stages: they feed the
|
// As in TS3, the speech detector judges the raw microphone signal, while
|
||||||
// level meter, VAD and encoder alike on the mono path. A stereo (music)
|
// the level meter and volume gate see the processed one.
|
||||||
// stream bypasses them and is transmitted as captured.
|
double probability = usesSpeechDetector()
|
||||||
if (!stereo) enhancer.process(mono, frameSamples);
|
? speechDetector.process(mono, frameSamples) : 0.0;
|
||||||
|
|
||||||
double sumSq = 0;
|
// Processing is a voice-chain stage on the mono path. A stereo (music)
|
||||||
for (int i = 0; i < frameSamples; i++) {
|
// stream bypasses it and is transmitted as captured.
|
||||||
sumSq += (double) mono[i] * mono[i];
|
if (!stereo) processor.process(mono, frameSamples);
|
||||||
}
|
|
||||||
double rms = Math.sqrt(sumSq / frameSamples);
|
double db = InputLevel.toDb(mono, frameSamples);
|
||||||
double db = (rms <= 1e-9) ? -100.0 : 20.0 * Math.log10(rms);
|
|
||||||
Consumer<Double> ll = levelListener;
|
Consumer<Double> ll = levelListener;
|
||||||
if (ll != null) ll.accept(db);
|
if (ll != null) ll.accept(db);
|
||||||
|
|
||||||
boolean open = decideGate(db, mono);
|
boolean wasOpen = transmitting.get();
|
||||||
|
boolean open = decideGate(db, probability);
|
||||||
setTransmitting(open);
|
setTransmitting(open);
|
||||||
|
|
||||||
if (open && !muted.get() && encoder != null) {
|
if (open && !muted.get() && encoder != null) {
|
||||||
try {
|
try {
|
||||||
packet = encoder.encode(stereo ? pcm : mono);
|
// On the opening edge, send the buffered lead-in first so the
|
||||||
|
// word's onset isn't lost to the frame that detected it.
|
||||||
|
if (!wasOpen) {
|
||||||
|
flushPreroll(stereo);
|
||||||
|
}
|
||||||
|
float[] sent = stereo ? pcm : mono;
|
||||||
|
packet = encoder.encode(sent);
|
||||||
|
monitor(sent, stereo ? encoderChannels : 1);
|
||||||
} catch (Exception ignored) {
|
} catch (Exception ignored) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (!open) {
|
||||||
|
rememberForPreroll(stereo ? pcm : mono);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (packet != null && packet.length > 0) {
|
if (packet != null && packet.length > 0) {
|
||||||
queue.offer(packet);
|
offer(packet);
|
||||||
// Guard against unbounded growth if the network stalls.
|
|
||||||
while (queue.size() > 10) queue.poll();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean decideGate(double db, float[] pcm) {
|
private void offer(byte[] packet) {
|
||||||
if (muted.get()) {
|
queue.offer(packet);
|
||||||
|
// Guard against unbounded growth if the network stalls.
|
||||||
|
while (queue.size() > 10) queue.poll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keeps the most recent frames while the gate is shut, for {@link #flushPreroll}. */
|
||||||
|
private void rememberForPreroll(float[] frame) {
|
||||||
|
float[] slot = preroll[prerollTail];
|
||||||
|
if (slot == null || slot.length != frame.length) {
|
||||||
|
slot = new float[frame.length];
|
||||||
|
preroll[prerollTail] = slot;
|
||||||
|
}
|
||||||
|
System.arraycopy(frame, 0, slot, 0, frame.length);
|
||||||
|
prerollTail = (prerollTail + 1) % PREROLL_FRAMES;
|
||||||
|
if (prerollCount < PREROLL_FRAMES) prerollCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Encodes and queues the buffered lead-in, oldest first. Call under {@link #encoderLock}. */
|
||||||
|
private void flushPreroll(boolean stereo) {
|
||||||
|
int expected = stereo ? VoiceFormat.FRAME_SIZE * encoderChannels : VoiceFormat.FRAME_SIZE;
|
||||||
|
for (int i = 0; i < prerollCount; i++) {
|
||||||
|
int idx = (prerollTail - prerollCount + i + PREROLL_FRAMES) % PREROLL_FRAMES;
|
||||||
|
float[] frame = preroll[idx];
|
||||||
|
// A channel-count change between capture and flush invalidates the buffer.
|
||||||
|
if (frame == null || frame.length != expected) continue;
|
||||||
|
try {
|
||||||
|
byte[] p = encoder.encode(frame);
|
||||||
|
if (p != null && p.length > 0) offer(p);
|
||||||
|
monitor(frame, stereo ? encoderChannels : 1);
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prerollCount = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the gate may consult the speech detector, so it must follow the signal. */
|
||||||
|
private boolean usesSpeechDetector() {
|
||||||
|
if (vadMode == Settings.VadMode.VOLUME_GATE) return false;
|
||||||
|
Settings.InputMode m = mode;
|
||||||
|
return m == Settings.InputMode.VOICE_ACTIVATION || (m == Settings.InputMode.PUSH_TO_TALK && vadOverPtt);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean decideGate(double db, double probability) {
|
||||||
|
if (muted.get() || localMuted.get()) {
|
||||||
hangover = 0;
|
hangover = 0;
|
||||||
detectMutedSpeech(db);
|
detectMutedSpeech(db);
|
||||||
return false;
|
return false;
|
||||||
@@ -345,32 +474,25 @@ public final class DesktopVoiceInput implements VoiceInput {
|
|||||||
return true;
|
return true;
|
||||||
case PUSH_TO_TALK:
|
case PUSH_TO_TALK:
|
||||||
if (pttDown.get()) return true;
|
if (pttDown.get()) return true;
|
||||||
return vadOverPtt && voiceActivated(db, pcm);
|
return vadOverPtt && voiceActivated(db, probability);
|
||||||
case VOICE_ACTIVATION:
|
case VOICE_ACTIVATION:
|
||||||
default:
|
default:
|
||||||
return voiceActivated(db, pcm);
|
return voiceActivated(db, probability);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Applies the selected VAD mode (volume gate, speech probability, or both) with
|
* Applies the selected VAD mode with a hangover so trailing syllables aren't clipped.
|
||||||
* a deactivation-delay hangover so trailing syllables aren't clipped.
|
*
|
||||||
|
* <p>The modes match the TS3 client's: the volume gate alone, the speech detector
|
||||||
|
* alone, or both together. Volume Gate skips the detector entirely, as TS3 does.
|
||||||
*/
|
*/
|
||||||
private boolean voiceActivated(double db, float[] pcm) {
|
private boolean voiceActivated(double db, double probability) {
|
||||||
boolean detected;
|
boolean detected = switch (vadMode) {
|
||||||
switch (vadMode) {
|
case VOLUME_GATE -> db >= thresholdDb;
|
||||||
case VOLUME_GATE:
|
case AUTOMATIC -> probability >= speechThreshold;
|
||||||
detected = db >= thresholdDb;
|
case HYBRID -> db >= thresholdDb && probability >= speechThreshold;
|
||||||
break;
|
};
|
||||||
case AUTOMATIC:
|
|
||||||
detected = speechDetector.process(pcm) >= speechThreshold;
|
|
||||||
break;
|
|
||||||
case HYBRID:
|
|
||||||
default:
|
|
||||||
double probability = speechDetector.process(pcm);
|
|
||||||
detected = db >= thresholdDb && probability >= speechThreshold;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (detected) {
|
if (detected) {
|
||||||
hangover = HANGOVER_FRAMES;
|
hangover = HANGOVER_FRAMES;
|
||||||
return true;
|
return true;
|
||||||
@@ -395,6 +517,12 @@ public final class DesktopVoiceInput implements VoiceInput {
|
|||||||
mutedTalking = talking;
|
mutedTalking = talking;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Hands a transmitted frame to the monitor, if one is attached. */
|
||||||
|
private void monitor(float[] frame, int channels) {
|
||||||
|
AudioFrameListener l = monitorListener;
|
||||||
|
if (l != null) l.onFrame(frame, channels);
|
||||||
|
}
|
||||||
|
|
||||||
private void setTransmitting(boolean t) {
|
private void setTransmitting(boolean t) {
|
||||||
transmitting.set(t);
|
transmitting.set(t);
|
||||||
if (t != lastTransmitting) {
|
if (t != lastTransmitting) {
|
||||||
@@ -4,18 +4,18 @@ package com.ts3client.audio;
|
|||||||
* In-place iterative radix-2 Cooley–Tukey FFT shared by the voice DSP stages.
|
* In-place iterative radix-2 Cooley–Tukey FFT shared by the voice DSP stages.
|
||||||
* All arrays must have a power-of-two length. Pure math, no platform dependencies.
|
* All arrays must have a power-of-two length. Pure math, no platform dependencies.
|
||||||
*/
|
*/
|
||||||
final class Fft {
|
public final class Fft {
|
||||||
|
|
||||||
private Fft() {
|
private Fft() {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Forward transform (unnormalised). */
|
/** Forward transform (unnormalised). */
|
||||||
static void forward(double[] re, double[] im) {
|
public static void forward(double[] re, double[] im) {
|
||||||
transform(re, im, false);
|
transform(re, im, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Inverse transform, normalised by {@code 1/n} so it inverts {@link #forward}. */
|
/** Inverse transform, normalised by {@code 1/n} so it inverts {@link #forward}. */
|
||||||
static void inverse(double[] re, double[] im) {
|
public static void inverse(double[] re, double[] im) {
|
||||||
transform(re, im, true);
|
transform(re, im, true);
|
||||||
int n = re.length;
|
int n = re.length;
|
||||||
double scale = 1.0 / n;
|
double scale = 1.0 / n;
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
package com.ts3client.audio;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Second-order Butterworth high-pass filter (RBJ biquad, transposed direct form II).
|
|
||||||
* Mirrors WebRTC APM's {@code high_pass_filter} stage — an always-on part of the
|
|
||||||
* capture chain that removes DC offset, mains hum and low-frequency rumble below the
|
|
||||||
* speech band before noise suppression sees the signal.
|
|
||||||
*/
|
|
||||||
final class HighPassFilter {
|
|
||||||
|
|
||||||
private static final double CUTOFF_HZ = 80.0; // WebRTC APM high-pass cutoff
|
|
||||||
|
|
||||||
private final double b0, b1, b2, a1, a2;
|
|
||||||
private double z1, z2;
|
|
||||||
|
|
||||||
HighPassFilter(int sampleRate) {
|
|
||||||
double w0 = 2 * Math.PI * CUTOFF_HZ / sampleRate;
|
|
||||||
double cos = Math.cos(w0);
|
|
||||||
double alpha = Math.sin(w0) / Math.sqrt(2.0); // Q = 1/sqrt(2) (Butterworth)
|
|
||||||
double a0 = 1 + alpha;
|
|
||||||
this.b0 = (1 + cos) / 2 / a0;
|
|
||||||
this.b1 = -(1 + cos) / a0;
|
|
||||||
this.b2 = (1 + cos) / 2 / a0;
|
|
||||||
this.a1 = -2 * cos / a0;
|
|
||||||
this.a2 = (1 - alpha) / a0;
|
|
||||||
}
|
|
||||||
|
|
||||||
void reset() {
|
|
||||||
z1 = 0;
|
|
||||||
z2 = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Filters one mono frame in place. */
|
|
||||||
void process(float[] buf, int len) {
|
|
||||||
for (int i = 0; i < len; i++) {
|
|
||||||
double x = buf[i];
|
|
||||||
double y = b0 * x + z1;
|
|
||||||
z1 = b1 * x - a1 * y + z2;
|
|
||||||
z2 = b2 * x - a2 * y;
|
|
||||||
buf[i] = (float) y;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package com.ts3client.audio;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capture level on the scale the TeamSpeak 3 client puts on its voice-activation slider.
|
||||||
|
*
|
||||||
|
* <p>TS3 stores {@code voiceactivation_level} in decibels but compares powers: its setter
|
||||||
|
* converts the slider value with {@code 10^(dB/10) * 0.0095} and its getter reports a
|
||||||
|
* measured power back with {@code 10*log10(power / 0.0095)}. The same reference constant is
|
||||||
|
* used here so a threshold of −40 means the same thing it does in TeamSpeak.
|
||||||
|
*
|
||||||
|
* <p>The resulting scale runs from {@link #MIN_DB} to {@link #MAX_DB}, with 0 dB
|
||||||
|
* corresponding to roughly −20 dBFS and the default −40 to about
|
||||||
|
* −60 dBFS.
|
||||||
|
*/
|
||||||
|
public final class InputLevel {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mean-square level that reads as 0 dB, taken from the client's own dB conversion.
|
||||||
|
*/
|
||||||
|
private static final double REFERENCE_POWER = 0.0095;
|
||||||
|
|
||||||
|
/** Lower end of the slider range. */
|
||||||
|
public static final double MIN_DB = -50.0;
|
||||||
|
|
||||||
|
/** Upper end of the slider range. */
|
||||||
|
public static final double MAX_DB = 50.0;
|
||||||
|
|
||||||
|
/** Reported when the frame is digital silence. */
|
||||||
|
public static final double SILENCE_DB = MIN_DB;
|
||||||
|
|
||||||
|
private InputLevel() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Level of {@code length} mono samples in [-1, 1], in dB on the scale above. */
|
||||||
|
public static double toDb(float[] frame, int length) {
|
||||||
|
if (length <= 0) {
|
||||||
|
return SILENCE_DB;
|
||||||
|
}
|
||||||
|
double sumSq = 0;
|
||||||
|
for (int i = 0; i < length; i++) {
|
||||||
|
sumSq += (double) frame[i] * frame[i];
|
||||||
|
}
|
||||||
|
return powerToDb(sumSq / length);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Converts a mean-square power to the slider's dB scale. */
|
||||||
|
public static double powerToDb(double power) {
|
||||||
|
if (power <= 0) {
|
||||||
|
return SILENCE_DB;
|
||||||
|
}
|
||||||
|
return Math.max(SILENCE_DB, 10.0 * Math.log10(power / REFERENCE_POWER));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inverse of {@link #powerToDb}, matching the client's threshold conversion. */
|
||||||
|
public static double dbToPower(double db) {
|
||||||
|
return Math.pow(10.0, db / 10.0) * REFERENCE_POWER;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,584 @@
|
|||||||
|
package com.ts3client.audio;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adaptive jitter buffer for incoming voice packets.
|
||||||
|
*
|
||||||
|
* <p>This is a port of the jitter buffer TeamSpeak itself uses: the official client links
|
||||||
|
* libspeex's {@code libspeex/jitter.c} verbatim (its diagnostics are still in the shipped
|
||||||
|
* binary) and drives it through the wrapper described in {@link #forTeamSpeakVoice}. The
|
||||||
|
* algorithm is what makes a voice stream survive a bad network: packets are reordered by
|
||||||
|
* timestamp, losses are reported so the codec can conceal them, and — most importantly —
|
||||||
|
* the playback delay continuously re-tunes itself to the measured jitter instead of being
|
||||||
|
* a fixed guess.
|
||||||
|
*
|
||||||
|
* <h2>How the delay adapts</h2>
|
||||||
|
* Every packet's arrival is recorded as a <em>timing</em>: how early (positive) or late
|
||||||
|
* (negative) it was relative to the moment it was needed. Timings are kept in three
|
||||||
|
* rotating sub-windows, each holding only the {@value #MAX_TIMINGS} <em>latest</em> arrivals
|
||||||
|
* it saw, which makes the set a cheap running estimate of the distribution's late tail.
|
||||||
|
* {@link #computeOptimalDelay()} then walks the {@value #TOP_DELAY} latest timings and picks
|
||||||
|
* the delay minimising {@code delay + lateCost * (packets that would then be late)}. The
|
||||||
|
* result is applied a whole {@link #delayStep} at a time, by dropping a frame to shrink the
|
||||||
|
* delay or inserting a concealed one to grow it, so the correction is inaudible.
|
||||||
|
*
|
||||||
|
* <h2>Timestamp units</h2>
|
||||||
|
* Timestamps and spans are in caller-defined ticks; the buffer never converts them to time.
|
||||||
|
* All comparisons are wrap-safe, so a tick counter may overflow freely.
|
||||||
|
*
|
||||||
|
* <p>Not thread-safe: every call must be externally serialised.
|
||||||
|
*/
|
||||||
|
public final class JitterBuffer {
|
||||||
|
|
||||||
|
/** Outcome of a {@link #get} call. */
|
||||||
|
public enum Status {
|
||||||
|
/** A packet was available; see {@link #payload()}. */
|
||||||
|
OK,
|
||||||
|
/** Nothing available — the caller should conceal {@link #span()} ticks of loss. */
|
||||||
|
MISSING,
|
||||||
|
/** The buffer is growing its delay; the caller should insert {@link #span()} ticks. */
|
||||||
|
INSERTION
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How many packets may be held at once, awaiting playback or reordering. */
|
||||||
|
private static final int MAX_PACKETS = 200;
|
||||||
|
|
||||||
|
/** Timings kept per sub-window. Only the latest arrivals are retained. */
|
||||||
|
private static final int MAX_TIMINGS = 40;
|
||||||
|
|
||||||
|
/** Rotating sub-windows the timings are spread over. */
|
||||||
|
private static final int SUB_WINDOWS = 3;
|
||||||
|
|
||||||
|
/** How many of the latest timings the delay search considers. */
|
||||||
|
private static final int TOP_DELAY = 40;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The latest arrival timings seen within one sub-window, sorted ascending.
|
||||||
|
*
|
||||||
|
* <p>Only the {@value #MAX_TIMINGS} latest are kept: an arrival too early to displace
|
||||||
|
* any of them is counted but not stored, since the delay search only ever looks at the
|
||||||
|
* late tail.
|
||||||
|
*/
|
||||||
|
private static final class TimingWindow {
|
||||||
|
final int[] timing = new int[MAX_TIMINGS];
|
||||||
|
/** Timings actually stored. */
|
||||||
|
int filled;
|
||||||
|
/** Arrivals seen, including those too early to store. */
|
||||||
|
int seen;
|
||||||
|
|
||||||
|
void clear() {
|
||||||
|
filled = 0;
|
||||||
|
seen = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void add(int value) {
|
||||||
|
if (filled >= MAX_TIMINGS && value >= timing[filled - 1]) {
|
||||||
|
seen++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int pos = 0;
|
||||||
|
while (pos < filled && value >= timing[pos]) pos++;
|
||||||
|
|
||||||
|
if (pos < filled) {
|
||||||
|
int moveSize = filled - pos;
|
||||||
|
if (filled == MAX_TIMINGS) moveSize--;
|
||||||
|
System.arraycopy(timing, pos, timing, pos + 1, moveSize);
|
||||||
|
}
|
||||||
|
timing[pos] = value;
|
||||||
|
|
||||||
|
seen++;
|
||||||
|
if (filled < MAX_TIMINGS) filled++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final byte[][] data = new byte[MAX_PACKETS][];
|
||||||
|
private final int[] timestamp = new int[MAX_PACKETS];
|
||||||
|
private final int[] span = new int[MAX_PACKETS];
|
||||||
|
/** Value of {@link #nextStop} when the packet arrived, or 0 if that is unknown. */
|
||||||
|
private final int[] arrival = new int[MAX_PACKETS];
|
||||||
|
|
||||||
|
private final TimingWindow[] windows = new TimingWindow[SUB_WINDOWS];
|
||||||
|
/** {@link #windows} in age order, newest first; rotated in place. */
|
||||||
|
private final TimingWindow[] byAge = new TimingWindow[SUB_WINDOWS];
|
||||||
|
|
||||||
|
private final int delayStep;
|
||||||
|
private final int concealmentSize;
|
||||||
|
|
||||||
|
private int bufferMargin;
|
||||||
|
private int maxLateRate;
|
||||||
|
private int lateCost;
|
||||||
|
private int windowSize;
|
||||||
|
private int subWindowSize;
|
||||||
|
|
||||||
|
/** Playback position: the timestamp the next {@link #get} wants. */
|
||||||
|
private int pointerTimestamp;
|
||||||
|
/** Timestamp playback will have reached by the time the next packet is needed. */
|
||||||
|
private int nextStop;
|
||||||
|
/** Ticks handed out by the last {@link #get} beyond what was asked for. */
|
||||||
|
private int buffered;
|
||||||
|
/** Ticks of concealment {@link #get} has been told to insert before reading on. */
|
||||||
|
private int interpolationRequested;
|
||||||
|
/** Consecutive {@link #get} calls that found nothing. */
|
||||||
|
private int lostCount;
|
||||||
|
/** True until the first packet arrives and defines the timeline. */
|
||||||
|
private boolean resetState;
|
||||||
|
/** Self-tuned cost of a late packet, used when {@link #lateCost} is 0. */
|
||||||
|
private int autoTradeoff;
|
||||||
|
|
||||||
|
// Result of the most recent get(), kept here so the per-frame path allocates nothing.
|
||||||
|
private byte[] resultPayload;
|
||||||
|
private int resultTimestamp;
|
||||||
|
private int resultSpan;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param stepSize granularity of delay changes, and the span of one concealed frame;
|
||||||
|
* normally the span of a single packet
|
||||||
|
* @param margin extra delay held on top of the measured optimum
|
||||||
|
* @param maxLateRate percentage of packets allowed to arrive too late, which also sets
|
||||||
|
* how long a window the delay is estimated over
|
||||||
|
* @param lateCost cost of one late packet relative to delay, or 0 to self-tune
|
||||||
|
*/
|
||||||
|
public JitterBuffer(int stepSize, int margin, int maxLateRate, int lateCost) {
|
||||||
|
this.delayStep = stepSize;
|
||||||
|
this.concealmentSize = stepSize;
|
||||||
|
this.bufferMargin = margin;
|
||||||
|
this.lateCost = lateCost;
|
||||||
|
setMaxLateRate(maxLateRate);
|
||||||
|
|
||||||
|
for (int i = 0; i < SUB_WINDOWS; i++) {
|
||||||
|
windows[i] = new TimingWindow();
|
||||||
|
byAge[i] = windows[i];
|
||||||
|
}
|
||||||
|
reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A buffer configured the way the official TeamSpeak client configures its own.
|
||||||
|
*
|
||||||
|
* <p>Recovered from {@code ts3client_linux_amd64} (3.6.x): it calls
|
||||||
|
* {@code jitter_buffer_init(60)} and then sets margin 60 and max late rate 1, pulling
|
||||||
|
* one 60-tick frame per {@code jitter_buffer_get}. One tick is therefore a sixtieth of
|
||||||
|
* a 20 ms voice frame, so the delay moves in whole-frame steps, one frame of slack
|
||||||
|
* is held on top of the measured optimum, and the target is for no more than 1% of
|
||||||
|
* packets to arrive late — a deliberately conservative setting that estimates over a
|
||||||
|
* ~1.3 s window and is the reason TeamSpeak rides out network trouble smoothly.
|
||||||
|
*
|
||||||
|
* @param ticksPerFrame ticks spanned by one voice frame; see {@link #TEAMSPEAK_TICKS_PER_FRAME}
|
||||||
|
*/
|
||||||
|
public static JitterBuffer forTeamSpeakVoice(int ticksPerFrame) {
|
||||||
|
return new JitterBuffer(ticksPerFrame, ticksPerFrame, 1, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ticks per voice frame in TeamSpeak's own timebase, i.e. a 20 ms frame in 1/3 ms units. */
|
||||||
|
public static final int TEAMSPEAK_TICKS_PER_FRAME = 60;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the share of packets allowed to arrive too late, as a percentage.
|
||||||
|
*
|
||||||
|
* <p>This doubles as the estimator's time constant: a stricter rate needs a
|
||||||
|
* proportionally longer window to observe that many late packets.
|
||||||
|
*/
|
||||||
|
public void setMaxLateRate(int rate) {
|
||||||
|
this.maxLateRate = Math.max(1, rate);
|
||||||
|
this.windowSize = 100 * TOP_DELAY / this.maxLateRate;
|
||||||
|
this.subWindowSize = this.windowSize / SUB_WINDOWS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sets the extra delay held on top of the measured optimum. */
|
||||||
|
public void setMargin(int margin) {
|
||||||
|
this.bufferMargin = margin;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int margin() {
|
||||||
|
return bufferMargin;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ticks currently held ahead of the playback position, i.e. the live buffering depth. */
|
||||||
|
public int bufferedSpan() {
|
||||||
|
int total = 0;
|
||||||
|
for (int i = 0; i < MAX_PACKETS; i++) {
|
||||||
|
if (data[i] != null && ge(timestamp[i], pointerTimestamp)) total += span[i];
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when no packet is held and playback has nothing left to drain. */
|
||||||
|
public boolean isEmpty() {
|
||||||
|
for (int i = 0; i < MAX_PACKETS; i++) {
|
||||||
|
if (data[i] != null) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drops every held packet and forgets both the timeline and the network estimate.
|
||||||
|
*
|
||||||
|
* <p>Reserved for the cases where a speaker's position genuinely stopped meaning
|
||||||
|
* anything — a new speaker, or a silence long enough that the sequence may have been
|
||||||
|
* recycled. Notably <em>not</em> something to do between talk bursts: the timeline stays
|
||||||
|
* valid across a pause because the sender stops numbering while the receiver stops
|
||||||
|
* playing, and the estimate needs far longer than one burst to be worth anything.
|
||||||
|
*/
|
||||||
|
public void reset() {
|
||||||
|
for (int i = 0; i < MAX_PACKETS; i++) data[i] = null;
|
||||||
|
pointerTimestamp = 0;
|
||||||
|
nextStop = 0;
|
||||||
|
resetState = true;
|
||||||
|
lostCount = 0;
|
||||||
|
buffered = 0;
|
||||||
|
interpolationRequested = 0;
|
||||||
|
autoTradeoff = 32000;
|
||||||
|
for (TimingWindow w : windows) w.clear();
|
||||||
|
System.arraycopy(windows, 0, byAge, 0, SUB_WINDOWS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Offers an arrived packet to the buffer.
|
||||||
|
*
|
||||||
|
* @param payload the packet, retained by reference until played or dropped
|
||||||
|
* @param timestamp the packet's position on the sender's timeline, in ticks
|
||||||
|
* @param span how many ticks the packet covers
|
||||||
|
*/
|
||||||
|
public void put(byte[] payload, int timestamp, int span) {
|
||||||
|
// Drop anything playback has already moved past.
|
||||||
|
if (!resetState) {
|
||||||
|
for (int i = 0; i < MAX_PACKETS; i++) {
|
||||||
|
if (data[i] != null && le(this.timestamp[i] + this.span[i], pointerTimestamp)) {
|
||||||
|
data[i] = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A packet that missed its slot still tells us how far behind the clock we are.
|
||||||
|
boolean late = !resetState && lt(timestamp, nextStop);
|
||||||
|
if (late) {
|
||||||
|
updateTimings(timestamp - nextStop - bufferMargin);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The consumer has stopped fetching; don't sit on packets it will never take.
|
||||||
|
if (lostCount > 20) reset();
|
||||||
|
|
||||||
|
// Ignore packets so late that even concealment has moved past them.
|
||||||
|
if (!resetState && !ge(timestamp + span + delayStep, pointerTimestamp)) return;
|
||||||
|
|
||||||
|
int slot = -1;
|
||||||
|
for (int i = 0; i < MAX_PACKETS; i++) {
|
||||||
|
if (data[i] == null) {
|
||||||
|
slot = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (slot < 0) {
|
||||||
|
// Full: make room by discarding the oldest packet.
|
||||||
|
slot = 0;
|
||||||
|
int earliest = this.timestamp[0];
|
||||||
|
for (int i = 1; i < MAX_PACKETS; i++) {
|
||||||
|
if (lt(this.timestamp[i], earliest)) {
|
||||||
|
earliest = this.timestamp[i];
|
||||||
|
slot = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data[slot] = payload;
|
||||||
|
this.timestamp[slot] = timestamp;
|
||||||
|
this.span[slot] = span;
|
||||||
|
this.arrival[slot] = (resetState || late) ? 0 : nextStop;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Takes the next {@code desiredSpan} ticks of audio.
|
||||||
|
*
|
||||||
|
* <p>On {@link Status#OK} the packet is in {@link #payload()}. On {@link Status#MISSING}
|
||||||
|
* or {@link Status#INSERTION} the caller must produce {@link #span()} ticks of
|
||||||
|
* concealment instead — for Opus that means decoding a null packet.
|
||||||
|
*/
|
||||||
|
public Status get(int desiredSpan) {
|
||||||
|
resultPayload = null;
|
||||||
|
|
||||||
|
// The first packet to arrive defines where the timeline starts.
|
||||||
|
if (resetState) {
|
||||||
|
int oldest = 0;
|
||||||
|
boolean found = false;
|
||||||
|
for (int i = 0; i < MAX_PACKETS; i++) {
|
||||||
|
if (data[i] != null && (!found || lt(timestamp[i], oldest))) {
|
||||||
|
oldest = timestamp[i];
|
||||||
|
found = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!found) {
|
||||||
|
resultTimestamp = 0;
|
||||||
|
resultSpan = interpolationRequested;
|
||||||
|
return Status.MISSING;
|
||||||
|
}
|
||||||
|
resetState = false;
|
||||||
|
pointerTimestamp = oldest;
|
||||||
|
nextStop = oldest;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A delay increase decided during the last tick(): pad before reading on.
|
||||||
|
if (interpolationRequested != 0) {
|
||||||
|
resultTimestamp = pointerTimestamp;
|
||||||
|
resultSpan = interpolationRequested;
|
||||||
|
pointerTimestamp += interpolationRequested;
|
||||||
|
interpolationRequested = 0;
|
||||||
|
buffered = resultSpan - desiredSpan;
|
||||||
|
return Status.INSERTION;
|
||||||
|
}
|
||||||
|
|
||||||
|
int found = findPacket(desiredSpan);
|
||||||
|
if (found >= 0) {
|
||||||
|
lostCount = 0;
|
||||||
|
|
||||||
|
// An on-time arrival: record how much slack it had.
|
||||||
|
if (arrival[found] != 0) {
|
||||||
|
updateTimings(timestamp[found] - arrival[found] - bufferMargin);
|
||||||
|
}
|
||||||
|
|
||||||
|
resultPayload = data[found];
|
||||||
|
resultTimestamp = timestamp[found];
|
||||||
|
resultSpan = span[found];
|
||||||
|
data[found] = null;
|
||||||
|
|
||||||
|
pointerTimestamp = resultTimestamp + resultSpan;
|
||||||
|
buffered = resultSpan - desiredSpan;
|
||||||
|
return Status.OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
lostCount++;
|
||||||
|
|
||||||
|
int optimal = computeOptimalDelay();
|
||||||
|
if (optimal < 0) {
|
||||||
|
// The measured jitter wants a deeper buffer: insert without advancing playback.
|
||||||
|
shiftTimings(-optimal);
|
||||||
|
resultTimestamp = pointerTimestamp;
|
||||||
|
resultSpan = -optimal;
|
||||||
|
buffered = resultSpan - desiredSpan;
|
||||||
|
return Status.INSERTION;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ordinary loss: conceal a whole number of frames and move on.
|
||||||
|
int concealed = roundDown(desiredSpan, concealmentSize);
|
||||||
|
resultTimestamp = pointerTimestamp;
|
||||||
|
resultSpan = concealed;
|
||||||
|
pointerTimestamp += concealed;
|
||||||
|
buffered = concealed - desiredSpan;
|
||||||
|
return Status.MISSING;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The held packet sitting at the playback position, without consuming it.
|
||||||
|
*
|
||||||
|
* <p>After a {@link Status#MISSING} the position has already stepped past the gap, so
|
||||||
|
* this is the packet immediately following the lost one — the one carrying the FEC copy
|
||||||
|
* that can rebuild it. {@code null} when that packet is missing too.
|
||||||
|
*/
|
||||||
|
public byte[] nextPayload() {
|
||||||
|
for (int i = 0; i < MAX_PACKETS; i++) {
|
||||||
|
if (data[i] != null && timestamp[i] == pointerTimestamp) return data[i];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The timestamp the next {@link #get} will play from. */
|
||||||
|
public int position() {
|
||||||
|
return pointerTimestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whole frames currently held, given a frame spans {@code frameSpan} ticks. */
|
||||||
|
public int heldFrames(int frameSpan) {
|
||||||
|
int held = 0;
|
||||||
|
for (int i = 0; i < MAX_PACKETS; i++) {
|
||||||
|
if (data[i] != null) held += Math.max(1, span[i] / frameSpan);
|
||||||
|
}
|
||||||
|
return held;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The packet returned by the last {@link #get}, or {@code null} if it was not {@link Status#OK}. */
|
||||||
|
public byte[] payload() {
|
||||||
|
return resultPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ticks covered by the last {@link #get}, whether a real packet or concealment. */
|
||||||
|
public int span() {
|
||||||
|
return resultSpan;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Timestamp of the last {@link #get}. */
|
||||||
|
public int timestamp() {
|
||||||
|
return resultTimestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Advances the playback clock by one frame, after the audio from {@link #get} has been
|
||||||
|
* handed to the output. This is also where the delay is re-tuned.
|
||||||
|
*/
|
||||||
|
public void tick() {
|
||||||
|
updateDelay();
|
||||||
|
nextStop = pointerTimestamp - Math.max(0, buffered);
|
||||||
|
buffered = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Advances the playback clock by one frame that the caller played from audio it still had
|
||||||
|
* from an earlier {@link #get}, instead of calling {@link #get} and {@link #tick}.
|
||||||
|
*
|
||||||
|
* @param remaining ticks of that audio still left after this frame
|
||||||
|
*/
|
||||||
|
public void remainingSpan(int remaining) {
|
||||||
|
updateDelay();
|
||||||
|
nextStop = pointerTimestamp - remaining;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Picks the held packet that best covers the chunk starting at the playback position. */
|
||||||
|
private int findPacket(int desiredSpan) {
|
||||||
|
// Exact start, covering the whole chunk.
|
||||||
|
for (int i = 0; i < MAX_PACKETS; i++) {
|
||||||
|
if (data[i] != null && timestamp[i] == pointerTimestamp
|
||||||
|
&& ge(timestamp[i] + span[i], pointerTimestamp + desiredSpan)) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// An earlier packet still covering the whole chunk.
|
||||||
|
for (int i = 0; i < MAX_PACKETS; i++) {
|
||||||
|
if (data[i] != null && le(timestamp[i], pointerTimestamp)
|
||||||
|
&& ge(timestamp[i] + span[i], pointerTimestamp + desiredSpan)) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// An earlier packet covering part of it.
|
||||||
|
for (int i = 0; i < MAX_PACKETS; i++) {
|
||||||
|
if (data[i] != null && le(timestamp[i], pointerTimestamp)
|
||||||
|
&& gt(timestamp[i] + span[i], pointerTimestamp)) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Anything starting inside the chunk: prefer the earliest, then the longest.
|
||||||
|
int best = -1;
|
||||||
|
for (int i = 0; i < MAX_PACKETS; i++) {
|
||||||
|
if (data[i] == null) continue;
|
||||||
|
if (!(lt(timestamp[i], pointerTimestamp + desiredSpan) && ge(timestamp[i], pointerTimestamp))) continue;
|
||||||
|
if (best < 0 || lt(timestamp[i], timestamp[best])
|
||||||
|
|| (timestamp[i] == timestamp[best] && gt(span[i], span[best]))) {
|
||||||
|
best = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies the delay the timings call for, by scheduling an insertion to grow the buffer
|
||||||
|
* or skipping ahead to shrink it. The timings are shifted by the same amount so the
|
||||||
|
* next estimate is made relative to the new delay.
|
||||||
|
*/
|
||||||
|
private void updateDelay() {
|
||||||
|
int optimal = computeOptimalDelay();
|
||||||
|
if (optimal == 0) return;
|
||||||
|
|
||||||
|
shiftTimings(-optimal);
|
||||||
|
pointerTimestamp += optimal;
|
||||||
|
if (optimal < 0) interpolationRequested = -optimal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds the delay minimising {@code delay + lateCost * latePackets} over the observed
|
||||||
|
* timings, quantised to whole {@link #delayStep}s.
|
||||||
|
*
|
||||||
|
* @return ticks to shift playback by: negative to buffer more, positive to catch up
|
||||||
|
*/
|
||||||
|
private int computeOptimalDelay() {
|
||||||
|
int totalSeen = 0;
|
||||||
|
for (TimingWindow w : windows) totalSeen += w.seen;
|
||||||
|
if (totalSeen == 0) return 0;
|
||||||
|
|
||||||
|
float lateFactor = lateCost != 0
|
||||||
|
? lateCost * 100.0f / totalSeen
|
||||||
|
: (float) autoTradeoff * windowSize / totalSeen;
|
||||||
|
|
||||||
|
int[] pos = new int[SUB_WINDOWS];
|
||||||
|
int optimal = 0;
|
||||||
|
int bestCost = Integer.MAX_VALUE;
|
||||||
|
int late = 0;
|
||||||
|
boolean penaltyTaken = false;
|
||||||
|
int earliestSeen = 0;
|
||||||
|
int latestSeen = 0;
|
||||||
|
|
||||||
|
// Walk the timings from latest to earliest, costing each as a candidate delay.
|
||||||
|
for (int i = 0; i < TOP_DELAY; i++) {
|
||||||
|
int next = -1;
|
||||||
|
int timing = Integer.MAX_VALUE;
|
||||||
|
for (int j = 0; j < SUB_WINDOWS; j++) {
|
||||||
|
if (pos[j] < windows[j].filled && windows[j].timing[pos[j]] < timing) {
|
||||||
|
next = j;
|
||||||
|
timing = windows[j].timing[pos[j]];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (next < 0) break;
|
||||||
|
|
||||||
|
if (i == 0) latestSeen = timing;
|
||||||
|
earliestSeen = timing;
|
||||||
|
timing = roundDown(timing, delayStep);
|
||||||
|
pos[next]++;
|
||||||
|
|
||||||
|
int cost = (int) (-timing + lateFactor * late);
|
||||||
|
if (cost < bestCost) {
|
||||||
|
bestCost = cost;
|
||||||
|
optimal = timing;
|
||||||
|
}
|
||||||
|
|
||||||
|
late++;
|
||||||
|
// Hysteresis: charge extra the first time we would start making packets late.
|
||||||
|
if (timing >= 0 && !penaltyTaken) {
|
||||||
|
penaltyTaken = true;
|
||||||
|
late += 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
autoTradeoff = 1 + (earliestSeen - latestSeen) / TOP_DELAY;
|
||||||
|
|
||||||
|
// Too little evidence to justify shrinking the buffer.
|
||||||
|
if (totalSeen < TOP_DELAY && optimal > 0) return 0;
|
||||||
|
return optimal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Records one arrival's slack, rotating the sub-windows when the newest fills up. */
|
||||||
|
private void updateTimings(int timing) {
|
||||||
|
timing = Math.max(-32767, Math.min(32767, timing));
|
||||||
|
|
||||||
|
if (byAge[0].seen >= subWindowSize) {
|
||||||
|
TimingWindow oldest = byAge[SUB_WINDOWS - 1];
|
||||||
|
System.arraycopy(byAge, 0, byAge, 1, SUB_WINDOWS - 1);
|
||||||
|
byAge[0] = oldest;
|
||||||
|
oldest.clear();
|
||||||
|
}
|
||||||
|
byAge[0].add(timing);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-bases every recorded timing after the delay moved. */
|
||||||
|
private void shiftTimings(int amount) {
|
||||||
|
for (TimingWindow w : windows) {
|
||||||
|
for (int i = 0; i < w.filled; i++) w.timing[i] += amount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int roundDown(int value, int step) {
|
||||||
|
return value < 0 ? (value - step + 1) / step * step : value / step * step;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrap-safe comparisons, so tick counters may overflow.
|
||||||
|
private static boolean lt(int a, int b) {
|
||||||
|
return a - b < 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean le(int a, int b) {
|
||||||
|
return a - b <= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean gt(int a, int b) {
|
||||||
|
return a - b > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean ge(int a, int b) {
|
||||||
|
return a - b >= 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package com.ts3client.audio;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
import java.util.function.ToDoubleFunction;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mixes every speaker onto one playback line, as mobile audio APIs want: a single
|
||||||
|
* low-latency stream. The line's blocking writes pace the mix, so it runs on the device's
|
||||||
|
* clock; between talk bursts the mixer writes nothing and waits.
|
||||||
|
*/
|
||||||
|
final class MixedPlayout implements Playout {
|
||||||
|
|
||||||
|
private static final int FRAME = VoiceFormat.FRAME_SIZE;
|
||||||
|
|
||||||
|
private final AudioIo io;
|
||||||
|
private final Supplier<String> outputDevice;
|
||||||
|
private final ToDoubleFunction<VoiceStream> gain;
|
||||||
|
private final Collection<VoiceStream> streams;
|
||||||
|
|
||||||
|
private final Object lock = new Object();
|
||||||
|
/** Guarded by {@link #lock}. */
|
||||||
|
private boolean running = true;
|
||||||
|
private Thread mixer;
|
||||||
|
|
||||||
|
MixedPlayout(AudioIo io, Supplier<String> outputDevice, ToDoubleFunction<VoiceStream> gain,
|
||||||
|
Collection<VoiceStream> streams) {
|
||||||
|
this.io = io;
|
||||||
|
this.outputDevice = outputDevice;
|
||||||
|
this.gain = gain;
|
||||||
|
this.streams = streams;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void started(VoiceStream stream) {
|
||||||
|
synchronized (lock) {
|
||||||
|
if (!running) return;
|
||||||
|
if (mixer == null) {
|
||||||
|
mixer = new Thread(this::mixLoop, "ts3j-voice-mixer");
|
||||||
|
mixer.setDaemon(true);
|
||||||
|
mixer.start();
|
||||||
|
}
|
||||||
|
lock.notifyAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void removed(VoiceStream stream) {
|
||||||
|
// The stream has already left the collection the mixer iterates.
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void shutdown() {
|
||||||
|
synchronized (lock) {
|
||||||
|
running = false;
|
||||||
|
if (mixer != null) mixer.interrupt();
|
||||||
|
lock.notifyAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void mixLoop() {
|
||||||
|
AudioPlayback line = null;
|
||||||
|
try {
|
||||||
|
int channels = 0;
|
||||||
|
float[] mix = null;
|
||||||
|
float[] scratch = null;
|
||||||
|
byte[] out = null;
|
||||||
|
String device = null;
|
||||||
|
while (awaitSpeech()) {
|
||||||
|
if (line != null && !device.equals(outputDevice.get())) {
|
||||||
|
line.close();
|
||||||
|
line = null;
|
||||||
|
}
|
||||||
|
if (line == null) {
|
||||||
|
device = outputDevice.get();
|
||||||
|
line = io.openPlayback(device, VoiceFormat.MAX_CHANNELS);
|
||||||
|
line.start();
|
||||||
|
channels = line.channels();
|
||||||
|
mix = new float[FRAME * channels];
|
||||||
|
scratch = new float[VoiceStream.MAX_FRAME * channels];
|
||||||
|
out = new byte[mix.length * 2];
|
||||||
|
}
|
||||||
|
Arrays.fill(mix, 0f);
|
||||||
|
for (VoiceStream stream : streams) {
|
||||||
|
int frames = stream.pull(scratch, channels, gain.applyAsDouble(stream));
|
||||||
|
// TeamSpeak clients send 20 ms packets; anything longer is cut to the frame.
|
||||||
|
int samples = Math.min(frames, FRAME) * channels;
|
||||||
|
for (int i = 0; i < samples; i++) mix[i] += scratch[i];
|
||||||
|
}
|
||||||
|
Pcm16.encode(mix, mix.length, out);
|
||||||
|
line.write(out, 0, out.length);
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
// No usable line: this mixer ends, and the next talk burst tries again.
|
||||||
|
} finally {
|
||||||
|
if (line != null) line.close();
|
||||||
|
synchronized (lock) {
|
||||||
|
mixer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Waits until some stream is playing; false once shut down. */
|
||||||
|
private boolean awaitSpeech() throws InterruptedException {
|
||||||
|
synchronized (lock) {
|
||||||
|
while (running) {
|
||||||
|
for (VoiceStream stream : streams) {
|
||||||
|
if (stream.isPlaying()) return true;
|
||||||
|
}
|
||||||
|
lock.wait();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
package com.ts3client.audio;
|
|
||||||
|
|
||||||
import java.util.Arrays;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Single-channel spectral noise suppressor — the "Remove background noise"
|
|
||||||
* (denoise) stage. The TeamSpeak 3 client filters steady background noise with
|
|
||||||
* WebRTC's {@code noise_suppression} module (and a Speex denoiser fallback); this is
|
|
||||||
* a self-contained equivalent that operates on the STFT bins produced by
|
|
||||||
* {@link AudioEnhancer}.
|
|
||||||
*
|
|
||||||
* <p>The noise floor is tracked per bin by continuous minimum statistics (Doblinger's
|
|
||||||
* recursive minimum tracker): the estimate follows the valleys of the smoothed power
|
|
||||||
* spectrum, so modulated speech — which dips between syllables — is
|
|
||||||
* preserved while only near-stationary background energy is learned as noise. From
|
|
||||||
* that floor a Wiener gain is formed with a decision-directed a priori SNR
|
|
||||||
* (Ephraim–Malah smoothing, which keeps musical noise low). A configurable
|
|
||||||
* aggressiveness ({@code denoiser_level}, 0–1) sets both the over-subtraction
|
|
||||||
* factor and the gain floor, i.e. how deeply steady noise is cut.
|
|
||||||
*/
|
|
||||||
final class NoiseSuppressor {
|
|
||||||
|
|
||||||
/** Power-spectrum smoothing feeding the minimum tracker. */
|
|
||||||
private static final double POWER_SMOOTH = 0.7;
|
|
||||||
/** Doblinger minimum-tracker constants. */
|
|
||||||
private static final double MIN_GAMMA = 0.998;
|
|
||||||
private static final double MIN_BETA = 0.96;
|
|
||||||
/** Over-estimation applied to the tracked minimum to get the noise power. */
|
|
||||||
private static final double NOISE_OVEREST = 1.5;
|
|
||||||
/** Decision-directed smoothing of the a priori SNR (higher = less musical noise). */
|
|
||||||
private static final double DD_ALPHA = 0.98;
|
|
||||||
/** Floor on the a priori SNR (~ -25 dB) to bound the deepest Wiener gain. */
|
|
||||||
private static final double XI_MIN = 0.003;
|
|
||||||
|
|
||||||
private final int bins;
|
|
||||||
private final double[] smoothed;
|
|
||||||
private final double[] prevSmoothed;
|
|
||||||
private final double[] minTrack;
|
|
||||||
private final double[] priorClean; // previous enhanced power, for the DD estimate
|
|
||||||
|
|
||||||
private double overSubtraction = 1.5;
|
|
||||||
private double gainFloor = dbToGain(-18);
|
|
||||||
private boolean initialised;
|
|
||||||
|
|
||||||
NoiseSuppressor(int bins) {
|
|
||||||
this.bins = bins;
|
|
||||||
this.smoothed = new double[bins];
|
|
||||||
this.prevSmoothed = new double[bins];
|
|
||||||
this.minTrack = new double[bins];
|
|
||||||
this.priorClean = new double[bins];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sets aggressiveness in [0,1]. 0 is a light touch (~6 dB max cut), 1 is
|
|
||||||
* heavy (~30 dB) with stronger over-subtraction.
|
|
||||||
*/
|
|
||||||
void setLevel(double level) {
|
|
||||||
double l = Math.max(0, Math.min(1, level));
|
|
||||||
this.gainFloor = dbToGain(-(6 + 24 * l));
|
|
||||||
this.overSubtraction = 1.0 + 1.5 * l;
|
|
||||||
}
|
|
||||||
|
|
||||||
void reset() {
|
|
||||||
initialised = false;
|
|
||||||
Arrays.fill(smoothed, 0);
|
|
||||||
Arrays.fill(prevSmoothed, 0);
|
|
||||||
Arrays.fill(minTrack, 0);
|
|
||||||
Arrays.fill(priorClean, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Multiplies the running per-bin gain by this stage's Wiener gain. */
|
|
||||||
void apply(double[] power, double[] gain) {
|
|
||||||
if (!initialised) {
|
|
||||||
for (int k = 0; k < bins; k++) {
|
|
||||||
smoothed[k] = prevSmoothed[k] = minTrack[k] = power[k];
|
|
||||||
priorClean[k] = power[k];
|
|
||||||
}
|
|
||||||
initialised = true;
|
|
||||||
}
|
|
||||||
for (int k = 0; k < bins; k++) {
|
|
||||||
double p = power[k] + 1e-12;
|
|
||||||
|
|
||||||
double s = POWER_SMOOTH * smoothed[k] + (1 - POWER_SMOOTH) * p;
|
|
||||||
// Doblinger continuous minimum tracking of the smoothed power.
|
|
||||||
double mt;
|
|
||||||
if (minTrack[k] < s) {
|
|
||||||
mt = MIN_GAMMA * minTrack[k]
|
|
||||||
+ ((1 - MIN_GAMMA) / (1 - MIN_BETA)) * (s - MIN_BETA * smoothed[k]);
|
|
||||||
} else {
|
|
||||||
mt = s;
|
|
||||||
}
|
|
||||||
minTrack[k] = mt;
|
|
||||||
smoothed[k] = s;
|
|
||||||
double noiseK = NOISE_OVEREST * mt + 1e-12;
|
|
||||||
|
|
||||||
double gamma = p / (noiseK * overSubtraction); // a posteriori SNR
|
|
||||||
double xi = DD_ALPHA * (priorClean[k] / noiseK)
|
|
||||||
+ (1 - DD_ALPHA) * Math.max(gamma - 1, 0); // a priori SNR
|
|
||||||
if (xi < XI_MIN) xi = XI_MIN;
|
|
||||||
|
|
||||||
double g = xi / (1 + xi); // Wiener gain
|
|
||||||
if (g < gainFloor) g = gainFloor;
|
|
||||||
|
|
||||||
priorClean[k] = g * g * p;
|
|
||||||
gain[k] *= g;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static double dbToGain(double db) {
|
|
||||||
return Math.pow(10.0, db / 20.0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
20
ts3-client/core/src/main/java/com/ts3client/audio/Pcm16.java
Normal file
20
ts3-client/core/src/main/java/com/ts3client/audio/Pcm16.java
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
package com.ts3client.audio;
|
||||||
|
|
||||||
|
/** Conversion between float samples in [-1, 1] and 16-bit little-endian PCM. */
|
||||||
|
public final class Pcm16 {
|
||||||
|
|
||||||
|
private Pcm16() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Writes {@code count} samples to {@code out}, clipping anything out of range. */
|
||||||
|
public static void encode(float[] samples, int count, byte[] out) {
|
||||||
|
for (int i = 0; i < count; i++) {
|
||||||
|
float f = samples[i];
|
||||||
|
if (f > 1f) f = 1f;
|
||||||
|
else if (f < -1f) f = -1f;
|
||||||
|
int s = Math.round(f * 32767f);
|
||||||
|
out[2 * i] = (byte) (s & 0xFF);
|
||||||
|
out[2 * i + 1] = (byte) ((s >> 8) & 0xFF);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
package com.ts3client.audio;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.ScheduledExecutorService;
|
||||||
|
import java.util.concurrent.ScheduledFuture;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
import java.util.function.ToDoubleFunction;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gives every speaker a playback line and worker thread of its own, so simultaneous
|
||||||
|
* speakers are mixed by the audio server and one slow decode never blocks another. On a
|
||||||
|
* PipeWire desktop every speaker then shows up as a separate stream in the mixer.
|
||||||
|
*/
|
||||||
|
final class PerSpeakerPlayout implements Playout {
|
||||||
|
|
||||||
|
private static final long FRAME_NANOS =
|
||||||
|
TimeUnit.SECONDS.toNanos(VoiceFormat.FRAME_SIZE) / VoiceFormat.SAMPLE_RATE;
|
||||||
|
/** Silence after which a speaker's line and thread are given back; the next burst reopens them. */
|
||||||
|
private static final long IDLE_RELEASE_NANOS = TimeUnit.SECONDS.toNanos(30);
|
||||||
|
|
||||||
|
private final class Speaker {
|
||||||
|
final VoiceStream stream;
|
||||||
|
final AudioPlayback line;
|
||||||
|
final int lineChannels;
|
||||||
|
final ExecutorService worker;
|
||||||
|
final AtomicBoolean tickQueued = new AtomicBoolean();
|
||||||
|
/** Touched only by {@link #worker}. */
|
||||||
|
final float[] pcm;
|
||||||
|
final byte[] out;
|
||||||
|
/** Guarded by this speaker. */
|
||||||
|
ScheduledFuture<?> ticks;
|
||||||
|
/** Guarded by this speaker; a closed speaker is never ticked again. */
|
||||||
|
boolean closed;
|
||||||
|
|
||||||
|
Speaker(VoiceStream stream) throws Exception {
|
||||||
|
this.stream = stream;
|
||||||
|
this.line = io.openPlayback(outputDevice.get(), VoiceFormat.MAX_CHANNELS);
|
||||||
|
this.lineChannels = line.channels();
|
||||||
|
this.line.start();
|
||||||
|
this.pcm = new float[VoiceStream.MAX_FRAME * lineChannels];
|
||||||
|
this.out = new byte[pcm.length * 2];
|
||||||
|
this.worker = Executors.newSingleThreadExecutor(r -> {
|
||||||
|
Thread t = new Thread(r, "ts3j-play-" + stream.clientId);
|
||||||
|
t.setDaemon(true);
|
||||||
|
return t;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void close() {
|
||||||
|
synchronized (this) {
|
||||||
|
closed = true;
|
||||||
|
if (ticks != null) ticks.cancel(false);
|
||||||
|
}
|
||||||
|
worker.shutdownNow();
|
||||||
|
line.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final AudioIo io;
|
||||||
|
private final Supplier<String> outputDevice;
|
||||||
|
private final ToDoubleFunction<VoiceStream> gain;
|
||||||
|
private final ScheduledExecutorService clock;
|
||||||
|
private final Map<VoiceStream, Speaker> speakers = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
PerSpeakerPlayout(AudioIo io, Supplier<String> outputDevice, ToDoubleFunction<VoiceStream> gain,
|
||||||
|
ScheduledExecutorService clock) {
|
||||||
|
this.io = io;
|
||||||
|
this.outputDevice = outputDevice;
|
||||||
|
this.gain = gain;
|
||||||
|
this.clock = clock;
|
||||||
|
clock.scheduleWithFixedDelay(this::releaseIdle, 1, 1, TimeUnit.SECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void started(VoiceStream stream) {
|
||||||
|
while (true) {
|
||||||
|
Speaker speaker = speakers.get(stream);
|
||||||
|
if (speaker == null) {
|
||||||
|
try {
|
||||||
|
speaker = new Speaker(stream);
|
||||||
|
} catch (Exception e) {
|
||||||
|
stream.stop(); // couldn't open a line; drop the burst
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Speaker existing = speakers.putIfAbsent(stream, speaker);
|
||||||
|
if (existing != null) {
|
||||||
|
speaker.close();
|
||||||
|
speaker = existing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
synchronized (speaker) {
|
||||||
|
if (speaker.closed) {
|
||||||
|
// Released a moment ago; this burst gets a fresh line.
|
||||||
|
speakers.remove(stream, speaker);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (speaker.ticks == null) {
|
||||||
|
Speaker s = speaker;
|
||||||
|
speaker.ticks = clock.scheduleAtFixedRate(
|
||||||
|
() -> queueTick(s), FRAME_NANOS, FRAME_NANOS, TimeUnit.NANOSECONDS);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gives back the line and thread of every speaker that has been silent for a while. */
|
||||||
|
private void releaseIdle() {
|
||||||
|
long now = System.nanoTime();
|
||||||
|
for (Speaker speaker : speakers.values()) {
|
||||||
|
synchronized (speaker) {
|
||||||
|
if (speaker.ticks != null || now - speaker.stream.lastPacketNanos() < IDLE_RELEASE_NANOS) continue;
|
||||||
|
speaker.closed = true;
|
||||||
|
}
|
||||||
|
speakers.remove(speaker.stream, speaker);
|
||||||
|
speaker.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void queueTick(Speaker speaker) {
|
||||||
|
if (!speaker.tickQueued.compareAndSet(false, true)) return;
|
||||||
|
try {
|
||||||
|
speaker.worker.submit(() -> {
|
||||||
|
try {
|
||||||
|
tick(speaker);
|
||||||
|
} finally {
|
||||||
|
speaker.tickQueued.set(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
if (speaker.worker.isShutdown()) {
|
||||||
|
speaker.tickQueued.set(false);
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void tick(Speaker speaker) {
|
||||||
|
int frames = speaker.stream.pull(speaker.pcm, speaker.lineChannels, gain.applyAsDouble(speaker.stream));
|
||||||
|
if (frames == VoiceStream.IDLE) {
|
||||||
|
synchronized (speaker) {
|
||||||
|
// A packet may have started the next burst since the pull; keep ticking then.
|
||||||
|
if (!speaker.stream.isPlaying() && speaker.ticks != null) {
|
||||||
|
speaker.ticks.cancel(false);
|
||||||
|
speaker.ticks = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (frames == 0) return;
|
||||||
|
int samples = frames * speaker.lineChannels;
|
||||||
|
Pcm16.encode(speaker.pcm, samples, speaker.out);
|
||||||
|
try {
|
||||||
|
speaker.line.write(speaker.out, 0, samples * 2);
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
// The line died (a device went away, say). Drop it rather than keep writing into it:
|
||||||
|
// the speaker's next packet starts a burst on a fresh one.
|
||||||
|
speakers.remove(speaker.stream, speaker);
|
||||||
|
speaker.close();
|
||||||
|
speaker.stream.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void removed(VoiceStream stream) {
|
||||||
|
Speaker speaker = speakers.remove(stream);
|
||||||
|
if (speaker != null) speaker.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void shutdown() {
|
||||||
|
for (Speaker speaker : speakers.values()) speaker.close();
|
||||||
|
speakers.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.ts3client.audio;
|
||||||
|
|
||||||
|
/** Drives the {@link VoiceStream}s of a {@link StreamingVoiceOutput} out to the speakers. */
|
||||||
|
interface Playout {
|
||||||
|
|
||||||
|
/** A stream began a talk burst and must now be pulled every 20 ms. */
|
||||||
|
void started(VoiceStream stream);
|
||||||
|
|
||||||
|
/** The stream's client left; it will not be pulled again. */
|
||||||
|
void removed(VoiceStream stream);
|
||||||
|
|
||||||
|
void shutdown();
|
||||||
|
}
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
package com.ts3client.audio;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lightweight speech-presence detector used by the "Automatic" and "Hybrid" voice
|
|
||||||
* activation modes (the TeamSpeak 3 client uses a WebRTC GMM detector for the same
|
|
||||||
* purpose). It classifies each 20 ms frame from three features — short-term
|
|
||||||
* energy, spectral flatness and dominant frequency — against an adaptive noise
|
|
||||||
* floor, following Moattar & Homayounpour's real-time VAD.
|
|
||||||
*
|
|
||||||
* <p>Pure DSP with no platform dependencies, so any frontend/backend can reuse it.
|
|
||||||
* A frame votes for speech when at least two of the three features exceed the
|
|
||||||
* baseline; the smoothed vote fraction is exposed as a [0,1] probability.
|
|
||||||
*/
|
|
||||||
public final class SpeechDetector {
|
|
||||||
|
|
||||||
private static final int FFT_SIZE = 1024;
|
|
||||||
|
|
||||||
// Primary thresholds from the reference algorithm (16-bit sample scale).
|
|
||||||
private static final double ENERGY_PRIM = 40.0;
|
|
||||||
private static final double DOMINANT_FREQ_PRIM = 185.0;
|
|
||||||
private static final double FLATNESS_PRIM = 5.0;
|
|
||||||
|
|
||||||
private final int sampleRate;
|
|
||||||
private final double[] re = new double[FFT_SIZE];
|
|
||||||
private final double[] im = new double[FFT_SIZE];
|
|
||||||
|
|
||||||
private double minEnergy;
|
|
||||||
private double minDominantFreq;
|
|
||||||
private double minFlatness;
|
|
||||||
private long silenceFrames;
|
|
||||||
private long frameCount;
|
|
||||||
private double probability;
|
|
||||||
|
|
||||||
public SpeechDetector(int sampleRate) {
|
|
||||||
this.sampleRate = sampleRate;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void reset() {
|
|
||||||
frameCount = 0;
|
|
||||||
silenceFrames = 0;
|
|
||||||
probability = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
public double getProbability() {
|
|
||||||
return probability;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Processes one frame of mono PCM in [-1,1] and returns the smoothed speech
|
|
||||||
* probability in [0,1].
|
|
||||||
*/
|
|
||||||
public double process(float[] frame) {
|
|
||||||
double energy = 0;
|
|
||||||
int n = Math.min(frame.length, FFT_SIZE);
|
|
||||||
for (int i = 0; i < n; i++) {
|
|
||||||
double s = frame[i] * 32768.0; // emulate 16-bit scale
|
|
||||||
energy += s * s;
|
|
||||||
re[i] = frame[i];
|
|
||||||
im[i] = 0;
|
|
||||||
}
|
|
||||||
for (int i = n; i < FFT_SIZE; i++) {
|
|
||||||
re[i] = 0;
|
|
||||||
im[i] = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
Fft.forward(re, im);
|
|
||||||
|
|
||||||
int half = FFT_SIZE / 2;
|
|
||||||
double geoLogSum = 0;
|
|
||||||
double arithSum = 0;
|
|
||||||
double maxMag = 0;
|
|
||||||
int maxBin = 0;
|
|
||||||
for (int k = 1; k < half; k++) {
|
|
||||||
double mag = Math.sqrt(re[k] * re[k] + im[k] * im[k]) + 1e-12;
|
|
||||||
geoLogSum += Math.log(mag);
|
|
||||||
arithSum += mag;
|
|
||||||
if (mag > maxMag) {
|
|
||||||
maxMag = mag;
|
|
||||||
maxBin = k;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
int bins = half - 1;
|
|
||||||
double geoMean = Math.exp(geoLogSum / bins);
|
|
||||||
double arithMean = arithSum / bins;
|
|
||||||
double flatness = -10.0 * Math.log10(geoMean / arithMean); // high = tonal (speech)
|
|
||||||
double dominantFreq = (double) maxBin * sampleRate / FFT_SIZE;
|
|
||||||
|
|
||||||
if (frameCount == 0) {
|
|
||||||
minEnergy = Math.max(energy, 1.0);
|
|
||||||
minDominantFreq = dominantFreq;
|
|
||||||
minFlatness = flatness;
|
|
||||||
}
|
|
||||||
|
|
||||||
double energyThresh = ENERGY_PRIM * Math.log10(Math.max(minEnergy, 1.0));
|
|
||||||
int votes = 0;
|
|
||||||
if (energy - minEnergy >= energyThresh) votes++;
|
|
||||||
if (dominantFreq - minDominantFreq >= DOMINANT_FREQ_PRIM) votes++;
|
|
||||||
if (flatness - minFlatness >= FLATNESS_PRIM) votes++;
|
|
||||||
|
|
||||||
boolean speech = votes >= 2;
|
|
||||||
if (!speech) {
|
|
||||||
// Adapt the noise floor towards the current (silent) energy.
|
|
||||||
silenceFrames++;
|
|
||||||
minEnergy = ((silenceFrames * minEnergy) + energy) / (silenceFrames + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
double instant = votes / 3.0;
|
|
||||||
probability = 0.6 * probability + 0.4 * instant;
|
|
||||||
frameCount++;
|
|
||||||
return probability;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package com.ts3client.audio;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estimates how likely it is that a frame of captured audio contains speech.
|
||||||
|
*
|
||||||
|
* <p>Used by the "Automatic" and "Hybrid" voice-activation modes. Implementations are
|
||||||
|
* stateful and expect frames in capture order; {@link #reset()} clears that state.
|
||||||
|
*
|
||||||
|
* <p>Pure DSP with no platform dependencies, so any frontend or backend can reuse it.
|
||||||
|
*/
|
||||||
|
public interface SpeechProbabilityDetector {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Consumes one frame of mono PCM in [-1, 1] and returns the current speech
|
||||||
|
* probability in [0, 1].
|
||||||
|
*
|
||||||
|
* @param frame samples in capture order
|
||||||
|
* @param length number of valid samples in {@code frame}
|
||||||
|
*/
|
||||||
|
double process(float[] frame, int length);
|
||||||
|
|
||||||
|
/** Speech probability from the most recent {@link #process} call. */
|
||||||
|
double probability();
|
||||||
|
|
||||||
|
/** Drops all history. Call on a capture discontinuity or a settings change. */
|
||||||
|
void reset();
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
package com.ts3client.audio;
|
||||||
|
|
||||||
|
import com.github.manevolent.ts3j.enums.CodecType;
|
||||||
|
import com.github.manevolent.ts3j.protocol.packet.PacketBody0Voice;
|
||||||
|
import com.github.manevolent.ts3j.protocol.packet.PacketBody1VoiceWhisper;
|
||||||
|
import com.ts3client.audio.opus.OpusCodec;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.ScheduledExecutorService;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.function.BiConsumer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The {@link VoiceOutput} every platform shares: decodes incoming voice per speaker, each
|
||||||
|
* through its own jitter buffer, and plays it on the platform's {@link AudioIo}.
|
||||||
|
*/
|
||||||
|
public final class StreamingVoiceOutput implements VoiceOutput {
|
||||||
|
|
||||||
|
/** How speakers reach the device. */
|
||||||
|
public enum Lines {
|
||||||
|
/** A line per speaker, mixed by the platform's audio server. */
|
||||||
|
PER_SPEAKER,
|
||||||
|
/** One line all speakers are mixed onto here. */
|
||||||
|
SHARED
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A speaker is only meant to stop when the empty voice packet marking the end of a
|
||||||
|
* talk burst arrives — but that packet is UDP too, and a lost one otherwise leaves
|
||||||
|
* the talking indicator stuck until the speaker's next burst. Opus frames are 20 ms
|
||||||
|
* apart while someone is actually talking, so a gap several times that long, with no
|
||||||
|
* packet of either kind, is unambiguous: force the indicator off rather than trust
|
||||||
|
* the one packet that could go missing.
|
||||||
|
*/
|
||||||
|
private static final long TALK_TIMEOUT_NANOS = TimeUnit.MILLISECONDS.toNanos(400);
|
||||||
|
|
||||||
|
private final OpusCodec opus;
|
||||||
|
private final Map<Integer, VoiceStream> streams = new ConcurrentHashMap<>();
|
||||||
|
private final Set<Integer> mutedClients = ConcurrentHashMap.newKeySet();
|
||||||
|
/** Per-client gain on top of the master volume; absent means 1.0. */
|
||||||
|
private final Map<Integer, Double> clientVolumes = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
private volatile String outputDevice;
|
||||||
|
private volatile double masterVolume = 1.0;
|
||||||
|
private volatile boolean deafened = false;
|
||||||
|
|
||||||
|
/** Notified (clientId, talking) on an audio thread when a speaker starts/stops. */
|
||||||
|
private volatile BiConsumer<Integer, Boolean> talkListener;
|
||||||
|
|
||||||
|
/** Catches talk bursts whose end packet never arrived, and paces per-speaker lines. */
|
||||||
|
private final ScheduledExecutorService clock = Executors.newSingleThreadScheduledExecutor(r -> {
|
||||||
|
Thread t = new Thread(r, "ts3j-talk-watchdog");
|
||||||
|
t.setDaemon(true);
|
||||||
|
return t;
|
||||||
|
});
|
||||||
|
private final Playout playout;
|
||||||
|
|
||||||
|
public StreamingVoiceOutput(AudioIo io, OpusCodec opus, String outputDevice, Lines lines) {
|
||||||
|
this.opus = opus;
|
||||||
|
this.outputDevice = outputDevice == null ? "" : outputDevice;
|
||||||
|
this.playout = switch (lines) {
|
||||||
|
case PER_SPEAKER -> new PerSpeakerPlayout(io, () -> this.outputDevice, this::gainFor, clock);
|
||||||
|
case SHARED -> new MixedPlayout(io, () -> this.outputDevice, this::gainFor, streams.values());
|
||||||
|
};
|
||||||
|
clock.scheduleWithFixedDelay(this::checkTalkTimeouts, 50, 50, TimeUnit.MILLISECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void checkTalkTimeouts() {
|
||||||
|
long now = System.nanoTime();
|
||||||
|
for (VoiceStream s : streams.values()) {
|
||||||
|
if (s.isTalking() && now - s.lastPacketNanos() > TALK_TIMEOUT_NANOS) s.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private double gainFor(VoiceStream stream) {
|
||||||
|
return masterVolume * clientVolumes.getOrDefault(stream.clientId, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void fireTalk(int clientId, boolean talking) {
|
||||||
|
BiConsumer<Integer, Boolean> l = talkListener;
|
||||||
|
if (l != null) l.accept(clientId, talking);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setTalkListener(BiConsumer<Integer, Boolean> l) {
|
||||||
|
this.talkListener = l;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setMasterVolume(double v) {
|
||||||
|
this.masterVolume = Math.max(0, Math.min(2.0, v));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setDeafened(boolean d) {
|
||||||
|
this.deafened = d;
|
||||||
|
if (d) {
|
||||||
|
// Stop everyone talking immediately.
|
||||||
|
for (VoiceStream s : streams.values()) s.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isDeafened() {
|
||||||
|
return deafened;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setClientMuted(int clientId, boolean muted) {
|
||||||
|
if (muted) {
|
||||||
|
mutedClients.add(clientId);
|
||||||
|
VoiceStream s = streams.get(clientId);
|
||||||
|
if (s != null) s.stop();
|
||||||
|
} else {
|
||||||
|
mutedClients.remove(clientId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isClientMuted(int clientId) {
|
||||||
|
return mutedClients.contains(clientId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setClientVolume(int clientId, double gain) {
|
||||||
|
if (gain == 1.0) clientVolumes.remove(clientId);
|
||||||
|
else clientVolumes.put(clientId, Math.max(0, gain));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public double getClientVolume(int clientId) {
|
||||||
|
return clientVolumes.getOrDefault(clientId, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setOutputDevice(String device) {
|
||||||
|
this.outputDevice = device == null ? "" : device;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Entry point wired into {@code client.setVoiceHandler(...)}. */
|
||||||
|
@Override
|
||||||
|
public void handleVoice(PacketBody0Voice voice) {
|
||||||
|
route(voice.getClientId(), voice.getPacketId(), voice.getCodecType(), voice.getCodecData());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Entry point wired into {@code client.setWhisperHandler(...)}. */
|
||||||
|
@Override
|
||||||
|
public void handleWhisper(PacketBody1VoiceWhisper whisper) {
|
||||||
|
route(whisper.getClientId(), whisper.getPacketId(), whisper.getCodecType(), whisper.getCodecData());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void route(int clientId, int packetId, CodecType codec, byte[] data) {
|
||||||
|
if (deafened) return;
|
||||||
|
if (mutedClients.contains(clientId)) return;
|
||||||
|
|
||||||
|
VoiceStream stream = streams.computeIfAbsent(clientId, id -> new VoiceStream(id, opus, this::fireTalk));
|
||||||
|
if (stream.offer(packetId, codec, data)) playout.started(stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Forgets a client that left; the server may hand its id to somebody else. */
|
||||||
|
@Override
|
||||||
|
public void removeClient(int clientId) {
|
||||||
|
VoiceStream s = streams.remove(clientId);
|
||||||
|
if (s != null) {
|
||||||
|
playout.removed(s);
|
||||||
|
s.close();
|
||||||
|
}
|
||||||
|
mutedClients.remove(clientId);
|
||||||
|
clientVolumes.remove(clientId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void shutdown() {
|
||||||
|
clock.shutdownNow();
|
||||||
|
playout.shutdown();
|
||||||
|
for (VoiceStream s : streams.values()) s.close();
|
||||||
|
streams.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
package com.ts3client.audio;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Transient (keystroke) suppressor — the "Typing attenuation" stage, which per
|
|
||||||
* the TeamSpeak 3 client "tries to detect and reduce the sounds made by typing"
|
|
||||||
* (WebRTC's {@code transient_suppression} module). Key clicks are short, impulsive,
|
|
||||||
* broadband bursts with a strong high-frequency component, unlike voiced speech which
|
|
||||||
* is sustained and low-frequency dominant.
|
|
||||||
*
|
|
||||||
* <p>Each STFT block is scored for a keystroke signature: a sudden jump in total
|
|
||||||
* power (both against the previous block and a slow running floor) together with an
|
|
||||||
* elevated high-frequency energy ratio. Matching blocks are ducked broadband with an
|
|
||||||
* immediate attack and a short release. A hold cap ensures only genuinely brief
|
|
||||||
* events are cut — a sustained sound such as a fricative outlasts the cap and is
|
|
||||||
* released, so speech is preserved.
|
|
||||||
*/
|
|
||||||
final class TypingAttenuator {
|
|
||||||
|
|
||||||
private static final double HF_HZ = 4000.0; // high-frequency band start
|
|
||||||
private static final double ONSET_FACTOR = 2.5; // total power vs slow floor
|
|
||||||
private static final double FLUX_FACTOR = 3.0; // total power vs previous block
|
|
||||||
private static final double HF_RATIO = 0.30; // fraction of energy above HF_HZ
|
|
||||||
private static final double SUPPRESS = 0.12; // ducking gain on a detected click (~ -18 dB)
|
|
||||||
private static final double RELEASE = 0.25; // recovery fraction per block after a click
|
|
||||||
private static final double FLOOR_SMOOTH = 0.98; // slow power-floor tracking
|
|
||||||
private static final int MAX_HOLD = 4; // max consecutive ducked blocks (~clicks only)
|
|
||||||
|
|
||||||
private final int bins;
|
|
||||||
private final int hfBin;
|
|
||||||
|
|
||||||
private double slowPower;
|
|
||||||
private double prevPower;
|
|
||||||
private double envGain = 1.0;
|
|
||||||
private int heldBlocks;
|
|
||||||
|
|
||||||
TypingAttenuator(int bins, int sampleRate, int fftSize) {
|
|
||||||
this.bins = bins;
|
|
||||||
this.hfBin = (int) Math.round(HF_HZ * fftSize / sampleRate);
|
|
||||||
}
|
|
||||||
|
|
||||||
void reset() {
|
|
||||||
slowPower = 0;
|
|
||||||
prevPower = 0;
|
|
||||||
envGain = 1.0;
|
|
||||||
heldBlocks = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Multiplies the running per-bin gain by the current broadband ducking gain. */
|
|
||||||
void apply(double[] power, double[] gain) {
|
|
||||||
double total = 0, high = 0;
|
|
||||||
for (int k = 0; k < bins; k++) {
|
|
||||||
total += power[k];
|
|
||||||
if (k >= hfBin) high += power[k];
|
|
||||||
}
|
|
||||||
double hfRatio = high / (total + 1e-12);
|
|
||||||
|
|
||||||
boolean signature = total > slowPower * ONSET_FACTOR
|
|
||||||
&& total > prevPower * FLUX_FACTOR
|
|
||||||
&& hfRatio > HF_RATIO;
|
|
||||||
|
|
||||||
if (signature && heldBlocks < MAX_HOLD) {
|
|
||||||
envGain = SUPPRESS; // fast attack: duck immediately
|
|
||||||
heldBlocks++;
|
|
||||||
} else {
|
|
||||||
envGain += (1 - envGain) * RELEASE;
|
|
||||||
if (!signature) {
|
|
||||||
heldBlocks = 0;
|
|
||||||
// Only let the floor track when we're not inside a transient.
|
|
||||||
slowPower = slowPower == 0 ? total : FLOOR_SMOOTH * slowPower + (1 - FLOOR_SMOOTH) * total;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
prevPower = total;
|
|
||||||
|
|
||||||
for (int k = 0; k < bins; k++) {
|
|
||||||
gain[k] *= envGain;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package com.ts3client.audio;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The PCM format voice is captured, processed and played in. TeamSpeak's Opus codecs run at
|
||||||
|
* 48 kHz in 20 ms frames, mono for {@code OPUS_VOICE} and stereo for {@code OPUS_MUSIC}.
|
||||||
|
*/
|
||||||
|
public final class VoiceFormat {
|
||||||
|
|
||||||
|
public static final int SAMPLE_RATE = 48_000;
|
||||||
|
/** Samples per channel in one 20 ms frame. */
|
||||||
|
public static final int FRAME_SIZE = 960;
|
||||||
|
public static final int MAX_CHANNELS = 2;
|
||||||
|
|
||||||
|
private VoiceFormat() {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,11 +18,23 @@ public interface VoiceInput extends Microphone {
|
|||||||
|
|
||||||
void setMuted(boolean muted);
|
void setMuted(boolean muted);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Silences capture the same way {@link #setMuted} does, but locally only: it does
|
||||||
|
* not touch {@link #isMuted}, so the mute status published to the server (and the
|
||||||
|
* mute/unmute sound) is unaffected. TS3's "Local Mic Mute".
|
||||||
|
*/
|
||||||
|
void setLocalMuted(boolean muted);
|
||||||
|
|
||||||
|
boolean isLocalMuted();
|
||||||
|
|
||||||
|
/** Moves capture to another device, reopening at once if capturing; empty is the default. */
|
||||||
|
void setInputDevice(String deviceId);
|
||||||
|
|
||||||
void setMode(Settings.InputMode mode);
|
void setMode(Settings.InputMode mode);
|
||||||
|
|
||||||
void setVadMode(Settings.VadMode mode);
|
void setVadMode(Settings.VadMode mode);
|
||||||
|
|
||||||
/** Voice-activation volume-gate threshold in dBFS. */
|
/** Volume-gate threshold on {@link InputLevel}'s scale. */
|
||||||
void setThresholdDb(double db);
|
void setThresholdDb(double db);
|
||||||
|
|
||||||
/** Speech-probability threshold (0..1) for Automatic/Hybrid modes. */
|
/** Speech-probability threshold (0..1) for Automatic/Hybrid modes. */
|
||||||
@@ -33,29 +45,59 @@ public interface VoiceInput extends Microphone {
|
|||||||
|
|
||||||
void setInputGain(double gain);
|
void setInputGain(double gain);
|
||||||
|
|
||||||
/** Enables removal of steady background noise (spectral denoise). */
|
/** Enables removal of steady background noise (WebRTC noise suppression). */
|
||||||
void setNoiseSuppression(boolean enabled);
|
void setNoiseSuppression(boolean enabled);
|
||||||
|
|
||||||
/** Background-noise removal aggressiveness, 0 (light) .. 1 (heavy). */
|
/** Noise suppression strength as TS3's {@code denoiser_level}: 0 (6 dB) .. 3 (21 dB). */
|
||||||
void setDenoiserLevel(double level);
|
void setDenoiserLevel(int level);
|
||||||
|
|
||||||
/** Enables detection and attenuation of keyboard typing sounds. */
|
/** Enables attenuation of keyboard clicks while the user is typing. */
|
||||||
void setTypingAttenuation(boolean enabled);
|
void setTypingAttenuation(boolean enabled);
|
||||||
|
|
||||||
|
/** Reports a key press anywhere on the system; typing attenuation only acts while typing. */
|
||||||
|
void keyPressed();
|
||||||
|
|
||||||
/** Enables automatic gain control (normalise microphone loudness). */
|
/** Enables automatic gain control (normalise microphone loudness). */
|
||||||
void setAgc(boolean enabled);
|
void setAgc(boolean enabled);
|
||||||
|
|
||||||
|
/** Lets AGC raise the background noise this many dB more to bring up a quiet voice. */
|
||||||
|
void setAgcBoost(int db);
|
||||||
|
|
||||||
/** Applies Opus encoder parameters, taking effect immediately if capturing. */
|
/** Applies Opus encoder parameters, taking effect immediately if capturing. */
|
||||||
void setOpusParameters(OpusParameters parameters);
|
void setOpusParameters(OpusParameters parameters);
|
||||||
|
|
||||||
void setPushToTalk(boolean pressed);
|
void setPushToTalk(boolean pressed);
|
||||||
|
|
||||||
/** Receives the live input level in dBFS, once per captured frame. */
|
/** Receives the live input level on {@link InputLevel}'s scale, once per captured frame. */
|
||||||
void setLevelListener(Consumer<Double> listener);
|
void setLevelListener(Consumer<Double> listener);
|
||||||
|
|
||||||
/** Receives local transmit-state transitions (talking / silent). */
|
/** Receives local transmit-state transitions (talking / silent). */
|
||||||
void setTalkListener(Consumer<Boolean> listener);
|
void setTalkListener(Consumer<Boolean> listener);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Receives every frame that is actually transmitted, for local monitoring: the audio
|
||||||
|
* after pre-processing and gating, including the pre-roll replayed when the gate opens.
|
||||||
|
* Nothing is delivered while the gate is shut or the microphone is muted.
|
||||||
|
*/
|
||||||
|
void setMonitorListener(AudioFrameListener listener);
|
||||||
|
|
||||||
/** Called when speech is detected while the microphone is muted. */
|
/** Called when speech is detected while the microphone is muted. */
|
||||||
void setMutedTalkListener(Runnable listener);
|
void setMutedTalkListener(Runnable listener);
|
||||||
|
|
||||||
|
/** Takes on the voice settings, as they stand now, while capturing. */
|
||||||
|
default void apply(Settings settings) {
|
||||||
|
setInputDevice(settings.inputDevice);
|
||||||
|
setMode(settings.inputMode);
|
||||||
|
setVadMode(settings.vadMode);
|
||||||
|
setThresholdDb(settings.vadThresholdDb);
|
||||||
|
setSpeechThreshold(settings.speechThreshold);
|
||||||
|
setVadOverPtt(settings.vadOverPtt);
|
||||||
|
setInputGain(settings.inputVolume);
|
||||||
|
setNoiseSuppression(settings.denoise);
|
||||||
|
setDenoiserLevel(settings.denoiserLevel);
|
||||||
|
setTypingAttenuation(settings.typingAttenuation);
|
||||||
|
setAgc(settings.agc);
|
||||||
|
setAgcBoost(settings.agcBoostDb);
|
||||||
|
setOpusParameters(OpusParameters.from(settings));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ public interface VoiceOutput {
|
|||||||
|
|
||||||
boolean isClientMuted(int clientId);
|
boolean isClientMuted(int clientId);
|
||||||
|
|
||||||
|
/** Scales one client's voice on top of the master volume; 1.0 is unchanged, the default. */
|
||||||
|
void setClientVolume(int clientId, double gain);
|
||||||
|
|
||||||
|
double getClientVolume(int clientId);
|
||||||
|
|
||||||
void setOutputDevice(String device);
|
void setOutputDevice(String device);
|
||||||
|
|
||||||
void removeClient(int clientId);
|
void removeClient(int clientId);
|
||||||
|
|||||||
@@ -0,0 +1,379 @@
|
|||||||
|
package com.ts3client.audio;
|
||||||
|
|
||||||
|
import com.github.manevolent.ts3j.enums.CodecType;
|
||||||
|
import com.ts3client.audio.opus.OpusCodec;
|
||||||
|
import com.ts3client.audio.opus.OpusDecoder;
|
||||||
|
|
||||||
|
import java.util.function.BiConsumer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One speaker's incoming voice: packets go into an adaptive {@link JitterBuffer} as they
|
||||||
|
* arrive, and every {@link #pull} plays out the next 20 ms, concealing what went missing.
|
||||||
|
*
|
||||||
|
* <p>The buffer is driven the way the official client drives it. A speaker has one timeline,
|
||||||
|
* {@code unwrap16(packetId) * 60} ticks, which is never re-anchored between talk bursts: the
|
||||||
|
* sender stops numbering when it stops talking and playback stops at the same point, so the
|
||||||
|
* next burst lands where the last one left off and the delay the buffer has learned carries
|
||||||
|
* over. Only a long silence drops the timeline.
|
||||||
|
*
|
||||||
|
* <p>An empty packet is the talker letting go of the key. It never enters the buffer; its
|
||||||
|
* position is remembered, the burst ends once playback reaches it, and its slot is played as
|
||||||
|
* silence rather than concealed, since nothing was lost there.
|
||||||
|
*
|
||||||
|
* <p>The stream keeps no clock of its own: whoever pulls sets the pace, one call per
|
||||||
|
* 20 ms frame. {@link #offer} and {@link #stop} may be called from any thread; pulls
|
||||||
|
* must come from one thread at a time.
|
||||||
|
*/
|
||||||
|
final class VoiceStream {
|
||||||
|
|
||||||
|
/** Returned by {@link #pull} when no talk burst is playing. */
|
||||||
|
static final int IDLE = -1;
|
||||||
|
|
||||||
|
/** Longest Opus frame (120 ms @ 48 kHz) a packet may decode to, per channel. */
|
||||||
|
static final int MAX_FRAME = 5760;
|
||||||
|
|
||||||
|
private static final int FRAME = VoiceFormat.FRAME_SIZE;
|
||||||
|
private static final int FRAME_TICKS = JitterBuffer.TEAMSPEAK_TICKS_PER_FRAME;
|
||||||
|
/** Pulls in a row with nothing held before a burst whose end marker got lost is given up. */
|
||||||
|
private static final int EMPTY_PULLS_TO_END = 10;
|
||||||
|
/** How far back a talker's stop is remembered, as in the official client. */
|
||||||
|
private static final int STOP_MARK_WINDOW_TICKS = 3000;
|
||||||
|
/** Silence after which the timeline and the delay estimate are dropped, as in the official client. */
|
||||||
|
private static final long IDLE_RESET_NANOS = 5_000_000_000L;
|
||||||
|
private static final int EPOCH_UNSET = Integer.MIN_VALUE;
|
||||||
|
|
||||||
|
/** What a pull plays, decided under the lock and produced outside it. */
|
||||||
|
private enum Source { PACKET, CONCEAL, SILENCE, SURPLUS }
|
||||||
|
|
||||||
|
final int clientId;
|
||||||
|
private final OpusCodec opus;
|
||||||
|
private final BiConsumer<Integer, Boolean> talkListener;
|
||||||
|
|
||||||
|
private final Object lock = new Object();
|
||||||
|
// Guarded by lock.
|
||||||
|
private final JitterBuffer jitter = JitterBuffer.forTeamSpeakVoice(FRAME_TICKS);
|
||||||
|
private int epoch = EPOCH_UNSET;
|
||||||
|
private int lastSeq;
|
||||||
|
private int[] stopMarks = new int[8];
|
||||||
|
private int stopMarkCount;
|
||||||
|
/** Channels of the latest packet: TeamSpeak streams {@code OPUS_MUSIC} in stereo. */
|
||||||
|
private int channels = 1;
|
||||||
|
private boolean playing;
|
||||||
|
private int emptyPulls;
|
||||||
|
/** Frames of decoded audio left over from the last packet, still to be played. */
|
||||||
|
private int surplusFrames;
|
||||||
|
/** Frames of concealment still owed to a delay increase. */
|
||||||
|
private int concealOwed;
|
||||||
|
/** Set when the timeline was dropped; the decoder follows on the next pull. */
|
||||||
|
private boolean resetDecoder;
|
||||||
|
|
||||||
|
/** Guards the decoder: a close from another thread may reach it mid-pull. */
|
||||||
|
private final Object decoderLock = new Object();
|
||||||
|
private final float[] pcm = new float[MAX_FRAME * VoiceFormat.MAX_CHANNELS];
|
||||||
|
/** Audio a packet decoded to beyond its first frame; only the pulling thread touches it. */
|
||||||
|
private final float[] surplus = new float[MAX_FRAME * VoiceFormat.MAX_CHANNELS];
|
||||||
|
private int surplusOffset;
|
||||||
|
private int surplusChannels;
|
||||||
|
private OpusDecoder decoder;
|
||||||
|
private int decoderChannels;
|
||||||
|
private boolean closed;
|
||||||
|
|
||||||
|
private volatile boolean talking;
|
||||||
|
private volatile long lastPacketNanos;
|
||||||
|
|
||||||
|
VoiceStream(int clientId, OpusCodec opus, BiConsumer<Integer, Boolean> talkListener) {
|
||||||
|
this.clientId = clientId;
|
||||||
|
this.opus = opus;
|
||||||
|
this.talkListener = talkListener;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int channelsFor(CodecType codec) {
|
||||||
|
return codec == CodecType.OPUS_MUSIC ? 2 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Places an incoming packet on the speaker's timeline; an empty one marks the end of the
|
||||||
|
* talk burst.
|
||||||
|
*
|
||||||
|
* @return true if the packet started a new burst, so playout has to begin
|
||||||
|
*/
|
||||||
|
boolean offer(int packetId, CodecType codec, byte[] data) {
|
||||||
|
long now = System.nanoTime();
|
||||||
|
boolean longIdle = lastPacketNanos != 0 && now - lastPacketNanos >= IDLE_RESET_NANOS;
|
||||||
|
lastPacketNanos = now;
|
||||||
|
boolean end = data == null || data.length == 0;
|
||||||
|
boolean started = false;
|
||||||
|
|
||||||
|
synchronized (lock) {
|
||||||
|
if (longIdle && !playing) {
|
||||||
|
jitter.reset();
|
||||||
|
epoch = EPOCH_UNSET;
|
||||||
|
stopMarkCount = 0;
|
||||||
|
resetDecoder = true;
|
||||||
|
}
|
||||||
|
boolean fresh = epoch == EPOCH_UNSET;
|
||||||
|
int ticks = unwrap(packetId & 0xFFFF) * FRAME_TICKS;
|
||||||
|
if (end) {
|
||||||
|
markStop(ticks);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Playback already went past it; the buffer would only drop it.
|
||||||
|
if (!fresh && ticks - jitter.position() < 0) return false;
|
||||||
|
|
||||||
|
channels = channelsFor(codec);
|
||||||
|
jitter.put(data, ticks, FRAME_TICKS);
|
||||||
|
if (!playing) {
|
||||||
|
playing = true;
|
||||||
|
started = true;
|
||||||
|
emptyPulls = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
markTalking(true);
|
||||||
|
return started;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plays out the next frame into {@code out}, interleaved over {@code outChannels} and
|
||||||
|
* scaled by {@code gain} but not clipped, so it can still be mixed.
|
||||||
|
*
|
||||||
|
* @param out room for {@link VoiceFormat#FRAME_SIZE} samples per channel
|
||||||
|
* @return the samples written per channel, at most one frame; 0 if a packet could not be
|
||||||
|
* decoded, or {@link #IDLE} once the burst is over
|
||||||
|
*/
|
||||||
|
int pull(float[] out, int outChannels, double gain) {
|
||||||
|
Source source;
|
||||||
|
byte[] packet = null;
|
||||||
|
int surplusTake = 0;
|
||||||
|
int packetChannels;
|
||||||
|
boolean reset;
|
||||||
|
synchronized (lock) {
|
||||||
|
if (!playing) return IDLE;
|
||||||
|
reset = resetDecoder;
|
||||||
|
resetDecoder = false;
|
||||||
|
packetChannels = channels;
|
||||||
|
|
||||||
|
if (surplusFrames > 0 || concealOwed > 0) {
|
||||||
|
// Still playing out what the last get() handed over.
|
||||||
|
if (surplusFrames > 0) {
|
||||||
|
source = Source.SURPLUS;
|
||||||
|
surplusTake = Math.min(FRAME, surplusFrames);
|
||||||
|
surplusFrames -= surplusTake;
|
||||||
|
} else {
|
||||||
|
source = Source.CONCEAL;
|
||||||
|
concealOwed--;
|
||||||
|
}
|
||||||
|
jitter.remainingSpan(remainingTicks());
|
||||||
|
} else {
|
||||||
|
boolean empty = jitter.isEmpty();
|
||||||
|
emptyPulls = empty ? emptyPulls + 1 : 0;
|
||||||
|
if (empty && (stoppedAt(jitter.position()) || emptyPulls >= EMPTY_PULLS_TO_END)) {
|
||||||
|
stopLocked();
|
||||||
|
source = null;
|
||||||
|
} else {
|
||||||
|
JitterBuffer.Status status = jitter.get(FRAME_TICKS);
|
||||||
|
switch (status) {
|
||||||
|
case OK -> {
|
||||||
|
source = Source.PACKET;
|
||||||
|
packet = jitter.payload();
|
||||||
|
}
|
||||||
|
case MISSING -> source = isStopMark(jitter.timestamp()) ? Source.SILENCE : Source.CONCEAL;
|
||||||
|
default -> {
|
||||||
|
// The buffer grows its delay by inserting this many frames.
|
||||||
|
source = Source.CONCEAL;
|
||||||
|
concealOwed = Math.max(1, jitter.span() / FRAME_TICKS) - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
jitter.tick();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (source == null) {
|
||||||
|
markTalking(false);
|
||||||
|
return IDLE;
|
||||||
|
}
|
||||||
|
|
||||||
|
int frames;
|
||||||
|
synchronized (decoderLock) {
|
||||||
|
if (closed) return 0;
|
||||||
|
try {
|
||||||
|
if (reset && decoder != null) decoder.reset();
|
||||||
|
frames = produce(source, packet, surplusTake, packetChannels, out, outChannels, gain);
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return frames;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int produce(Source source, byte[] packet, int surplusTake, int packetChannels, float[] out,
|
||||||
|
int outChannels, double gain) {
|
||||||
|
switch (source) {
|
||||||
|
case SURPLUS -> {
|
||||||
|
emit(surplus, surplusOffset, surplusTake, surplusChannels, out, outChannels, gain);
|
||||||
|
surplusOffset += surplusTake;
|
||||||
|
return surplusTake;
|
||||||
|
}
|
||||||
|
case SILENCE -> {
|
||||||
|
java.util.Arrays.fill(out, 0, FRAME * outChannels, 0f);
|
||||||
|
return FRAME;
|
||||||
|
}
|
||||||
|
case CONCEAL -> {
|
||||||
|
int frames = decoderFor(decoderChannels == 0 ? packetChannels : decoderChannels).conceal(pcm, FRAME);
|
||||||
|
emit(pcm, 0, frames, decoderChannels, out, outChannels, gain);
|
||||||
|
return frames;
|
||||||
|
}
|
||||||
|
default -> {
|
||||||
|
int frames = decoderFor(packetChannels).decode(packet, pcm);
|
||||||
|
int played = Math.min(frames, FRAME);
|
||||||
|
emit(pcm, 0, played, packetChannels, out, outChannels, gain);
|
||||||
|
if (frames > played) keepSurplus(played, frames - played, packetChannels);
|
||||||
|
return played;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Holds back what a packet decoded to beyond one frame, for the following pulls. */
|
||||||
|
private void keepSurplus(int from, int frames, int channels) {
|
||||||
|
System.arraycopy(pcm, from * channels, surplus, 0, frames * channels);
|
||||||
|
surplusOffset = 0;
|
||||||
|
surplusChannels = channels;
|
||||||
|
synchronized (lock) {
|
||||||
|
if (playing) surplusFrames = frames;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ticks of audio already handed out but not yet played; guarded by the lock. */
|
||||||
|
private int remainingTicks() {
|
||||||
|
return (surplusFrames * FRAME_TICKS + FRAME - 1) / FRAME + concealOwed * FRAME_TICKS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Matches decoded audio to the output: duplicates mono across stereo and folds a stereo
|
||||||
|
* (music) stream down onto mono.
|
||||||
|
*/
|
||||||
|
private static void emit(float[] src, int fromFrame, int frames, int channels, float[] out, int outChannels,
|
||||||
|
double gain) {
|
||||||
|
int base = fromFrame * channels;
|
||||||
|
for (int i = 0, k = 0; i < frames; i++) {
|
||||||
|
for (int c = 0; c < outChannels; c++, k++) {
|
||||||
|
double v;
|
||||||
|
if (channels == outChannels) {
|
||||||
|
v = src[base + i * channels + c];
|
||||||
|
} else if (channels == 1) {
|
||||||
|
v = src[base + i];
|
||||||
|
} else {
|
||||||
|
double sum = 0;
|
||||||
|
for (int s = 0; s < channels; s++) sum += src[base + i * channels + s];
|
||||||
|
v = sum / channels;
|
||||||
|
}
|
||||||
|
out[k] = (float) (v * gain);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a decoder matching the stream's channel count, rebuilding it when a speaker
|
||||||
|
* switches between the mono {@code OPUS_VOICE} and stereo {@code OPUS_MUSIC} codecs.
|
||||||
|
*/
|
||||||
|
private OpusDecoder decoderFor(int channels) {
|
||||||
|
if (decoder == null || decoderChannels != channels) {
|
||||||
|
if (decoder != null) decoder.close();
|
||||||
|
decoder = opus.createDecoder(VoiceFormat.SAMPLE_RATE, MAX_FRAME, channels);
|
||||||
|
decoderChannels = channels;
|
||||||
|
}
|
||||||
|
return decoder;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Widens the 16-bit packet id into a continuous sequence. A packet that arrives after the
|
||||||
|
* counter wrapped, but was sent before it, counts back into the previous epoch.
|
||||||
|
*/
|
||||||
|
private int unwrap(int seq) {
|
||||||
|
if (epoch == EPOCH_UNSET) {
|
||||||
|
epoch = 0;
|
||||||
|
lastSeq = seq;
|
||||||
|
} else {
|
||||||
|
int delta = (short) (seq - lastSeq);
|
||||||
|
if (delta > 0) {
|
||||||
|
if (seq < lastSeq) epoch++;
|
||||||
|
lastSeq = seq;
|
||||||
|
} else if (seq > lastSeq) {
|
||||||
|
epoch--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (epoch << 16) | seq;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void markStop(int ticks) {
|
||||||
|
int position = jitter.position();
|
||||||
|
int kept = 0;
|
||||||
|
for (int i = 0; i < stopMarkCount; i++) {
|
||||||
|
if (position - stopMarks[i] < STOP_MARK_WINDOW_TICKS) stopMarks[kept++] = stopMarks[i];
|
||||||
|
}
|
||||||
|
stopMarkCount = kept;
|
||||||
|
if (stopMarkCount == stopMarks.length) stopMarks = java.util.Arrays.copyOf(stopMarks, stopMarkCount * 2);
|
||||||
|
stopMarks[stopMarkCount++] = ticks;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isStopMark(int ticks) {
|
||||||
|
for (int i = 0; i < stopMarkCount; i++) {
|
||||||
|
if (stopMarks[i] == ticks) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the talker let go at or shortly before {@code position}, so the burst is spoken out. */
|
||||||
|
private boolean stoppedAt(int position) {
|
||||||
|
for (int i = 0; i < stopMarkCount; i++) {
|
||||||
|
int behind = position - stopMarks[i];
|
||||||
|
if (behind >= 0 && behind < STOP_MARK_WINDOW_TICKS) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ends the current talk burst, if any; the next packet starts a new one. */
|
||||||
|
void stop() {
|
||||||
|
synchronized (lock) {
|
||||||
|
stopLocked();
|
||||||
|
}
|
||||||
|
markTalking(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void stopLocked() {
|
||||||
|
playing = false;
|
||||||
|
surplusFrames = 0;
|
||||||
|
concealOwed = 0;
|
||||||
|
emptyPulls = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean isPlaying() {
|
||||||
|
synchronized (lock) {
|
||||||
|
return playing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean isTalking() {
|
||||||
|
return talking;
|
||||||
|
}
|
||||||
|
|
||||||
|
long lastPacketNanos() {
|
||||||
|
return lastPacketNanos;
|
||||||
|
}
|
||||||
|
|
||||||
|
void close() {
|
||||||
|
synchronized (lock) {
|
||||||
|
stopLocked();
|
||||||
|
}
|
||||||
|
synchronized (decoderLock) {
|
||||||
|
closed = true;
|
||||||
|
if (decoder != null) decoder.close();
|
||||||
|
decoder = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void markTalking(boolean talking) {
|
||||||
|
if (this.talking == talking) return;
|
||||||
|
this.talking = talking;
|
||||||
|
talkListener.accept(clientId, talking);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package com.ts3client.audio.opus;
|
||||||
|
|
||||||
|
/** An {@link OpusEncoder} configured through libopus' own controls; a binding supplies {@link #ctl}. */
|
||||||
|
public abstract class CtlOpusEncoder implements OpusEncoder {
|
||||||
|
|
||||||
|
/** Calls {@code opus_encoder_ctl(encoder, request, value)}. */
|
||||||
|
protected abstract void ctl(int request, int value);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setBitrate(int bitsPerSecond) {
|
||||||
|
ctl(LibOpus.SET_BITRATE_REQUEST, bitsPerSecond);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setComplexity(int complexity) {
|
||||||
|
ctl(LibOpus.SET_COMPLEXITY_REQUEST, Math.max(0, Math.min(10, complexity)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setVbr(boolean vbr) {
|
||||||
|
ctl(LibOpus.SET_VBR_REQUEST, vbr ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setInbandFec(boolean fec) {
|
||||||
|
ctl(LibOpus.SET_INBAND_FEC_REQUEST, fec ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setExpectedPacketLoss(int percent) {
|
||||||
|
ctl(LibOpus.SET_PACKET_LOSS_PERC_REQUEST, Math.max(0, Math.min(100, percent)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setSignal(Signal signal) {
|
||||||
|
ctl(LibOpus.SET_SIGNAL_REQUEST, signal == Signal.MUSIC ? LibOpus.SIGNAL_MUSIC : LibOpus.SIGNAL_VOICE);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user