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.MODIFY_AUDIO_SETTINGS" />
|
||||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<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" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||||
|
|||||||
@@ -14,7 +14,11 @@ import androidx.lifecycle.LifecycleService
|
|||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import com.ts3client.config.Settings
|
||||||
|
import com.ts3client.net.ClientStatus
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -26,6 +30,7 @@ class ConnectionService : LifecycleService() {
|
|||||||
private lateinit var sessions: SessionController
|
private lateinit var sessions: SessionController
|
||||||
private lateinit var wifiLock: WifiManager.WifiLock
|
private lateinit var wifiLock: WifiManager.WifiLock
|
||||||
private lateinit var wakeLock: PowerManager.WakeLock
|
private lateinit var wakeLock: PowerManager.WakeLock
|
||||||
|
private lateinit var overlay: PttOverlay
|
||||||
private var foregroundTypes = 0
|
private var foregroundTypes = 0
|
||||||
|
|
||||||
override fun onCreate() {
|
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 {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
@@ -80,6 +102,7 @@ class ConnectionService : LifecycleService() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
|
overlay.hide()
|
||||||
wifiLock.release()
|
wifiLock.release()
|
||||||
wakeLock.release()
|
wakeLock.release()
|
||||||
super.onDestroy()
|
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
|
* @param rgb the colour for art that paints in the text colour
|
||||||
*/
|
*/
|
||||||
fun render(keys: List<String>, size: Int, rgb: Int): Bitmap? {
|
fun render(keys: List<String>, size: Int, rgb: Int): Bitmap? = render(lookupOrder, keys, size, rgb)
|
||||||
val order = lookupOrder
|
|
||||||
|
/** 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"
|
val cacheKey = "${order.first().id()}/${keys.joinToString(",")}@$size#$rgb"
|
||||||
cache.get(cacheKey)?.let { return it }
|
cache.get(cacheKey)?.let { return it }
|
||||||
for (key in keys) {
|
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)
|
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. */
|
/** Whether the app is on screen; pokes and private messages are notified of while it is not. */
|
||||||
@Volatile
|
var foreground: Boolean
|
||||||
var foreground = false
|
get() = _onScreen.value
|
||||||
set(value) {
|
set(value) {
|
||||||
field = value
|
_onScreen.value = value
|
||||||
if (value) notifier.clearPokes()
|
if (value) notifier.clearPokes()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,6 +324,16 @@ class SessionController(private val context: Context) {
|
|||||||
if (session.isConnected) conn.microphone?.setPushToTalk(pressed)
|
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. */
|
/** Saves the settings and puts the voice and device ones into effect at once. */
|
||||||
fun applySettings() {
|
fun applySettings() {
|
||||||
settings.save()
|
settings.save()
|
||||||
@@ -551,6 +564,7 @@ class SessionController(private val context: Context) {
|
|||||||
private companion object {
|
private companion object {
|
||||||
const val TREE_FRAME_MS = 30L
|
const val TREE_FRAME_MS = 30L
|
||||||
const val DEFAULT_PORT = 9987
|
const val DEFAULT_PORT = 9987
|
||||||
|
const val OVERLAY_ENABLED = "enabled"
|
||||||
|
|
||||||
fun parseAddress(address: String): Pair<String, Int> {
|
fun parseAddress(address: String): Pair<String, Int> {
|
||||||
val host = address.substringBefore(':').trim()
|
val host = address.substringBefore(':').trim()
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
package com.ts3client.android.ui
|
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.Canvas
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
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.CornerRadius
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.geometry.Size
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.LifecycleStartEffect
|
import androidx.lifecycle.compose.LifecycleStartEffect
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
@@ -91,6 +98,7 @@ fun SoundSettingsScreen(vm: SessionController, onBack: () -> Unit) {
|
|||||||
change { this.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
|
val gated = inputMode == Settings.InputMode.VOICE_ACTIVATION && vadMode != Settings.VadMode.AUTOMATIC
|
||||||
LevelMeter(level, threshold.takeIf { gated })
|
LevelMeter(level, threshold.takeIf { gated })
|
||||||
if (!level.live) {
|
if (!level.live) {
|
||||||
@@ -196,6 +204,21 @@ private val VAD_MODES = listOf(
|
|||||||
Settings.VadMode.HYBRID to "Hybrid",
|
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. */
|
/** Picks a device by its settings id; one that is not plugged in now still shows as chosen. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun DevicePicker(label: String, devices: List<AudioDevice>, selected: String, onSelect: (String) -> Unit) {
|
private fun DevicePicker(label: String, devices: List<AudioDevice>, selected: String, onSelect: (String) -> Unit) {
|
||||||
|
|||||||
Reference in New Issue
Block a user