Browse Source

chatgui-android: PTT-оверлей — talking flow, цвет idle/talking, drag-lock

- RadioAudioEngine: StateFlow talking для реактивной подсветки кнопки
- RadioOverlayService: градиент idle/talking, drag-lock, сброс isShown при destroy
- ChatScreen: скрывать PTT-кнопку при активном оверлее, подсветка по talking
proxy
evgeny 2 weeks ago
parent
commit
5e955168d2
  1. 8
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioAudioEngine.kt
  2. 42
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioOverlayService.kt
  3. 11
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt

8
tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioAudioEngine.kt

@ -8,6 +8,8 @@ import android.media.AudioRecord
import android.media.AudioTrack
import android.media.MediaRecorder
import com.utun.chat.ChatApplication
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* Полудуплексный аудио-движок рации (аналог CallAudioEngine, но с PTT).
@ -44,6 +46,9 @@ class RadioAudioEngine {
@Volatile private var groupId = 0L
@Volatile private var talking = false
private val _talkingFlow = MutableStateFlow(false)
val talkingFlow: StateFlow<Boolean> = _talkingFlow
fun isActive(): Boolean = active
fun isTalking(): Boolean = talking
@ -128,6 +133,7 @@ class RadioAudioEngine {
if (!active) return
active = false
talking = false
_talkingFlow.value = false
captureThread?.join(1000); captureThread = null
playThread?.join(1000); playThread = null
@ -148,6 +154,7 @@ class RadioAudioEngine {
fun talkBegin() {
if (!active || talking) return
talking = true
_talkingFlow.value = true
NativeLib.radioTalkBegin(groupId)
}
@ -155,6 +162,7 @@ class RadioAudioEngine {
fun talkEnd() {
if (!active || !talking) return
talking = false
_talkingFlow.value = false
NativeLib.radioTalkEnd(groupId)
}

42
tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioOverlayService.kt

@ -6,6 +6,7 @@ import android.app.Service
import android.content.Context
import android.content.Intent
import android.graphics.PixelFormat
import android.graphics.drawable.GradientDrawable
import android.os.Build
import android.os.IBinder
import android.provider.Settings
@ -13,10 +14,13 @@ import android.view.Gravity
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.view.ViewConfiguration
import android.view.WindowManager
import android.widget.TextView
import androidx.core.app.NotificationCompat
import kotlin.math.abs
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* Оверлей PTT поверх других приложений: плавающая круглая кнопка, drag-and-drop.
@ -29,6 +33,12 @@ class RadioOverlayService : Service() {
companion object {
const val CHANNEL_ID = "radio_overlay"
const val NOTIF_ID = 1002
const val COLOR_IDLE = 0xFF81C784.toInt()
const val COLOR_TALKING = 0xFFE53935.toInt()
const val DRAG_LOCK_MS = 300L
private val _isShown = MutableStateFlow(false)
val isShown: StateFlow<Boolean> = _isShown
fun canDrawOverlays(ctx: Context): Boolean =
Build.VERSION.SDK_INT < Build.VERSION_CODES.M || Settings.canDrawOverlays(ctx)
@ -89,8 +99,12 @@ class RadioOverlayService : Service() {
text = "\uD83C\uDF99"
textSize = 30f
gravity = android.view.Gravity.CENTER
setBackgroundColor(0xFFE74C3C.toInt())
background = GradientDrawable().apply {
shape = GradientDrawable.OVAL
setColor(COLOR_IDLE)
}
}
val touchSlop = (ViewConfiguration.get(this).scaledTouchSlop * 2.5f).toInt()
val lp = WindowManager.LayoutParams(
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.WRAP_CONTENT,
@ -109,6 +123,7 @@ class RadioOverlayService : Service() {
private var startX = 0; private var startY = 0
private var touchX = 0f; private var touchY = 0f
private var dragging = false
private var dragAllowed = true
override fun onTouch(view: View, event: MotionEvent): Boolean {
when (event.action) {
@ -118,16 +133,25 @@ class RadioOverlayService : Service() {
touchX = event.rawX
touchY = event.rawY
dragging = false
dragAllowed = true
beginTalk()
return true
}
MotionEvent.ACTION_MOVE -> {
if (abs(event.rawX - touchX) > 8 || abs(event.rawY - touchY) > 8) {
dragging = true
endTalk()
if (dragging) {
lp.x = startX + (event.rawX - touchX).toInt()
lp.y = startY + (event.rawY - touchY).toInt()
wm.updateViewLayout(v, lp)
} else if (dragAllowed) {
if (event.eventTime - event.downTime > DRAG_LOCK_MS) {
dragAllowed = false
} else if (abs(event.rawX - touchX) > touchSlop || abs(event.rawY - touchY) > touchSlop) {
dragging = true
endTalk()
lp.x = startX + (event.rawX - touchX).toInt()
lp.y = startY + (event.rawY - touchY).toInt()
wm.updateViewLayout(v, lp)
}
}
return true
}
@ -142,27 +166,37 @@ class RadioOverlayService : Service() {
wm.addView(v, lp)
overlayView = v
params = lp
_isShown.value = true
LogManager.addLog("INFO", "RadioOverlay", "overlay shown")
}
private fun beginTalk() {
if (talking) return
talking = true
updateButtonColor()
RadioAudioEngine.instance().talkBegin()
}
private fun endTalk() {
if (!talking) return
talking = false
updateButtonColor()
RadioAudioEngine.instance().talkEnd()
}
private fun updateButtonColor() {
(overlayView?.background as? GradientDrawable)?.setColor(
if (talking) COLOR_TALKING else COLOR_IDLE
)
}
private fun dp(v: Int): Int = (v * resources.displayMetrics.density).toInt()
override fun onDestroy() {
endTalk()
overlayView?.let { windowManager?.removeView(it) }
overlayView = null
_isShown.value = false
LogManager.addLog("INFO", "RadioOverlay", "overlay destroyed")
super.onDestroy()
}

11
tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt

@ -21,12 +21,15 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.utun.chat.data.Channel
import com.utun.chat.data.Message
import com.utun.chat.data.NativeLib
import com.utun.chat.data.RadioAudioEngine
import com.utun.chat.data.RadioOverlayService
import com.utun.chat.ui.components.InputBar
import com.utun.chat.ui.components.MessageBubble
import com.utun.chat.viewmodel.ChatViewModel
@ -64,6 +67,8 @@ fun ChatScreen(
/* рация */
val radioActive by viewModel.radioActive.collectAsState()
val radioTalking by viewModel.radioTalking.collectAsState()
val overlayShown by RadioOverlayService.isShown.collectAsState()
val localTalking by RadioAudioEngine.instance().talkingFlow.collectAsState()
/* attach menu */
var showAttachMenu by remember { mutableStateOf(false) }
@ -173,9 +178,10 @@ fun ChatScreen(
onPlayVideo = { msg -> onPlayVideo(msg) })
}
}
if (radioActive) {
if (radioActive && !overlayShown) {
RadioPttButton(
modifier = Modifier.align(Alignment.TopCenter).padding(top = 12.dp),
isTalking = localTalking,
onTalkBegin = { viewModel.radioTalkBegin() },
onTalkEnd = { viewModel.radioTalkEnd() }
)
@ -260,6 +266,7 @@ fun ChatScreen(
@Composable
private fun RadioPttButton(
modifier: Modifier = Modifier,
isTalking: Boolean = false,
onTalkBegin: () -> Unit,
onTalkEnd: () -> Unit
) {
@ -276,7 +283,7 @@ private fun RadioPttButton(
)
},
shape = CircleShape,
color = MaterialTheme.colorScheme.error,
color = if (isTalking) Color(0xFFE53935) else Color(0xFF81C784),
shadowElevation = 6.dp
) {
Box(contentAlignment = Alignment.Center) {

Loading…
Cancel
Save