diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallController.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallController.kt index 121dfec6..4fddb772 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallController.kt +++ b/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 = _state private val _video = MutableStateFlow(null) val video: StateFlow = _video + private val _videoActive = MutableStateFlow(false) + val videoActive: StateFlow = _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 = _earpiece private val proximity = ProximityManager(ChatApplication.instance) - val proximityNear: StateFlow = proximity.near + val proximityNear: StateFlow = 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") } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallScreen.kt index bb63857f..549d1874 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallScreen.kt +++ b/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