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.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow 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 kotlinx.coroutines.launch
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.nio.ByteOrder import java.nio.ByteOrder
@ -26,6 +30,10 @@ object CallController : TelecomCallManager.Listener {
val state: StateFlow<CallState?> = _state val state: StateFlow<CallState?> = _state
private val _video = MutableStateFlow<CallVideoEngine?>(null) private val _video = MutableStateFlow<CallVideoEngine?>(null)
val video: StateFlow<CallVideoEngine?> = _video 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) { fun setVideo(settings: CallVideoSettings) {
checkMain() checkMain()
val owner = service ?: return val owner = service ?: return
@ -41,7 +49,7 @@ object CallController : TelecomCallManager.Listener {
private val _earpiece = MutableStateFlow(true) private val _earpiece = MutableStateFlow(true)
val earpiece: StateFlow<Boolean> = _earpiece val earpiece: StateFlow<Boolean> = _earpiece
private val proximity = ProximityManager(ChatApplication.instance) private val proximity = ProximityManager(ChatApplication.instance)
val proximityNear: StateFlow<Boolean> = proximity.near val proximityNear: StateFlow<Boolean?> = proximity.near
private var service: CallService? = null private var service: CallService? = null
@ -60,6 +68,7 @@ object CallController : TelecomCallManager.Listener {
if (initialized) return if (initialized) return
initialized = true initialized = true
TelecomCallManager.setListener(this) TelecomCallManager.setListener(this)
scope.launch { proximity.near.collect { applyVideoSpeakerIfReady() } }
scope.launch { scope.launch {
while (true) { while (true) {
delay(500) delay(500)
@ -209,6 +218,7 @@ object CallController : TelecomCallManager.Listener {
accepting = false accepting = false
acceptSent = false acceptSent = false
answerWhenVisible = 0L answerWhenVisible = 0L
videoJob?.cancel(); videoJob = null; videoSpeakerPending = false; _videoActive.value = false
owner?.shutdown() owner?.shutdown()
_video.value = null _video.value = null
proximity.stop() proximity.stop()
@ -256,7 +266,9 @@ object CallController : TelecomCallManager.Listener {
fun setRoute(route: AudioRoute) { fun setRoute(route: AudioRoute) {
checkMain() checkMain()
videoSpeakerPending = false // Явный выбор пользователя отменяет ожидающее автоматическое переключение.
service?.audio?.router?.setRoute(route) service?.audio?.router?.setRoute(route)
syncProximity()
_state.value?.let { TelecomCallManager.setRoute(it.callId, route) } _state.value?.let { TelecomCallManager.setRoute(it.callId, route) }
scope.launch { ChatApplication.instance.configProvider.setCallAudioRoute(route) } scope.launch { ChatApplication.instance.configProvider.setCallAudioRoute(route) }
} }
@ -264,10 +276,30 @@ object CallController : TelecomCallManager.Listener {
private fun routeChanged(route: AudioRoute, headset: Boolean) { private fun routeChanged(route: AudioRoute, headset: Boolean) {
_route.value = route _route.value = route
_headset.value = headset _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) 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. */ /** Только живые события ядра, сериализованные AppEventHandler на main thread. */
fun onEvent(type: Int, data: ByteArray?) { fun onEvent(type: Int, data: ByteArray?) {
checkMain() checkMain()
@ -342,6 +374,19 @@ object CallController : TelecomCallManager.Listener {
owner.audio.router.onChanged = { route, headset -> routeChanged(route, headset) } owner.audio.router.onChanged = { route, headset -> routeChanged(route, headset) }
_earpiece.value = owner.audio.router.hasEarpiece _earpiece.value = owner.audio.router.hasEarpiece
routeChanged(owner.audio.router.route, owner.audio.router.headsetAvailable) 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() owner.updateNotification()
LogManager.addLog("INFO", "Call", "active id=$id; service owns audio and CPU wake lock") 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.graphics.vector.ImageVector
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -123,6 +124,7 @@ fun CallScreen(
val peerNodeId = st?.peerNodeId ?: 0L val peerNodeId = st?.peerNodeId ?: 0L
val name = st?.peerName?.ifEmpty { "0x%016x".format(peerNodeId) } ?: "" val name = st?.peerName?.ifEmpty { "0x%016x".format(peerNodeId) } ?: ""
val video by CallController.video.collectAsState() val video by CallController.video.collectAsState()
val videoActive by CallController.videoActive.collectAsState()
val remote = video?.display?.collectAsState()?.value val remote = video?.display?.collectAsState()?.value
val receiving = phase == CallPhase.ACTIVE && remote != null val receiving = phase == CallPhase.ACTIVE && remote != null
var controlsVisible by rememberSaveable { mutableStateOf(true) } var controlsVisible by rememberSaveable { mutableStateOf(true) }
@ -132,9 +134,20 @@ fun CallScreen(
if (receiving && controlsVisible) { delay(5000); controlsVisible = false } 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 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 не должно поворачивать его обратно. // Ориентация принадлежит экрану звонка; краткое переподключение Surface не должно поворачивать его обратно.
DisposableEffect(video, phase == CallPhase.ACTIVE) { DisposableEffect(video, phase == CallPhase.ACTIVE) {
val activity = context.findActivity(); val previous = activity?.requestedOrientation val activity = context.findActivity(); val previous = activity?.requestedOrientation

Loading…
Cancel
Save