From f1e7c93594bc1259fcaf94443551f850e578dbe6 Mon Sep 17 00:00:00 2001 From: ericek111 Date: Sat, 26 Sep 2026 23:38:59 +0000 Subject: [PATCH] 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 --- android/app/src/main/AndroidManifest.xml | 1 + .../ts3client/android/ConnectionService.kt | 23 +++ .../java/com/ts3client/android/IconTheme.kt | 8 +- .../java/com/ts3client/android/PttOverlay.kt | 176 ++++++++++++++++++ .../ts3client/android/SessionController.kt | 20 +- .../android/ui/SoundSettingsScreen.kt | 23 +++ 6 files changed, 246 insertions(+), 5 deletions(-) create mode 100644 android/app/src/main/java/com/ts3client/android/PttOverlay.kt 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) {