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:
@@ -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" />
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user