radio: PTT double-tap latch + single-tap off (in-chat & overlay); compressor initial gain = mid-range

This commit is contained in:
evgeny committed 2026-09-29 20:00:28 +03:00
1 parent 556b0bc7b7
commit 61bf235ef4
3 files changed
+101 -16

No files matched your search

@@ -12,6 +12,7 @@ import android.graphics.PixelFormat
import android.graphics.drawable.GradientDrawable
import android.os.Build
import android.os.IBinder
import android.os.SystemClock
import android.provider.Settings
import android.view.Gravity
import android.view.LayoutInflater
@@ -29,9 +30,10 @@ import kotlinx.coroutines.flow.StateFlow
/**
* Оверлей PTT поверх других приложений: плавающая круглая кнопка, drag-and-drop.
* Удержание активирует передачу (RadioAudioEngine.talkBegin/talkEnd), отпускание —
* завершает. Работает пока включена опция «PTT поверх приложений» и разрешено
* «поверх других приложений» (SYSTEM_ALERT_WINDOW).
* Удержание > PTT_HOLD_MS активирует передачу (RadioAudioEngine.talkBegin/talkEnd),
* отпускание — завершает. Двойной тап фиксирует передачу (latch) без удержания,
* повторный двойной тап или drag — отбой. Работает пока включена опция
* «PTT поверх приложений» и разрешено «поверх других приложений» (SYSTEM_ALERT_WINDOW).
*/
class RadioOverlayService : Service() {
@@ -41,6 +43,7 @@ class RadioOverlayService : Service() {
const val COLOR_IDLE = 0xFF81C784.toInt()
const val COLOR_TALKING = 0xFFE53935.toInt()
const val DRAG_LOCK_MS = 300L
const val PTT_HOLD_MS = 250L
private val _isShown = MutableStateFlow(false)
val isShown: StateFlow<Boolean> = _isShown
@@ -135,7 +138,7 @@ class RadioOverlayService : Service() {
val wm = windowManager ?: return
val v = TextView(this).apply {
text = "\uD83C\uDF99"
textSize = 30f
textSize = 21f
gravity = android.view.Gravity.CENTER
background = GradientDrawable().apply {
shape = GradientDrawable.OVAL
@@ -143,6 +146,7 @@ class RadioOverlayService : Service() {
}
}
val touchSlop = (ViewConfiguration.get(this).scaledTouchSlop * 2.5f).toInt()
val doubleTapTimeout = ViewConfiguration.getDoubleTapTimeout().toLong()
val lp = WindowManager.LayoutParams(
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.WRAP_CONTENT,
@@ -153,7 +157,7 @@ class RadioOverlayService : Service() {
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
PixelFormat.TRANSLUCENT
).apply {
width = dp(96); height = dp(96)
width = dp(67); height = dp(67)
gravity = Gravity.TOP or Gravity.START
x = 16; y = 200
}
@@ -162,6 +166,9 @@ class RadioOverlayService : Service() {
private var touchX = 0f; private var touchY = 0f
private var dragging = false
private var dragAllowed = true
private var latched = false
private var lastTapUpTime = 0L
private val talkBeginRunnable = Runnable { beginTalk() }
override fun onTouch(view: View, event: MotionEvent): Boolean {
when (event.action) {
@@ -172,7 +179,8 @@ class RadioOverlayService : Service() {
touchY = event.rawY
dragging = false
dragAllowed = true
beginTalk()
view.removeCallbacks(talkBeginRunnable)
view.postDelayed(talkBeginRunnable, PTT_HOLD_MS)
return true
}
MotionEvent.ACTION_MOVE -> {
@@ -185,6 +193,8 @@ class RadioOverlayService : Service() {
dragAllowed = false
} else if (abs(event.rawX - touchX) > touchSlop || abs(event.rawY - touchY) > touchSlop) {
dragging = true
view.removeCallbacks(talkBeginRunnable)
latched = false
endTalkImmediate()
lp.x = startX + (event.rawX - touchX).toInt()
lp.y = startY + (event.rawY - touchY).toInt()
@@ -193,8 +203,27 @@ class RadioOverlayService : Service() {
}
return true
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
endTalk()
MotionEvent.ACTION_UP -> {
view.removeCallbacks(talkBeginRunnable)
val now = SystemClock.uptimeMillis()
val isDouble = lastTapUpTime != 0L && (now - lastTapUpTime) <= doubleTapTimeout
if (latched) {
latched = false
lastTapUpTime = 0L
endTalk()
} else if (isDouble) {
latched = true
lastTapUpTime = 0L
beginTalk()
} else {
lastTapUpTime = now
endTalk()
}
return true
}
MotionEvent.ACTION_CANCEL -> {
view.removeCallbacks(talkBeginRunnable)
if (!latched) endTalk()
return true
}
}
@@ -2,11 +2,13 @@ package com.utun.chat.ui.screens
import android.content.ContentResolver
import android.net.Uri
import android.os.SystemClock
import android.provider.OpenableColumns
import android.view.ViewConfiguration
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
@@ -42,6 +44,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -276,7 +279,10 @@ fun ChatScreen(
}
}
/* Круглая PTT-кнопка рации: держим — говорим. */
/* Круглая PTT-кнопка рации: удержание > PTT_HOLD_MS — говорим, двойной тап —
* фиксирует передачу (latch) без удержания, следующий тап/двойной тап — отбой. */
private const val PTT_HOLD_MS = 250L
@Composable
private fun RadioPttButton(
modifier: Modifier = Modifier,
@@ -284,17 +290,60 @@ private fun RadioPttButton(
onTalkBegin: () -> Unit,
onTalkEnd: () -> Unit
) {
val doubleTapTimeout = ViewConfiguration.getDoubleTapTimeout().toLong()
var latched by remember { mutableStateOf(false) }
var lastTapUp by remember { mutableStateOf(0L) }
Surface(
modifier = modifier
.size(96.dp)
.pointerInput(Unit) {
detectTapGestures(
onPress = {
awaitPointerEventScope {
while (true) {
val down = awaitFirstDown(requireUnconsumed = false)
val downId = down.id
down.consume()
val now = SystemClock.uptimeMillis()
val isSecondTap = lastTapUp != 0L && (now - lastTapUp) <= doubleTapTimeout
/* быстрое отпускание (до PTT_HOLD_MS) — это тап */
var tapped = false
withTimeoutOrNull(PTT_HOLD_MS) {
while (true) {
val ev = awaitPointerEvent()
val c = ev.changes.firstOrNull { it.id == downId } ?: return@withTimeoutOrNull
if (!c.pressed) { tapped = true; return@withTimeoutOrNull }
c.consume()
}
}
if (tapped) {
if (latched) {
latched = false
lastTapUp = 0L
onTalkEnd()
} else if (isSecondTap) {
latched = true
lastTapUp = 0L
onTalkBegin()
} else {
lastTapUp = SystemClock.uptimeMillis()
}
continue
}
/* удержание — говорим, пока не отпустят */
onTalkBegin()
tryAwaitRelease()
while (true) {
val ev = awaitPointerEvent()
val c = ev.changes.firstOrNull { it.id == downId } ?: break
c.consume()
if (!c.pressed) break
}
onTalkEnd()
}
)
}
},
shape = CircleShape,
color = if (isTalking) Color(0xFFE53935) else Color(0xFF81C784),