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

38
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.Context
import android.content.Intent import android.content.Intent
import android.graphics.PixelFormat import android.graphics.PixelFormat
import android.graphics.drawable.GradientDrawable
import android.os.Build import android.os.Build
import android.os.IBinder import android.os.IBinder
import android.provider.Settings import android.provider.Settings
@ -13,10 +14,13 @@ import android.view.Gravity
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.MotionEvent import android.view.MotionEvent
import android.view.View import android.view.View
import android.view.ViewConfiguration
import android.view.WindowManager import android.view.WindowManager
import android.widget.TextView import android.widget.TextView
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import kotlin.math.abs import kotlin.math.abs
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/** /**
* Оверлей PTT поверх других приложений: плавающая круглая кнопка, drag-and-drop. * Оверлей PTT поверх других приложений: плавающая круглая кнопка, drag-and-drop.
@ -29,6 +33,12 @@ class RadioOverlayService : Service() {
companion object { companion object {
const val CHANNEL_ID = "radio_overlay" const val CHANNEL_ID = "radio_overlay"
const val NOTIF_ID = 1002 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 = fun canDrawOverlays(ctx: Context): Boolean =
Build.VERSION.SDK_INT < Build.VERSION_CODES.M || Settings.canDrawOverlays(ctx) Build.VERSION.SDK_INT < Build.VERSION_CODES.M || Settings.canDrawOverlays(ctx)
@ -89,8 +99,12 @@ class RadioOverlayService : Service() {
text = "\uD83C\uDF99" text = "\uD83C\uDF99"
textSize = 30f textSize = 30f
gravity = android.view.Gravity.CENTER 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( val lp = WindowManager.LayoutParams(
WindowManager.LayoutParams.WRAP_CONTENT, WindowManager.LayoutParams.WRAP_CONTENT,
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 startX = 0; private var startY = 0
private var touchX = 0f; private var touchY = 0f private var touchX = 0f; private var touchY = 0f
private var dragging = false private var dragging = false
private var dragAllowed = true
override fun onTouch(view: View, event: MotionEvent): Boolean { override fun onTouch(view: View, event: MotionEvent): Boolean {
when (event.action) { when (event.action) {
@ -118,17 +133,26 @@ class RadioOverlayService : Service() {
touchX = event.rawX touchX = event.rawX
touchY = event.rawY touchY = event.rawY
dragging = false dragging = false
dragAllowed = true
beginTalk() beginTalk()
return true return true
} }
MotionEvent.ACTION_MOVE -> { MotionEvent.ACTION_MOVE -> {
if (abs(event.rawX - touchX) > 8 || abs(event.rawY - touchY) > 8) { 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 dragging = true
endTalk() endTalk()
lp.x = startX + (event.rawX - touchX).toInt() lp.x = startX + (event.rawX - touchX).toInt()
lp.y = startY + (event.rawY - touchY).toInt() lp.y = startY + (event.rawY - touchY).toInt()
wm.updateViewLayout(v, lp) wm.updateViewLayout(v, lp)
} }
}
return true return true
} }
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
@ -142,27 +166,37 @@ class RadioOverlayService : Service() {
wm.addView(v, lp) wm.addView(v, lp)
overlayView = v overlayView = v
params = lp params = lp
_isShown.value = true
LogManager.addLog("INFO", "RadioOverlay", "overlay shown") LogManager.addLog("INFO", "RadioOverlay", "overlay shown")
} }
private fun beginTalk() { private fun beginTalk() {
if (talking) return if (talking) return
talking = true talking = true
updateButtonColor()
RadioAudioEngine.instance().talkBegin() RadioAudioEngine.instance().talkBegin()
} }
private fun endTalk() { private fun endTalk() {
if (!talking) return if (!talking) return
talking = false talking = false
updateButtonColor()
RadioAudioEngine.instance().talkEnd() 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() private fun dp(v: Int): Int = (v * resources.displayMetrics.density).toInt()
override fun onDestroy() { override fun onDestroy() {
endTalk() endTalk()
overlayView?.let { windowManager?.removeView(it) } overlayView?.let { windowManager?.removeView(it) }
overlayView = null overlayView = null
_isShown.value = false
LogManager.addLog("INFO", "RadioOverlay", "overlay destroyed") LogManager.addLog("INFO", "RadioOverlay", "overlay destroyed")
super.onDestroy() 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.utun.chat.data.Channel import com.utun.chat.data.Channel
import com.utun.chat.data.Message import com.utun.chat.data.Message
import com.utun.chat.data.NativeLib 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.InputBar
import com.utun.chat.ui.components.MessageBubble import com.utun.chat.ui.components.MessageBubble
import com.utun.chat.viewmodel.ChatViewModel import com.utun.chat.viewmodel.ChatViewModel
@ -64,6 +67,8 @@ fun ChatScreen(
/* рация */ /* рация */
val radioActive by viewModel.radioActive.collectAsState() val radioActive by viewModel.radioActive.collectAsState()
val radioTalking by viewModel.radioTalking.collectAsState() val radioTalking by viewModel.radioTalking.collectAsState()
val overlayShown by RadioOverlayService.isShown.collectAsState()
val localTalking by RadioAudioEngine.instance().talkingFlow.collectAsState()
/* attach menu */ /* attach menu */
var showAttachMenu by remember { mutableStateOf(false) } var showAttachMenu by remember { mutableStateOf(false) }
@ -173,9 +178,10 @@ fun ChatScreen(
onPlayVideo = { msg -> onPlayVideo(msg) }) onPlayVideo = { msg -> onPlayVideo(msg) })
} }
} }
if (radioActive) { if (radioActive && !overlayShown) {
RadioPttButton( RadioPttButton(
modifier = Modifier.align(Alignment.TopCenter).padding(top = 12.dp), modifier = Modifier.align(Alignment.TopCenter).padding(top = 12.dp),
isTalking = localTalking,
onTalkBegin = { viewModel.radioTalkBegin() }, onTalkBegin = { viewModel.radioTalkBegin() },
onTalkEnd = { viewModel.radioTalkEnd() } onTalkEnd = { viewModel.radioTalkEnd() }
) )
@ -260,6 +266,7 @@ fun ChatScreen(
@Composable @Composable
private fun RadioPttButton( private fun RadioPttButton(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
isTalking: Boolean = false,
onTalkBegin: () -> Unit, onTalkBegin: () -> Unit,
onTalkEnd: () -> Unit onTalkEnd: () -> Unit
) { ) {
@ -276,7 +283,7 @@ private fun RadioPttButton(
) )
}, },
shape = CircleShape, shape = CircleShape,
color = MaterialTheme.colorScheme.error, color = if (isTalking) Color(0xFFE53935) else Color(0xFF81C784),
shadowElevation = 6.dp shadowElevation = 6.dp
) { ) {
Box(contentAlignment = Alignment.Center) { Box(contentAlignment = Alignment.Center) {

Loading…
Cancel
Save