Browse Source

Defer video speaker activation until the phone is away from the ear

master
evgeny 4 days ago
parent
commit
f61e1c0a9f
  1. 49
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallController.kt
  2. 15
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallScreen.kt

49
tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallController.kt

@ -8,9 +8,13 @@ import com.utun.chat.MainActivity
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import java.nio.ByteBuffer
import java.nio.ByteOrder
@ -26,6 +30,10 @@ object CallController : TelecomCallManager.Listener {
val state: StateFlow<CallState?> = _state
private val _video = MutableStateFlow<CallVideoEngine?>(null)
val video: StateFlow<CallVideoEngine?> = _video
private val _videoActive = MutableStateFlow(false)
val videoActive: StateFlow<Boolean> = _videoActive
private var videoJob: Job? = null
private var videoSpeakerPending = false
fun setVideo(settings: CallVideoSettings) {
checkMain()
val owner = service ?: return
@ -41,7 +49,7 @@ object CallController : TelecomCallManager.Listener {
private val _earpiece = MutableStateFlow(true)
val earpiece: StateFlow<Boolean> = _earpiece
private val proximity = ProximityManager(ChatApplication.instance)
val proximityNear: StateFlow<Boolean> = proximity.near
val proximityNear: StateFlow<Boolean?> = proximity.near
private var service: CallService? = null
@ -60,6 +68,7 @@ object CallController : TelecomCallManager.Listener {
if (initialized) return
initialized = true
TelecomCallManager.setListener(this)
scope.launch { proximity.near.collect { applyVideoSpeakerIfReady() } }
scope.launch {
while (true) {
delay(500)
@ -209,6 +218,7 @@ object CallController : TelecomCallManager.Listener {
accepting = false
acceptSent = false
answerWhenVisible = 0L
videoJob?.cancel(); videoJob = null; videoSpeakerPending = false; _videoActive.value = false
owner?.shutdown()
_video.value = null
proximity.stop()
@ -256,7 +266,9 @@ object CallController : TelecomCallManager.Listener {
fun setRoute(route: AudioRoute) {
checkMain()
videoSpeakerPending = false // Явный выбор пользователя отменяет ожидающее автоматическое переключение.
service?.audio?.router?.setRoute(route)
syncProximity()
_state.value?.let { TelecomCallManager.setRoute(it.callId, route) }
scope.launch { ChatApplication.instance.configProvider.setCallAudioRoute(route) }
}
@ -264,10 +276,30 @@ object CallController : TelecomCallManager.Listener {
private fun routeChanged(route: AudioRoute, headset: Boolean) {
_route.value = route
_headset.value = headset
if (route == AudioRoute.EARPIECE && _state.value?.phase == CallPhase.ACTIVE) proximity.start() else proximity.stop()
if (route != AudioRoute.EARPIECE) videoSpeakerPending = false
syncProximity()
service?.audio?.syncAecForRoute(route)
}
private fun syncProximity() {
if (_state.value?.phase == CallPhase.ACTIVE && _route.value == AudioRoute.EARPIECE
&& (!_videoActive.value || videoSpeakerPending)) proximity.start() else proximity.stop()
}
private fun applyVideoSpeakerIfReady() {
if (!videoSpeakerPending || !_videoActive.value || proximity.near.value != false || _route.value != AudioRoute.EARPIECE) return
val st = _state.value ?: return
val audio = service?.audio ?: return
if (st.phase != CallPhase.ACTIVE) return
videoSpeakerPending = false
// Сохранённый маршрут аудиозвонков не меняем; автоследование за гарнитурой сохраняется.
audio.router.fallbackRoute = AudioRoute.SPEAKER
audio.router.setRoute(AudioRoute.SPEAKER, automatic = true)
TelecomCallManager.setRoute(st.callId, AudioRoute.SPEAKER)
syncProximity()
LogManager.addLog("INFO", "call", "video speaker enabled after confirmed FAR id=${st.callId}")
}
/** Только живые события ядра, сериализованные AppEventHandler на main thread. */
fun onEvent(type: Int, data: ByteArray?) {
checkMain()
@ -342,6 +374,19 @@ object CallController : TelecomCallManager.Listener {
owner.audio.router.onChanged = { route, headset -> routeChanged(route, headset) }
_earpiece.value = owner.audio.router.hasEarpiece
routeChanged(owner.audio.router.route, owner.audio.router.headsetAvailable)
owner.video?.let { engine ->
videoJob = scope.launch {
combine(engine.state, engine.display) { video, display -> video.settings.enabled || display != null }
.distinctUntilChanged().collect { active ->
if (service !== owner || _state.value?.callId != id || _state.value?.phase != CallPhase.ACTIVE) return@collect
if (active == _videoActive.value) return@collect
_videoActive.value = active
videoSpeakerPending = active && _route.value == AudioRoute.EARPIECE
LogManager.addLog("INFO", "call", "video active=$active speaker_pending=$videoSpeakerPending proximity=${proximity.near.value} id=$id")
syncProximity(); applyVideoSpeakerIfReady()
}
}
}
owner.updateNotification()
LogManager.addLog("INFO", "Call", "active id=$id; service owns audio and CPU wake lock")
}

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

@ -42,6 +42,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@ -123,6 +124,7 @@ fun CallScreen(
val peerNodeId = st?.peerNodeId ?: 0L
val name = st?.peerName?.ifEmpty { "0x%016x".format(peerNodeId) } ?: ""
val video by CallController.video.collectAsState()
val videoActive by CallController.videoActive.collectAsState()
val remote = video?.display?.collectAsState()?.value
val receiving = phase == CallPhase.ACTIVE && remote != null
var controlsVisible by rememberSaveable { mutableStateOf(true) }
@ -132,9 +134,20 @@ fun CallScreen(
if (receiving && controlsVisible) { delay(5000); controlsVisible = false }
}
val locked = phase == CallPhase.ACTIVE && route == AudioRoute.EARPIECE && proximityNear
val locked = phase == CallPhase.ACTIVE && !videoActive && route == AudioRoute.EARPIECE && proximityNear == true
val context = LocalContext.current
val view = LocalView.current
DisposableEffect(view, videoActive) {
val previous = view.keepScreenOn
if (videoActive) {
view.keepScreenOn = true
LogManager.addLog("INFO", "call", "video screen awake; proximity overlay disabled")
}
onDispose {
if (videoActive) { view.keepScreenOn = previous; LogManager.addLog("INFO", "call", "video screen wake policy restored") }
}
}
// Ориентация принадлежит экрану звонка; краткое переподключение Surface не должно поворачивать его обратно.
DisposableEffect(video, phase == CallPhase.ACTIVE) {
val activity = context.findActivity(); val previous = activity?.requestedOrientation

Loading…
Cancel
Save