Float a push-to-talk button over other apps on Android

Held, it talks; a double tap whose second touch is held drags it. It shows
while the app is off screen in push-to-talk mode, half see-through at rest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-26 23:38:59 +00:00
parent 4b50f37735
commit f1e7c93594
6 changed files with 246 additions and 5 deletions

View File

@@ -7,6 +7,7 @@
<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" />

View File

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

View File

@@ -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<String>, size: Int, rgb: Int): Bitmap? {
val order = lookupOrder
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) {

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

View File

@@ -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<Boolean> = _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<Boolean> = _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<String, Int> {
val host = address.substringBefore(':').trim()

View File

@@ -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<AudioDevice>, selected: String, onSelect: (String) -> Unit) {