diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 866122a..5eb3c07 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -7,6 +7,7 @@
+
diff --git a/android/app/src/main/java/com/ts3client/android/ConnectionService.kt b/android/app/src/main/java/com/ts3client/android/ConnectionService.kt
index 9a46042..f95a516 100644
--- a/android/app/src/main/java/com/ts3client/android/ConnectionService.kt
+++ b/android/app/src/main/java/com/ts3client/android/ConnectionService.kt
@@ -14,7 +14,11 @@ import androidx.lifecycle.LifecycleService
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
+import com.ts3client.config.Settings
+import com.ts3client.net.ClientStatus
import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
/**
@@ -26,6 +30,7 @@ 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
override fun onCreate() {
@@ -54,6 +59,23 @@ class ConnectionService : LifecycleService() {
}
}
}
+
+ overlay = PttOverlay(this, sessions.icons, sessions::setPushToTalk)
+ lifecycleScope.launch {
+ combine(sessions.state, sessions.tree, sessions.onScreen, sessions.pttOverlay) { state, tree, onScreen, enabled ->
+ 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)
+ }
+ }
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
@@ -80,6 +102,7 @@ class ConnectionService : LifecycleService() {
}
override fun onDestroy() {
+ overlay.hide()
wifiLock.release()
wakeLock.release()
super.onDestroy()
diff --git a/android/app/src/main/java/com/ts3client/android/IconTheme.kt b/android/app/src/main/java/com/ts3client/android/IconTheme.kt
index c269f8c..8fbd59b 100644
--- a/android/app/src/main/java/com/ts3client/android/IconTheme.kt
+++ b/android/app/src/main/java/com/ts3client/android/IconTheme.kt
@@ -78,8 +78,12 @@ class IconTheme(private val context: Context, private val settings: Settings) {
*
* @param rgb the colour for art that paints in the text colour
*/
- fun render(keys: List, size: Int, rgb: Int): Bitmap? {
- val order = lookupOrder
+ fun render(keys: List, 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, keys: List, size: Int, rgb: Int): Bitmap? {
val cacheKey = "${order.first().id()}/${keys.joinToString(",")}@$size#$rgb"
cache.get(cacheKey)?.let { return it }
for (key in keys) {
diff --git a/android/app/src/main/java/com/ts3client/android/PttOverlay.kt b/android/app/src/main/java/com/ts3client/android/PttOverlay.kt
new file mode 100644
index 0000000..a30849e
--- /dev/null
+++ b/android/app/src/main/java/com/ts3client/android/PttOverlay.kt
@@ -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)
+ }
+}
diff --git a/android/app/src/main/java/com/ts3client/android/SessionController.kt b/android/app/src/main/java/com/ts3client/android/SessionController.kt
index f039caa..cb8ebfd 100644
--- a/android/app/src/main/java/com/ts3client/android/SessionController.kt
+++ b/android/app/src/main/java/com/ts3client/android/SessionController.kt
@@ -117,11 +117,14 @@ class SessionController(private val context: Context) {
if (value is ChatKey.Private) notifier.clear(value.clientId)
}
+ private val _onScreen = MutableStateFlow(false)
+ val onScreen: StateFlow = _onScreen
+
/** Whether the app is on screen; pokes and private messages are notified of while it is not. */
- @Volatile
- var foreground = false
+ var foreground: Boolean
+ get() = _onScreen.value
set(value) {
- field = value
+ _onScreen.value = value
if (value) notifier.clearPokes()
}
@@ -321,6 +324,16 @@ class SessionController(private val context: Context) {
if (session.isConnected) conn.microphone?.setPushToTalk(pressed)
}
+ 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 = _pttOverlay
+
+ fun setPttOverlay(enabled: Boolean) {
+ overlayPrefs.edit().putBoolean(OVERLAY_ENABLED, enabled).apply()
+ _pttOverlay.value = enabled
+ }
+
/** Saves the settings and puts the voice and device ones into effect at once. */
fun applySettings() {
settings.save()
@@ -551,6 +564,7 @@ class SessionController(private val context: Context) {
private companion object {
const val TREE_FRAME_MS = 30L
const val DEFAULT_PORT = 9987
+ const val OVERLAY_ENABLED = "enabled"
fun parseAddress(address: String): Pair {
val host = address.substringBefore(':').trim()
diff --git a/android/app/src/main/java/com/ts3client/android/ui/SoundSettingsScreen.kt b/android/app/src/main/java/com/ts3client/android/ui/SoundSettingsScreen.kt
index cc462ea..6ddfe24 100644
--- a/android/app/src/main/java/com/ts3client/android/ui/SoundSettingsScreen.kt
+++ b/android/app/src/main/java/com/ts3client/android/ui/SoundSettingsScreen.kt
@@ -1,5 +1,11 @@
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
@@ -20,6 +26,7 @@ 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
@@ -91,6 +98,7 @@ fun SoundSettingsScreen(vm: SessionController, onBack: () -> Unit) {
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) {
@@ -196,6 +204,21 @@ private val VAD_MODES = listOf(
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, selected: String, onSelect: (String) -> Unit) {