Coordinate Android call audio ownership and improve video controls
This commit is contained in:
1 parent
39ed42da2e
commit
21a20da5ee
15 files changed
+404
-70
No files matched your search
@@ -272,6 +272,17 @@ ACTIVE и выключают Android standby. Состояние не истек
|
|||||||
завершения звонка в фоне возвращается standby; экран может гаснуть во время
|
завершения звонка в фоне возвращается standby; экран может гаснуть во время
|
||||||
разговора независимо от частичного wake lock сервиса.
|
разговора независимо от частичного wake lock сервиса.
|
||||||
|
|
||||||
|
Если работают рация, публикация или просмотр трансляции, исходящий Android-звонок
|
||||||
|
требует подтверждения их остановки; входящий показывает предупреждение до принятия.
|
||||||
|
После подтверждения/принятия `CallController` выключает участие в рации, завершает
|
||||||
|
публикацию и просмотр, асинхронно ждёт освобождения их аудио и только затем запускает
|
||||||
|
сигналинг и звонковое аудио. Foreground-сервис резервируется сразу из видимой Activity.
|
||||||
|
Отмена во время освобождения не запускает устаревший звонок.
|
||||||
|
До окончания звонка повторное включение рации/трансляции заблокировано.
|
||||||
|
После закрытия звонкового аудио и Telecom Connection ранее включённая рация
|
||||||
|
возвращается в прежний канал; удержания PTT сброшены. Удалённый канал и остановленное
|
||||||
|
ядро не восстанавливаются. Публикация и просмотр трансляции автоматически не возобновляются.
|
||||||
|
|
||||||
`utun_set_client_activity()` в сборке с `UTUN_HAVE_STANDBY` применяет фактический
|
`utun_set_client_activity()` в сборке с `UTUN_HAVE_STANDBY` применяет фактический
|
||||||
режим duty-cycle до рассылки событий активности; JNI не переключает его отдельно.
|
режим duty-cycle до рассылки событий активности; JNI не переключает его отдельно.
|
||||||
Повторная команда не перезапускает фазу и анонсы, но первый фоновый запуск включает
|
Повторная команда не перезапускает фазу и анонсы, но первый фоновый запуск включает
|
||||||
|
|||||||
@@ -542,6 +542,18 @@ class MainActivity : AppCompatActivity() {
|
|||||||
pmInvite?.let { state ->
|
pmInvite?.let { state ->
|
||||||
PmInviteDialog(state, vm::selectPmInviteGroup, vm::sendPmInvite, vm::dismissPmInvite)
|
PmInviteDialog(state, vm::selectPmInviteGroup, vm::sendPmInvite, vm::dismissPmInvite)
|
||||||
}
|
}
|
||||||
|
val outgoingCall by CallController.outgoingRequest.collectAsState()
|
||||||
|
outgoingCall?.let {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = CallController::cancelOutgoing,
|
||||||
|
title = { Text(stringResource(R.string.call_stop_media_title)) },
|
||||||
|
text = { Text(stringResource(CallController.mediaWarning(incoming = false))) },
|
||||||
|
confirmButton = { TextButton(onClick = CallController::confirmOutgoing) {
|
||||||
|
Text(stringResource(R.string.call_stop_media_confirm))
|
||||||
|
} },
|
||||||
|
dismissButton = { TextButton(onClick = CallController::cancelOutgoing) { Text(stringResource(R.string.cancel)) } }
|
||||||
|
)
|
||||||
|
}
|
||||||
val localInvitations by com.utun.chat.data.LocalDiscovery.invitations.collectAsState()
|
val localInvitations by com.utun.chat.data.LocalDiscovery.invitations.collectAsState()
|
||||||
localInvitations.firstOrNull()?.let { invitation ->
|
localInvitations.firstOrNull()?.let { invitation ->
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
|
|||||||
@@ -83,8 +83,21 @@ class CallAudioEngine {
|
|||||||
private val trackRouteListener = AudioRouting.OnRoutingChangedListener { output ->
|
private val trackRouteListener = AudioRouting.OnRoutingChangedListener { output ->
|
||||||
synchronized(trackLock) {
|
synchronized(trackLock) {
|
||||||
val track = audioTrack
|
val track = audioTrack
|
||||||
if (active && track != null && output === track.track && Build.VERSION.SDK_INT < 31) {
|
if (active && track != null && output === track.track) {
|
||||||
confirmedDeviceId = track.routedDevice?.id ?: 0
|
val device = track.routedDevice
|
||||||
|
if (Build.VERSION.SDK_INT < 31) confirmedDeviceId = device?.id ?: 0
|
||||||
|
LogManager.addLog("INFO", "CallAudio", "playback routed id=$callId backend=${track.name} " +
|
||||||
|
"device=${device?.id}/${device?.type} communication=$confirmedDeviceId selected=${router.route}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private val recordRouteListener = AudioRouting.OnRoutingChangedListener { input ->
|
||||||
|
synchronized(recordLock) {
|
||||||
|
val record = audioRecord
|
||||||
|
if (active && record != null && input === record) {
|
||||||
|
val device = record.routedDevice
|
||||||
|
LogManager.addLog("INFO", "CallAudio", "capture routed id=$callId device=${device?.id}/${device?.type} " +
|
||||||
|
"preferred=${record.preferredDevice?.id} communication=$confirmedDeviceId selected=${router.route} wired=$wiredActive")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -396,7 +409,8 @@ class CallAudioEngine {
|
|||||||
/* диагностика: уровень захваченного сигнала (тишина vs звук) */
|
/* диагностика: уровень захваченного сигнала (тишина vs звук) */
|
||||||
if (sinceSwap < 10 || frame % 100L == 0L) {
|
if (sinceSwap < 10 || frame % 100L == 0L) {
|
||||||
LogManager.addLog("DEBUG", "CallAudio",
|
LogManager.addLog("DEBUG", "CallAudio",
|
||||||
"cap wired=$wiredActive frame=$frame rmsDb=${rmsDb(buf)}")
|
"cap wired=$wiredActive frame=$frame rmsDb=${rmsDb(buf)} selected=${router.route} " +
|
||||||
|
"communication=$confirmedDeviceId muted=$muted")
|
||||||
}
|
}
|
||||||
sinceSwap++
|
sinceSwap++
|
||||||
frame++
|
frame++
|
||||||
@@ -478,6 +492,7 @@ class CallAudioEngine {
|
|||||||
}
|
}
|
||||||
LogManager.addLog("INFO", "CallAudio", "capture device ready id=$callId minQueryMs=$queryMs constructorMs=$constructorMs " +
|
LogManager.addLog("INFO", "CallAudio", "capture device ready id=$callId minQueryMs=$queryMs constructorMs=$constructorMs " +
|
||||||
"routingMs=${SystemClock.elapsedRealtime() - routeAt} minBytes=$recMin")
|
"routingMs=${SystemClock.elapsedRealtime() - routeAt} minBytes=$recMin")
|
||||||
|
rec.addOnRoutingChangedListener(recordRouteListener, mainHandler)
|
||||||
return rec
|
return rec
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ 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.Job
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
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
|
||||||
@@ -23,6 +25,8 @@ import kotlinx.coroutines.launch
|
|||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
import java.nio.ByteOrder
|
import java.nio.ByteOrder
|
||||||
|
|
||||||
|
data class OutgoingCallRequest(val channelId: String, val peerNodeId: Long, val peerName: String)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Единственный владелец звонковой сессии в процессе приложения. Все операции — main thread.
|
* Единственный владелец звонковой сессии в процессе приложения. Все операции — main thread.
|
||||||
* Сервис владеет аудио и wake lock, ядро — сигналингом, ViewModel только читает StateFlow.
|
* Сервис владеет аудио и wake lock, ядро — сигналингом, ViewModel только читает StateFlow.
|
||||||
@@ -32,6 +36,37 @@ object CallController : TelecomCallManager.Listener {
|
|||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||||
private val _state = MutableStateFlow<CallState?>(null)
|
private val _state = MutableStateFlow<CallState?>(null)
|
||||||
val state: StateFlow<CallState?> = _state
|
val state: StateFlow<CallState?> = _state
|
||||||
|
private val mutableOutgoing = MutableStateFlow<OutgoingCallRequest?>(null)
|
||||||
|
val outgoingRequest: StateFlow<OutgoingCallRequest?> = mutableOutgoing
|
||||||
|
private var mediaPreparation: Job? = null
|
||||||
|
private var radioToRestore: Long? = null
|
||||||
|
|
||||||
|
/** На время подготовки и разговора только звонок может запускать аудиоустройства. */
|
||||||
|
fun blocksMedia(): Boolean = mediaPreparation?.isActive == true || _state.value?.let {
|
||||||
|
it.preparing || it.phase == CallPhase.OUTGOING || it.phase == CallPhase.RINGING ||
|
||||||
|
it.phase == CallPhase.WAIT_DECISION || it.phase == CallPhase.ACTIVE
|
||||||
|
} == true
|
||||||
|
|
||||||
|
fun hasMedia(): Boolean = RadioAudioEngine.instance().activeGroup.value != null ||
|
||||||
|
StreamController.state.value?.ongoing == true || StreamPlayerEngine.viewing.value
|
||||||
|
|
||||||
|
fun mediaWarning(incoming: Boolean): Int {
|
||||||
|
val radio = RadioAudioEngine.instance().activeGroup.value != null
|
||||||
|
val stream = StreamController.state.value?.ongoing == true || StreamPlayerEngine.viewing.value
|
||||||
|
return when {
|
||||||
|
radio && stream -> if (incoming) R.string.call_incoming_stop_media else R.string.call_outgoing_stop_media
|
||||||
|
radio -> if (incoming) R.string.call_incoming_stop_radio else R.string.call_outgoing_stop_radio
|
||||||
|
else -> if (incoming) R.string.call_incoming_stop_stream else R.string.call_outgoing_stop_stream
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelOutgoing() { mutableOutgoing.value = null }
|
||||||
|
|
||||||
|
fun confirmOutgoing() {
|
||||||
|
val request = mutableOutgoing.value ?: return
|
||||||
|
mutableOutgoing.value = null
|
||||||
|
start(request.channelId, request.peerNodeId, request.peerName, mediaConfirmed = true)
|
||||||
|
}
|
||||||
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)
|
private val _videoActive = MutableStateFlow(false)
|
||||||
@@ -59,6 +94,7 @@ object CallController : TelecomCallManager.Listener {
|
|||||||
|
|
||||||
fun refreshLanguage() { service?.refreshLanguage() }
|
fun refreshLanguage() { service?.refreshLanguage() }
|
||||||
private var serviceRequested = false
|
private var serviceRequested = false
|
||||||
|
private var signallingStarted = false
|
||||||
private var accepting = false
|
private var accepting = false
|
||||||
private var acceptSent = false
|
private var acceptSent = false
|
||||||
private var answerWhenVisible = 0L
|
private var answerWhenVisible = 0L
|
||||||
@@ -105,13 +141,12 @@ object CallController : TelecomCallManager.Listener {
|
|||||||
CoreActivity.call(_state.value?.ongoing == true)
|
CoreActivity.call(_state.value?.ongoing == true)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun start(channelId: String, peerNodeId: Long, peerName: String) {
|
fun start(channelId: String, peerNodeId: Long, peerName: String, mediaConfirmed: Boolean = false) {
|
||||||
checkMain()
|
checkMain()
|
||||||
if (RadioCallController.busy()) {
|
if (RadioCallController.busy()) {
|
||||||
LogManager.addLog("WARN", "Call", "start rejected: pending radio invitation")
|
LogManager.addLog("WARN", "Call", "start rejected: pending radio invitation")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (StreamController.blocksCapture()) return
|
|
||||||
if (channelId.isEmpty() || peerNodeId == 0L) {
|
if (channelId.isEmpty() || peerNodeId == 0L) {
|
||||||
LogManager.addLog("ERROR", "Call", "invalid outgoing call channel=$channelId peer=$peerNodeId")
|
LogManager.addLog("ERROR", "Call", "invalid outgoing call channel=$channelId peer=$peerNodeId")
|
||||||
return
|
return
|
||||||
@@ -120,20 +155,27 @@ object CallController : TelecomCallManager.Listener {
|
|||||||
LogManager.addLog("WARN", "Call", "start rejected: busy=${_state.value?.ongoing} core=$coreReady native=${NativeLib.isReady()}")
|
LogManager.addLog("WARN", "Call", "start rejected: busy=${_state.value?.ongoing} core=$coreReady native=${NativeLib.isReady()}")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (!mediaConfirmed && hasMedia()) {
|
||||||
|
mutableOutgoing.value = OutgoingCallRequest(channelId, peerNodeId, peerName)
|
||||||
|
LogManager.addLog("INFO", "Call", "outgoing requires media confirmation peer=$peerNodeId")
|
||||||
|
return
|
||||||
|
}
|
||||||
if (!MicrophonePermission.granted()) {
|
if (!MicrophonePermission.granted()) {
|
||||||
MicrophonePermission.request("Outgoing call") { start(channelId, peerNodeId, peerName) }
|
MicrophonePermission.request("Outgoing call") { start(channelId, peerNodeId, peerName, mediaConfirmed) }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var id = java.security.SecureRandom().nextLong()
|
var id = java.security.SecureRandom().nextLong()
|
||||||
if (id == 0L) id = 1
|
if (id == 0L) id = 1
|
||||||
_state.value = CallState(id, channelId, peerNodeId, peerName.ifEmpty { "0x%016x".format(peerNodeId) }, CallPhase.OUTGOING)
|
mutableOutgoing.value = null
|
||||||
|
_state.value = CallState(id, channelId, peerNodeId, peerName.ifEmpty { "0x%016x".format(peerNodeId) },
|
||||||
|
CallPhase.OUTGOING, preparing = true)
|
||||||
syncCoreActivity()
|
syncCoreActivity()
|
||||||
requestService(id)
|
requestService(id)
|
||||||
|
if (_state.value?.callId == id && _state.value?.ongoing == true) prepareMedia(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun accept() {
|
fun accept() {
|
||||||
checkMain()
|
checkMain()
|
||||||
if (StreamController.blocksCapture()) return
|
|
||||||
val st = _state.value ?: return
|
val st = _state.value ?: return
|
||||||
if (st.phase != CallPhase.INCOMING || accepting) {
|
if (st.phase != CallPhase.INCOMING || accepting) {
|
||||||
LogManager.addLog("WARN", "Call", "accept ignored id=${st.callId} phase=${st.phase} accepting=$accepting")
|
LogManager.addLog("WARN", "Call", "accept ignored id=${st.callId} phase=${st.phase} accepting=$accepting")
|
||||||
@@ -147,7 +189,41 @@ object CallController : TelecomCallManager.Listener {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
accepting = true
|
accepting = true
|
||||||
|
_state.value = st.copy(preparing = true)
|
||||||
|
IncomingCallManager.stopRinging()
|
||||||
requestService(st.callId)
|
requestService(st.callId)
|
||||||
|
if (_state.value?.callId == st.callId && _state.value?.ongoing == true) prepareMedia(st.callId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Сначала release чужих аудиотрактов, затем сигналинг; отмена вызова не прерывает release. */
|
||||||
|
private fun prepareMedia(id: Long) {
|
||||||
|
val previous = mediaPreparation
|
||||||
|
if (radioToRestore == null) radioToRestore = RadioAudioEngine.instance().activeGroup.value
|
||||||
|
mediaPreparation = scope.launch {
|
||||||
|
try {
|
||||||
|
previous?.join()
|
||||||
|
if (_state.value?.callId != id || _state.value?.ongoing != true) return@launch
|
||||||
|
LogManager.addLog("INFO", "Call", "media release begins id=$id restoreRadio=$radioToRestore")
|
||||||
|
val radioStopped = RadioAudioEngine.instance().stopForCall()
|
||||||
|
StreamController.stopForCall()
|
||||||
|
StreamPlayerEngine.stopForCall()
|
||||||
|
withContext(Dispatchers.IO) { radioStopped.get() }
|
||||||
|
val current = _state.value
|
||||||
|
if (current?.callId != id || !current.ongoing) {
|
||||||
|
LogManager.addLog("INFO", "Call", "media released after call cancellation id=$id")
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
_state.value = current.copy(preparing = false)
|
||||||
|
LogManager.addLog("INFO", "Call", "media released id=$id; call signalling allowed")
|
||||||
|
startSignalling()
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
LogManager.addLog("ERROR", "Call", "media release failed id=$id: $e")
|
||||||
|
Toast.makeText(ChatApplication.instance, R.string.call_media_stop_failed, Toast.LENGTH_LONG).show()
|
||||||
|
end(id, 7)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun requestService(id: Long) {
|
private fun requestService(id: Long) {
|
||||||
@@ -174,16 +250,26 @@ object CallController : TelecomCallManager.Listener {
|
|||||||
}
|
}
|
||||||
service = owner
|
service = owner
|
||||||
LogManager.addLog("INFO", "Call", "service ready id=$id phase=${st.phase}")
|
LogManager.addLog("INFO", "Call", "service ready id=$id phase=${st.phase}")
|
||||||
|
startSignalling()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Сервис резервируется видимым UI сразу, сигналинг ждёт фактического освобождения медиа. */
|
||||||
|
private fun startSignalling() {
|
||||||
|
val st = _state.value ?: return
|
||||||
|
if (service == null || !st.ongoing || st.preparing || signallingStarted) return
|
||||||
|
val id = st.callId
|
||||||
if (st.phase == CallPhase.OUTGOING) {
|
if (st.phase == CallPhase.OUTGOING) {
|
||||||
|
signallingStarted = true
|
||||||
NativeLib.callStart(st.channelId, st.peerNodeId, id)
|
NativeLib.callStart(st.channelId, st.peerNodeId, id)
|
||||||
TelecomCallManager.placeCall(id, st.peerNodeId, st.peerName)
|
TelecomCallManager.placeCall(id, st.peerNodeId, st.peerName)
|
||||||
CallRingback.startConnecting()
|
CallRingback.startConnecting()
|
||||||
} else if (st.phase == CallPhase.INCOMING && accepting) {
|
} else if (st.phase == CallPhase.INCOMING && accepting) {
|
||||||
|
signallingStarted = true
|
||||||
IncomingCallManager.stopRinging()
|
IncomingCallManager.stopRinging()
|
||||||
acceptSent = true
|
acceptSent = true
|
||||||
NativeLib.callAccept(id)
|
NativeLib.callAccept(id)
|
||||||
}
|
}
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun wantsService(id: Long): Boolean = _state.value?.let { it.callId == id && it.ongoing && serviceRequested } == true
|
fun wantsService(id: Long): Boolean = _state.value?.let { it.callId == id && it.ongoing && serviceRequested } == true
|
||||||
@@ -212,7 +298,8 @@ object CallController : TelecomCallManager.Listener {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (NativeLib.isReady()) {
|
if (NativeLib.isReady()) {
|
||||||
if (st.phase == CallPhase.INCOMING && !acceptSent) NativeLib.callDecline(id) else NativeLib.callHangup(id)
|
if ((st.phase == CallPhase.INCOMING || st.phase == CallPhase.PENDING_INCOMING) && !acceptSent) NativeLib.callDecline(id)
|
||||||
|
else if (signallingStarted) NativeLib.callHangup(id)
|
||||||
}
|
}
|
||||||
finish(id, reason)
|
finish(id, reason)
|
||||||
}
|
}
|
||||||
@@ -223,6 +310,7 @@ object CallController : TelecomCallManager.Listener {
|
|||||||
val owner = service
|
val owner = service
|
||||||
service = null
|
service = null
|
||||||
serviceRequested = false
|
serviceRequested = false
|
||||||
|
signallingStarted = false
|
||||||
accepting = false
|
accepting = false
|
||||||
acceptSent = false
|
acceptSent = false
|
||||||
answerWhenVisible = 0L
|
answerWhenVisible = 0L
|
||||||
@@ -238,10 +326,18 @@ object CallController : TelecomCallManager.Listener {
|
|||||||
_route.value = AudioRoute.SPEAKER
|
_route.value = AudioRoute.SPEAKER
|
||||||
_headset.value = false
|
_headset.value = false
|
||||||
_earpiece.value = true
|
_earpiece.value = true
|
||||||
_state.value = st.copy(phase = CallPhase.ENDED, reason = reason,
|
_state.value = st.copy(phase = CallPhase.ENDED, reason = reason, preparing = false,
|
||||||
connectionType = CallConnectionType.NONE, connectionNextHop = 0)
|
connectionType = CallConnectionType.NONE, connectionNextHop = 0)
|
||||||
syncCoreActivity()
|
syncCoreActivity()
|
||||||
LogManager.addLog("INFO", "Call", "finished id=$id reason=$reason; audio/service/proximity released")
|
LogManager.addLog("INFO", "Call", "finished id=$id reason=$reason; audio/service/proximity released")
|
||||||
|
val released = mediaPreparation
|
||||||
|
scope.launch {
|
||||||
|
released?.join()
|
||||||
|
if (!coreReady || !NativeLib.isReady() || blocksMedia()) return@launch
|
||||||
|
val group = radioToRestore ?: return@launch
|
||||||
|
radioToRestore = null
|
||||||
|
RadioAudioEngine.instance().restoreAfterCall(group)
|
||||||
|
}
|
||||||
if (reason == 8 && st.phase != CallPhase.INCOMING && st.phase != CallPhase.PENDING_INCOMING) {
|
if (reason == 8 && st.phase != CallPhase.INCOMING && st.phase != CallPhase.PENDING_INCOMING) {
|
||||||
val app = ChatApplication.instance
|
val app = ChatApplication.instance
|
||||||
val text = AppLanguage.text(R.string.phone_busy_warning)
|
val text = AppLanguage.text(R.string.phone_busy_warning)
|
||||||
@@ -268,6 +364,8 @@ object CallController : TelecomCallManager.Listener {
|
|||||||
/** Перед stop/restart ядра освобождаем аудио, чтобы workers больше не обращались к native-ресурсам. */
|
/** Перед stop/restart ядра освобождаем аудио, чтобы workers больше не обращались к native-ресурсам. */
|
||||||
fun reset() {
|
fun reset() {
|
||||||
checkMain()
|
checkMain()
|
||||||
|
mutableOutgoing.value = null
|
||||||
|
radioToRestore = null
|
||||||
coreReady = false
|
coreReady = false
|
||||||
CoreActivity.reset()
|
CoreActivity.reset()
|
||||||
end()
|
end()
|
||||||
@@ -357,6 +455,7 @@ object CallController : TelecomCallManager.Listener {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
incomingAt = SystemClock.elapsedRealtime()
|
incomingAt = SystemClock.elapsedRealtime()
|
||||||
|
mutableOutgoing.value = null
|
||||||
// Однократное чтение на main завершается до reset; фоновый JNI-запрос не переживает ядро.
|
// Однократное чтение на main завершается до reset; фоновый JNI-запрос не переживает ядро.
|
||||||
val name = ChatRepository().getMembers(channel).firstOrNull { it.nodeId == peer }?.name
|
val name = ChatRepository().getMembers(channel).firstOrNull { it.nodeId == peer }?.name
|
||||||
?.takeIf { it.isNotEmpty() } ?: "0x%016x".format(peer)
|
?.takeIf { it.isNotEmpty() } ?: "0x%016x".format(peer)
|
||||||
|
|||||||
@@ -34,7 +34,8 @@ data class CallState(
|
|||||||
val stats: CallStats? = null,
|
val stats: CallStats? = null,
|
||||||
val startedAtElapsedRealtime: Long = 0,
|
val startedAtElapsedRealtime: Long = 0,
|
||||||
val connectionType: CallConnectionType = CallConnectionType.NONE,
|
val connectionType: CallConnectionType = CallConnectionType.NONE,
|
||||||
val connectionNextHop: Long = 0
|
val connectionNextHop: Long = 0,
|
||||||
|
val preparing: Boolean = false // Освобождаем рацию/трансляцию до сигналинга и запуска звонкового аудио.
|
||||||
) {
|
) {
|
||||||
val ongoing: Boolean get() = phase != CallPhase.NONE && phase != CallPhase.ENDED
|
val ongoing: Boolean get() = phase != CallPhase.NONE && phase != CallPhase.ENDED
|
||||||
}
|
}
|
||||||
@@ -150,6 +150,11 @@ object IncomingCallManager {
|
|||||||
.setContentIntent(contentPi)
|
.setContentIntent(contentPi)
|
||||||
.addAction(0, AppLanguage.text(if (activeRadio) R.string.decline else R.string.hang_up), declinePi)
|
.addAction(0, AppLanguage.text(if (activeRadio) R.string.decline else R.string.hang_up), declinePi)
|
||||||
|
|
||||||
|
if (!activeRadio && CallController.hasMedia()) {
|
||||||
|
val warning = AppLanguage.text(CallController.mediaWarning(incoming = true))
|
||||||
|
builder.setContentText(warning).setStyle(NotificationCompat.BigTextStyle().bigText(warning))
|
||||||
|
}
|
||||||
|
|
||||||
if (activeRadio) {
|
if (activeRadio) {
|
||||||
val answerPi = PendingIntent.getActivity(ctx, 2, Intent(launchIntent).apply {
|
val answerPi = PendingIntent.getActivity(ctx, 2, Intent(launchIntent).apply {
|
||||||
action = "com.utun.chat.ANSWER_RADIO"
|
action = "com.utun.chat.ANSWER_RADIO"
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import com.utun.chat.ChatApplication
|
|||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import java.util.concurrent.Executors
|
import java.util.concurrent.Executors
|
||||||
|
import java.util.concurrent.Future
|
||||||
import java.util.concurrent.atomic.AtomicLong
|
import java.util.concurrent.atomic.AtomicLong
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -63,7 +64,6 @@ class RadioAudioEngine {
|
|||||||
private var captureThread: Thread? = null
|
private var captureThread: Thread? = null
|
||||||
@Volatile private var audioTrack: AudioTrack? = null
|
@Volatile private var audioTrack: AudioTrack? = null
|
||||||
private var playThread: Thread? = null
|
private var playThread: Thread? = null
|
||||||
private var prevMode: Int = AudioManager.MODE_NORMAL
|
|
||||||
private var prevSpeaker: Boolean = false
|
private var prevSpeaker: Boolean = false
|
||||||
private var audioModeOwned = false
|
private var audioModeOwned = false
|
||||||
private val streamPriorities = AtomicLong()
|
private val streamPriorities = AtomicLong()
|
||||||
@@ -73,6 +73,8 @@ class RadioAudioEngine {
|
|||||||
|
|
||||||
@Volatile private var active = false
|
@Volatile private var active = false
|
||||||
@Volatile private var groupId = 0L
|
@Volatile private var groupId = 0L
|
||||||
|
private val mutableGroup = MutableStateFlow<Long?>(null)
|
||||||
|
val activeGroup: StateFlow<Long?> = mutableGroup
|
||||||
@Volatile private var talking = false
|
@Volatile private var talking = false
|
||||||
@Volatile private var captureRunning = false
|
@Volatile private var captureRunning = false
|
||||||
@Volatile private var playbackRunning = false
|
@Volatile private var playbackRunning = false
|
||||||
@@ -180,6 +182,11 @@ class RadioAudioEngine {
|
|||||||
/** Начать прослушивание канала (переключатель рации вкл).
|
/** Начать прослушивание канала (переключатель рации вкл).
|
||||||
* Тяжёлая инициализация аудио выполняется на io-потоке, не блокируя main. */
|
* Тяжёлая инициализация аудио выполняется на io-потоке, не блокируя main. */
|
||||||
fun start(group: Long, onReady: ((Boolean) -> Unit)? = null) {
|
fun start(group: Long, onReady: ((Boolean) -> Unit)? = null) {
|
||||||
|
if (CallController.blocksMedia()) {
|
||||||
|
LogManager.addLog("WARN", "radio", "start blocked by call group=$group")
|
||||||
|
onReady?.invoke(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (active && groupId == group) {
|
if (active && groupId == group) {
|
||||||
val version = generation
|
val version = generation
|
||||||
io.execute { mainHandler.post { onReady?.invoke(active && generation == version && audioTrack != null) } }
|
io.execute { mainHandler.post { onReady?.invoke(active && generation == version && audioTrack != null) } }
|
||||||
@@ -189,6 +196,7 @@ class RadioAudioEngine {
|
|||||||
val version = ++generation
|
val version = ++generation
|
||||||
active = true
|
active = true
|
||||||
groupId = group
|
groupId = group
|
||||||
|
mutableGroup.value = group
|
||||||
requestedTalk = false
|
requestedTalk = false
|
||||||
pttHolds.cancel()
|
pttHolds.cancel()
|
||||||
_talkingFlow.value = false
|
_talkingFlow.value = false
|
||||||
@@ -210,14 +218,55 @@ class RadioAudioEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun stop() {
|
fun stop(): Future<*> {
|
||||||
mainHandler.removeCallbacks(releaseTalk)
|
mainHandler.removeCallbacks(releaseTalk)
|
||||||
active = false
|
active = false
|
||||||
|
mutableGroup.value = null
|
||||||
requestedTalk = false
|
requestedTalk = false
|
||||||
pttHolds.cancel()
|
pttHolds.cancel()
|
||||||
_talkingFlow.value = false
|
_talkingFlow.value = false
|
||||||
++generation
|
++generation
|
||||||
io.execute { releaseLocked() }
|
return io.submit {
|
||||||
|
try {
|
||||||
|
releaseLocked()
|
||||||
|
LogManager.addLog("INFO", "radio", "audio resources released group=$audioGroup")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
LogManager.addLog("ERROR", "radio", "audio release failed group=$audioGroup: $e")
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Выключить участие в канале и вернуть завершение фактического освобождения аудиотракта. */
|
||||||
|
fun stopForCall(): Future<*> {
|
||||||
|
mutableGroup.value?.let { group ->
|
||||||
|
NativeLib.radioSetActive(group, false)
|
||||||
|
LogManager.addLog("INFO", "radio", "disabled for call group=$group")
|
||||||
|
}
|
||||||
|
val stopped = stop()
|
||||||
|
RadioOverlayService.sync(ChatApplication.instance)
|
||||||
|
return stopped
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Восстановить режим прослушивания; старые удержания PTT уже сброшены в stop(). */
|
||||||
|
fun restoreAfterCall(group: Long) {
|
||||||
|
if (active || CallController.blocksMedia()) {
|
||||||
|
LogManager.addLog("WARN", "radio", "call restore skipped: audio busy group=$group active=$active")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val exists = ChatRepository().getChannels().any { it.id.toULongOrNull()?.toLong() == group }
|
||||||
|
if (!exists) { LogManager.addLog("WARN", "radio", "call restore skipped: channel removed group=$group"); return }
|
||||||
|
NativeLib.radioSetActive(group, true)
|
||||||
|
start(group) { ready ->
|
||||||
|
val superseded = mutableGroup.value != group || CallController.blocksMedia()
|
||||||
|
LogManager.addLog(if (ready || superseded) "INFO" else "ERROR", "radio",
|
||||||
|
"restored after call group=$group ready=$ready superseded=$superseded")
|
||||||
|
if (!ready && mutableGroup.value == group && !isActive()) {
|
||||||
|
NativeLib.radioSetActive(group, false)
|
||||||
|
stop()
|
||||||
|
}
|
||||||
|
RadioOverlayService.sync(ChatApplication.instance)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Смена настроек сохраняет окно автоматики; явное включение открывает новое. */
|
/** Смена настроек сохраняет окно автоматики; явное включение открывает новое. */
|
||||||
@@ -247,7 +296,6 @@ class RadioAudioEngine {
|
|||||||
private fun setupAudio() {
|
private fun setupAudio() {
|
||||||
loadCaptureSettings()
|
loadCaptureSettings()
|
||||||
val group = audioGroup
|
val group = audioGroup
|
||||||
val ctx = ChatApplication.instance
|
|
||||||
if (streamPriority == 0L) acquireAudioMode()
|
if (streamPriority == 0L) acquireAudioMode()
|
||||||
router.setGlobalRouting(streamPriority == 0L)
|
router.setGlobalRouting(streamPriority == 0L)
|
||||||
|
|
||||||
@@ -799,7 +847,7 @@ class RadioAudioEngine {
|
|||||||
private fun acquireAudioMode() {
|
private fun acquireAudioMode() {
|
||||||
if (audioModeOwned) return
|
if (audioModeOwned) return
|
||||||
val am = ChatApplication.instance.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
val am = ChatApplication.instance.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||||
prevMode = am.mode; prevSpeaker = am.isSpeakerphoneOn; audioModeOwned = true
|
prevSpeaker = am.isSpeakerphoneOn; audioModeOwned = true
|
||||||
try { am.mode = AudioManager.MODE_IN_COMMUNICATION }
|
try { am.mode = AudioManager.MODE_IN_COMMUNICATION }
|
||||||
catch (e: Exception) { LogManager.addLog("WARN", "radio", "set audio mode failed: ${e.message}") }
|
catch (e: Exception) { LogManager.addLog("WARN", "radio", "set audio mode failed: ${e.message}") }
|
||||||
}
|
}
|
||||||
@@ -810,7 +858,8 @@ class RadioAudioEngine {
|
|||||||
try {
|
try {
|
||||||
val am = ChatApplication.instance.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
val am = ChatApplication.instance.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||||
am.isSpeakerphoneOn = prevSpeaker
|
am.isSpeakerphoneOn = prevSpeaker
|
||||||
am.mode = prevMode
|
// MODE_NORMAL снимает нашу заявку; прежний глобальный mode мог принадлежать завершаемому Telecom-звонку.
|
||||||
|
am.mode = AudioManager.MODE_NORMAL
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
LogManager.addLog("WARN", "RadioAudio", "restore audio mode failed: ${e.message}")
|
LogManager.addLog("WARN", "RadioAudio", "restore audio mode failed: ${e.message}")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import kotlinx.coroutines.SupervisorJob
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import java.util.concurrent.atomic.AtomicLong
|
import java.util.concurrent.atomic.AtomicLong
|
||||||
|
|
||||||
enum class StreamPhase { STARTING, LIVE, STOPPING, ENDED, FAILED }
|
enum class StreamPhase { STARTING, LIVE, STOPPING, ENDED, FAILED }
|
||||||
@@ -131,6 +132,16 @@ object StreamController {
|
|||||||
service?.shutdown() // До onStartCommand отменённый start завершит foreground-handshake и остановится.
|
service?.shutdown() // До onStartCommand отменённый start завершит foreground-handshake и остановится.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** STOPPING заканчивается только после release capture и завершения foreground-сервиса. */
|
||||||
|
suspend fun stopForCall() {
|
||||||
|
val st = mutable.value ?: return
|
||||||
|
if (!st.ongoing) return
|
||||||
|
LogManager.addLog("INFO", "stream", "stopping publisher for call id=${st.id}")
|
||||||
|
stop()
|
||||||
|
state.first { it?.id != st.id || !it.ongoing }
|
||||||
|
LogManager.addLog("INFO", "stream", "publisher released for call id=${st.id}")
|
||||||
|
}
|
||||||
|
|
||||||
fun ended(id: Long, owner: StreamService) {
|
fun ended(id: Long, owner: StreamService) {
|
||||||
if (service === owner) service = null
|
if (service === owner) service = null
|
||||||
val st = mutable.value ?: return
|
val st = mutable.value ?: return
|
||||||
|
|||||||
@@ -5,10 +5,14 @@ import android.media.MediaCodecInfo
|
|||||||
import android.media.MediaCodecList
|
import android.media.MediaCodecList
|
||||||
import android.media.MediaFormat
|
import android.media.MediaFormat
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
import android.view.Surface
|
import android.view.Surface
|
||||||
import com.utun.chat.R
|
import com.utun.chat.R
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
import java.nio.ByteOrder
|
import java.nio.ByteOrder
|
||||||
import java.util.concurrent.locks.ReentrantLock
|
import java.util.concurrent.locks.ReentrantLock
|
||||||
@@ -18,7 +22,26 @@ import kotlin.concurrent.withLock
|
|||||||
* общий native controller ведёт минутную историю и темп, AudioTrack задаёт фактические часы.
|
* общий native controller ведёт минутную историю и темп, AudioTrack задаёт фактические часы.
|
||||||
* Временная нехватка сохраняет decoder references; GAP/вытеснение начинают новую точку KEY. */
|
* Временная нехватка сохраняет decoder references; GAP/вытеснение начинают новую точку KEY. */
|
||||||
class StreamPlayerEngine(private val group: Long, private val source: Long, private val epoch: Long) {
|
class StreamPlayerEngine(private val group: Long, private val source: Long, private val epoch: Long) {
|
||||||
companion object { private val viewerLock = ReentrantLock() }
|
companion object {
|
||||||
|
private val viewerLock = ReentrantLock()
|
||||||
|
private val main = Handler(Looper.getMainLooper())
|
||||||
|
private val players = mutableSetOf<StreamPlayerEngine>() // Только main thread.
|
||||||
|
private val mutableViewing = MutableStateFlow(false)
|
||||||
|
val viewing: StateFlow<Boolean> = mutableViewing
|
||||||
|
|
||||||
|
/** Закрыть просмотр и дождаться освобождения AudioTrack/decoder вне main thread. */
|
||||||
|
suspend fun stopForCall() {
|
||||||
|
val stopped = players.toList()
|
||||||
|
for (player in stopped) {
|
||||||
|
player.mutableStatus.value = R.string.stream_ended
|
||||||
|
player.stop()
|
||||||
|
}
|
||||||
|
withContext(Dispatchers.IO) { for (player in stopped) player.worker?.join() }
|
||||||
|
players.removeAll(stopped.toSet())
|
||||||
|
mutableViewing.value = players.isNotEmpty()
|
||||||
|
LogManager.addLog("INFO", "stream", "viewers released for call count=${stopped.size}")
|
||||||
|
}
|
||||||
|
}
|
||||||
private class UnsupportedCodec(message: String) : IllegalStateException(message)
|
private class UnsupportedCodec(message: String) : IllegalStateException(message)
|
||||||
private data class Frame(override val pts: Long, val key: Boolean, val width: Int, val height: Int,
|
private data class Frame(override val pts: Long, val key: Boolean, val width: Int, val height: Int,
|
||||||
val rotation: Int, val revision: Long, val codec: StreamVideoCodec,
|
val rotation: Int, val revision: Long, val codec: StreamVideoCodec,
|
||||||
@@ -33,8 +56,17 @@ class StreamPlayerEngine(private val group: Long, private val source: Long, priv
|
|||||||
|
|
||||||
fun start(surface: Surface) {
|
fun start(surface: Surface) {
|
||||||
if (worker != null) return
|
if (worker != null) return
|
||||||
|
if (CallController.blocksMedia()) {
|
||||||
|
LogManager.addLog("WARN", "stream", "viewer blocked by call id=$id")
|
||||||
|
mutableStatus.value = R.string.stream_ended
|
||||||
|
return
|
||||||
|
}
|
||||||
|
players.add(this); mutableViewing.value = true
|
||||||
running = true
|
running = true
|
||||||
worker = Thread({ viewerLock.withLock { if (running) play(surface) } }, "stream-player").apply { start() }
|
worker = Thread({
|
||||||
|
try { viewerLock.withLock { if (running) play(surface) } }
|
||||||
|
finally { main.post { players.remove(this); mutableViewing.value = players.isNotEmpty() } }
|
||||||
|
}, "stream-player").apply { start() }
|
||||||
}
|
}
|
||||||
fun stop() { running = false; NativeLib.streamStop(id) }
|
fun stop() { running = false; NativeLib.streamStop(id) }
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import android.content.ContextWrapper
|
|||||||
import android.content.pm.ActivityInfo
|
import android.content.pm.ActivityInfo
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import android.widget.Toast
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
@@ -27,6 +28,8 @@ import androidx.compose.material.icons.filled.Mic
|
|||||||
import androidx.compose.material.icons.filled.MicOff
|
import androidx.compose.material.icons.filled.MicOff
|
||||||
import androidx.compose.material.icons.filled.PhoneInTalk
|
import androidx.compose.material.icons.filled.PhoneInTalk
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
|
import androidx.compose.material.icons.filled.Videocam
|
||||||
|
import androidx.compose.material.icons.filled.VideocamOff
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
@@ -35,6 +38,7 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -57,6 +61,7 @@ import com.utun.chat.data.LogManager
|
|||||||
import com.utun.chat.viewmodel.ChatViewModel
|
import com.utun.chat.viewmodel.ChatViewModel
|
||||||
import com.utun.chat.data.CallPhase
|
import com.utun.chat.data.CallPhase
|
||||||
import com.utun.chat.data.CallController
|
import com.utun.chat.data.CallController
|
||||||
|
import com.utun.chat.data.CallVideoEngine
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
private tailrec fun Context.findActivity(): Activity? = when (this) {
|
private tailrec fun Context.findActivity(): Activity? = when (this) {
|
||||||
@@ -89,6 +94,7 @@ private fun CallControlButton(
|
|||||||
active: Boolean,
|
active: Boolean,
|
||||||
activeColor: Color = Color(0xFF1F6FEB),
|
activeColor: Color = Color(0xFF1F6FEB),
|
||||||
showLabel: Boolean = true,
|
showLabel: Boolean = true,
|
||||||
|
enabled: Boolean = true,
|
||||||
onClick: () -> Unit
|
onClick: () -> Unit
|
||||||
) {
|
) {
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
@@ -97,8 +103,8 @@ private fun CallControlButton(
|
|||||||
.background(if (active) activeColor else Color(0xFF24323D), CircleShape),
|
.background(if (active) activeColor else Color(0xFF24323D), CircleShape),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
IconButton(onClick = onClick) {
|
IconButton(onClick = onClick, enabled = enabled) {
|
||||||
Icon(icon, contentDescription, tint = Color.White, modifier = Modifier.size(28.dp))
|
Icon(icon, contentDescription, tint = Color.White.copy(alpha = if (enabled) 1f else 0.38f), modifier = Modifier.size(28.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (showLabel) {
|
if (showLabel) {
|
||||||
@@ -108,6 +114,27 @@ private fun CallControlButton(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CallVideoButton(video: CallVideoEngine, showLabel: Boolean, requestPermission: () -> Unit) {
|
||||||
|
val state by video.state.collectAsState()
|
||||||
|
val context = LocalContext.current
|
||||||
|
CallControlButton(
|
||||||
|
icon = if (state.settings.enabled) Icons.Filled.Videocam else Icons.Filled.VideocamOff,
|
||||||
|
contentDescription = stringResource(if (state.settings.enabled) R.string.call_video_disable else R.string.call_video_enable),
|
||||||
|
label = stringResource(R.string.camera),
|
||||||
|
active = state.settings.enabled,
|
||||||
|
showLabel = showLabel,
|
||||||
|
enabled = state.settings.enabled || (state.ready && state.codecs.isNotEmpty() && state.cameras.isNotEmpty()),
|
||||||
|
onClick = {
|
||||||
|
val enabled = !state.settings.enabled
|
||||||
|
LogManager.addLog("INFO", "call", "video button enabled=$enabled")
|
||||||
|
if (!enabled || ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED)
|
||||||
|
CallController.setVideo(state.settings.copy(enabled = enabled))
|
||||||
|
else requestPermission()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@OptIn(ExperimentalLayoutApi::class)
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
fun CallScreen(
|
fun CallScreen(
|
||||||
@@ -150,6 +177,18 @@ fun CallScreen(
|
|||||||
val locked = phase == CallPhase.ACTIVE && !videoActive && route == AudioRoute.EARPIECE && proximityNear == true
|
val locked = phase == CallPhase.ACTIVE && !videoActive && route == AudioRoute.EARPIECE && proximityNear == true
|
||||||
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
// Launcher живёт вместе с экраном, даже когда панель кнопок автоматически скрыта поверх видео.
|
||||||
|
var permissionVideo by remember { mutableStateOf<CallVideoEngine?>(null) }
|
||||||
|
val cameraPermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||||
|
val requestedVideo = permissionVideo
|
||||||
|
permissionVideo = null
|
||||||
|
LogManager.addLog(if (granted) "INFO" else "WARN", "call", "camera permission granted=$granted")
|
||||||
|
if (granted && requestedVideo != null && CallController.video.value === requestedVideo) {
|
||||||
|
CallController.setVideo(requestedVideo.state.value.settings.copy(enabled = true))
|
||||||
|
} else if (!granted) {
|
||||||
|
Toast.makeText(context, R.string.camera_permission_required, Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
val view = LocalView.current
|
val view = LocalView.current
|
||||||
DisposableEffect(view, videoActive) {
|
DisposableEffect(view, videoActive) {
|
||||||
val previous = view.keepScreenOn
|
val previous = view.keepScreenOn
|
||||||
@@ -213,11 +252,11 @@ fun CallScreen(
|
|||||||
onTap = { controlsVisible = !controlsVisible }, modifier = Modifier.matchParentSize())
|
onTap = { controlsVisible = !controlsVisible }, modifier = Modifier.matchParentSize())
|
||||||
}
|
}
|
||||||
if (!viewingVideo || controlsVisible) {
|
if (!viewingVideo || controlsVisible) {
|
||||||
if (!viewingVideo) Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxWidth().align(Alignment.Center)
|
modifier = Modifier.fillMaxWidth().align(if (viewingVideo) Alignment.BottomCenter else Alignment.Center)
|
||||||
.windowInsetsPadding(controlInsets)
|
.windowInsetsPadding(controlInsets)
|
||||||
.padding(top = if (hasVideo) 224.dp else 56.dp)
|
.padding(top = if (viewingVideo) 0.dp else if (hasVideo) 224.dp else 56.dp)
|
||||||
.background(Color(0xFF0F1B22)).verticalScroll(rememberScrollState())
|
.background(Color(0xFF0F1B22).copy(alpha = if (viewingVideo) 0.75f else 1f)).verticalScroll(rememberScrollState())
|
||||||
.padding(bottom = 24.dp),
|
.padding(bottom = 24.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
) {
|
) {
|
||||||
@@ -237,7 +276,8 @@ fun CallScreen(
|
|||||||
Text(name, color = Color.White, fontSize = 24.sp, fontWeight = FontWeight.Medium)
|
Text(name, color = Color.White, fontSize = 24.sp, fontWeight = FontWeight.Medium)
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
Text(
|
Text(
|
||||||
if (phase == CallPhase.ACTIVE) fmtDuration(duration) else callStatusText(phase, st?.reason ?: -1),
|
if (st?.preparing == true) stringResource(R.string.call_preparing_audio)
|
||||||
|
else if (phase == CallPhase.ACTIVE) fmtDuration(duration) else callStatusText(phase, st?.reason ?: -1),
|
||||||
color = Color(0xFFAEBCC8),
|
color = Color(0xFFAEBCC8),
|
||||||
fontSize = 16.sp
|
fontSize = 16.sp
|
||||||
)
|
)
|
||||||
@@ -320,7 +360,7 @@ fun CallScreen(
|
|||||||
Spacer(Modifier.height(28.dp))
|
Spacer(Modifier.height(28.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(48.dp), verticalAlignment = Alignment.CenterVertically) {
|
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
if (phase == CallPhase.ACTIVE) {
|
if (phase == CallPhase.ACTIVE) {
|
||||||
CallControlButton(
|
CallControlButton(
|
||||||
icon = if (muted) Icons.Filled.MicOff else Icons.Filled.Mic,
|
icon = if (muted) Icons.Filled.MicOff else Icons.Filled.Mic,
|
||||||
@@ -348,6 +388,25 @@ fun CallScreen(
|
|||||||
color = Color(0xFFAEBCC8), fontSize = 12.sp)
|
color = Color(0xFFAEBCC8), fontSize = 12.sp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (phase == CallPhase.ACTIVE) {
|
||||||
|
video?.let { engine ->
|
||||||
|
CallVideoButton(engine, showLabel = !viewingVideo, requestPermission = {
|
||||||
|
permissionVideo = engine
|
||||||
|
cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
CallControlButton(
|
||||||
|
icon = Icons.Filled.Settings,
|
||||||
|
contentDescription = stringResource(R.string.call_video_settings),
|
||||||
|
label = stringResource(R.string.settings),
|
||||||
|
active = false,
|
||||||
|
showLabel = !viewingVideo,
|
||||||
|
onClick = {
|
||||||
|
LogManager.addLog("DEBUG", "call", "video settings opened")
|
||||||
|
settingsVisible = true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -362,13 +421,9 @@ fun CallScreen(
|
|||||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(if (viewingVideo) R.string.call_controls else R.string.minimize_call),
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(if (viewingVideo) R.string.call_controls else R.string.minimize_call),
|
||||||
tint = Color.White, modifier = Modifier.size(24.dp))
|
tint = Color.White, modifier = Modifier.size(24.dp))
|
||||||
}
|
}
|
||||||
if (phase == CallPhase.ACTIVE) IconButton(onClick = { selectedVideo = null; settingsVisible = true },
|
|
||||||
modifier = Modifier.align(Alignment.TopEnd).windowInsetsPadding(controlInsets).padding(8.dp)) {
|
|
||||||
Icon(Icons.Filled.Settings, stringResource(R.string.settings), tint = Color.White)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (settingsVisible && phase == CallPhase.ACTIVE && !locked) AlertDialog(
|
if (settingsVisible && phase == CallPhase.ACTIVE && !locked) AlertDialog(
|
||||||
onDismissRequest = { settingsVisible = false }, title = { Text(stringResource(R.string.call_settings)) },
|
onDismissRequest = { settingsVisible = false }, title = { Text(stringResource(R.string.call_video_settings)) },
|
||||||
confirmButton = { TextButton(onClick = { settingsVisible = false }) { Text(stringResource(R.string.close)) } },
|
confirmButton = { TextButton(onClick = { settingsVisible = false }) { Text(stringResource(R.string.close)) } },
|
||||||
text = {
|
text = {
|
||||||
Column(Modifier.verticalScroll(rememberScrollState())) {
|
Column(Modifier.verticalScroll(rememberScrollState())) {
|
||||||
|
|||||||
@@ -1,14 +1,10 @@
|
|||||||
package com.utun.chat.ui.screens
|
package com.utun.chat.ui.screens
|
||||||
|
|
||||||
import android.Manifest
|
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.graphics.Matrix
|
import android.graphics.Matrix
|
||||||
import android.graphics.SurfaceTexture
|
import android.graphics.SurfaceTexture
|
||||||
import android.view.Surface
|
import android.view.Surface
|
||||||
import android.view.TextureView
|
import android.view.TextureView
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
@@ -17,12 +13,10 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.zIndex
|
import androidx.compose.ui.zIndex
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import com.utun.chat.data.CallController
|
import com.utun.chat.data.CallController
|
||||||
import com.utun.chat.data.CallVideoBitrateRange
|
import com.utun.chat.data.CallVideoBitrateRange
|
||||||
import com.utun.chat.data.CallVideoEngine
|
import com.utun.chat.data.CallVideoEngine
|
||||||
@@ -32,23 +26,8 @@ import com.utun.chat.R
|
|||||||
@Composable
|
@Composable
|
||||||
internal fun CallVideoPanel(video: CallVideoEngine) {
|
internal fun CallVideoPanel(video: CallVideoEngine) {
|
||||||
val state by video.state.collectAsState()
|
val state by video.state.collectAsState()
|
||||||
val context = LocalContext.current
|
|
||||||
var denied by remember(video) { mutableStateOf(false) }
|
|
||||||
val permission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
|
||||||
denied = !granted
|
|
||||||
if (granted && CallController.video.value === video) CallController.setVideo(state.settings.copy(enabled = true))
|
|
||||||
}
|
|
||||||
Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp)) {
|
Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp)) {
|
||||||
if (state.status.isNotEmpty()) Text(state.status)
|
if (state.status.isNotEmpty()) Text(state.status)
|
||||||
if (denied) Text(stringResource(R.string.camera_permission_required), color = MaterialTheme.colorScheme.error)
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
Text(stringResource(R.string.send_video), modifier = Modifier.weight(1f).padding(top = 12.dp))
|
|
||||||
Switch(state.settings.enabled, enabled = state.ready && state.codecs.isNotEmpty(), onCheckedChange = { enabled ->
|
|
||||||
if (!enabled || ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED)
|
|
||||||
CallController.setVideo(state.settings.copy(enabled = enabled))
|
|
||||||
else permission.launch(Manifest.permission.CAMERA)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
VideoChoice(stringResource(R.string.camera), state.cameras.map { it.name }, state.cameras.indexOfFirst { it.id == state.settings.camera }) { index ->
|
VideoChoice(stringResource(R.string.camera), state.cameras.map { it.name }, state.cameras.indexOfFirst { it.id == state.settings.camera }) { index ->
|
||||||
val camera = state.cameras[index]
|
val camera = state.cameras[index]
|
||||||
val size = camera.sizes.firstOrNull { it.width == state.settings.width && it.height == state.settings.height } ?: camera.sizes.first()
|
val size = camera.sizes.firstOrNull { it.width == state.settings.width && it.height == state.settings.height } ?: camera.sizes.first()
|
||||||
|
|||||||
+15
-1
@@ -18,6 +18,9 @@ import androidx.compose.ui.text.font.FontWeight
|
|||||||
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.viewmodel.ChatViewModel
|
import com.utun.chat.viewmodel.ChatViewModel
|
||||||
|
import com.utun.chat.data.CallController
|
||||||
|
import com.utun.chat.data.StreamController
|
||||||
|
import com.utun.chat.data.StreamPlayerEngine
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Входящий звонок — inline-оверлей внутри окна активити (не отдельное Dialog-окно):
|
* Входящий звонок — inline-оверлей внутри окна активити (не отдельное Dialog-окно):
|
||||||
@@ -29,6 +32,9 @@ fun IncomingCallDialog(
|
|||||||
) {
|
) {
|
||||||
val st = viewModel.callState.collectAsState().value ?: return
|
val st = viewModel.callState.collectAsState().value ?: return
|
||||||
val name = st.peerName.ifEmpty { "0x%016x".format(st.peerNodeId) }
|
val name = st.peerName.ifEmpty { "0x%016x".format(st.peerNodeId) }
|
||||||
|
val radio = viewModel.radioAudio.activeGroup.collectAsState().value
|
||||||
|
val publisher = StreamController.state.collectAsState().value
|
||||||
|
val viewing = StreamPlayerEngine.viewing.collectAsState().value
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.fillMaxSize().background(Color(0xCC000000)),
|
modifier = Modifier.fillMaxSize().background(Color(0xCC000000)),
|
||||||
@@ -54,6 +60,14 @@ fun IncomingCallDialog(
|
|||||||
Text(stringResource(R.string.incoming_call_waiting), color = Color(0xFFAEBCC8), fontSize = 14.sp)
|
Text(stringResource(R.string.incoming_call_waiting), color = Color(0xFFAEBCC8), fontSize = 14.sp)
|
||||||
Spacer(Modifier.height(6.dp))
|
Spacer(Modifier.height(6.dp))
|
||||||
Text(stringResource(R.string.connection_label, st.connectionType.label), color = Color(0xFFAEBCC8), fontSize = 13.sp)
|
Text(stringResource(R.string.connection_label, st.connectionType.label), color = Color(0xFFAEBCC8), fontSize = 13.sp)
|
||||||
|
if (radio != null || publisher?.ongoing == true || viewing) {
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Text(stringResource(CallController.mediaWarning(incoming = true)), color = Color(0xFFFFCC80), fontSize = 14.sp)
|
||||||
|
}
|
||||||
|
if (st.preparing) {
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Text(stringResource(R.string.call_preparing_audio), color = Color.White, fontSize = 14.sp)
|
||||||
|
}
|
||||||
Spacer(Modifier.height(28.dp))
|
Spacer(Modifier.height(28.dp))
|
||||||
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(48.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(48.dp)) {
|
||||||
@@ -73,7 +87,7 @@ fun IncomingCallDialog(
|
|||||||
modifier = Modifier.size(64.dp).background(Color(0xFF43A047), CircleShape),
|
modifier = Modifier.size(64.dp).background(Color(0xFF43A047), CircleShape),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
IconButton(onClick = { viewModel.acceptCall() }) {
|
IconButton(onClick = { viewModel.acceptCall() }, enabled = !st.preparing) {
|
||||||
Icon(Icons.Filled.Call, stringResource(R.string.accept), tint = Color.White, modifier = Modifier.size(30.dp))
|
Icon(Icons.Filled.Call, stringResource(R.string.accept), tint = Color.White, modifier = Modifier.size(30.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -195,7 +195,7 @@ class ChatViewModel : ViewModel() {
|
|||||||
private var radioToggleCount = 0
|
private var radioToggleCount = 0
|
||||||
|
|
||||||
/* рация: активный канал (null = выключена). Одна рация — один канал. */
|
/* рация: активный канал (null = выключена). Одна рация — один канал. */
|
||||||
private var radioChannelId: String? = null
|
private val radioChannelId: String? get() = radioAudio.activeGroup.value?.toULong()?.toString()
|
||||||
|
|
||||||
val muted = CallController.muted
|
val muted = CallController.muted
|
||||||
val audioRoute = CallController.route
|
val audioRoute = CallController.route
|
||||||
@@ -357,6 +357,31 @@ class ChatViewModel : ViewModel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
init {
|
init {
|
||||||
|
radioAudio.router.onChanged = { route, headset ->
|
||||||
|
_radioRoute.value = route
|
||||||
|
_radioHeadsetAvailable.value = headset
|
||||||
|
}
|
||||||
|
radioAudio.router.onInputDevicesChanged = { wired, bt ->
|
||||||
|
_wiredInputAvailable.value = wired
|
||||||
|
_btInputAvailable.value = bt
|
||||||
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
radioAudio.activeGroup.collect { group ->
|
||||||
|
if (group == null) clearRadioControls()
|
||||||
|
else {
|
||||||
|
_radioActive.value = radioChannelId == _currentChannel.value?.id
|
||||||
|
_radioTalkState.value = RadioTalkState(group)
|
||||||
|
_radioInputSource.value = radioAudio.inputSource()
|
||||||
|
_radioAuto.value = _radioInputSource.value == RadioAudioEngine.INPUT_AUTO
|
||||||
|
}
|
||||||
|
syncRadioOverlay()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
CallController.state.collect { call ->
|
||||||
|
if (call?.preparing == true || CallController.blocksMedia()) _streamSelection.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_cpuLoadVisible.collectLatest { visible ->
|
_cpuLoadVisible.collectLatest { visible ->
|
||||||
if (!visible) return@collectLatest
|
if (!visible) return@collectLatest
|
||||||
@@ -576,7 +601,6 @@ class ChatViewModel : ViewModel() {
|
|||||||
_radioTalkState.value = RadioTalkState()
|
_radioTalkState.value = RadioTalkState()
|
||||||
_radioTalking.value = null
|
_radioTalking.value = null
|
||||||
_radioActive.value = false
|
_radioActive.value = false
|
||||||
radioChannelId = null
|
|
||||||
radioAudio.stop()
|
radioAudio.stop()
|
||||||
memberRadios.clear()
|
memberRadios.clear()
|
||||||
memberRadioMutes.clear()
|
memberRadioMutes.clear()
|
||||||
@@ -1018,10 +1042,15 @@ class ChatViewModel : ViewModel() {
|
|||||||
"toggleRadio#$radioToggleCount next=$next prevActive=${_radioActive.value} " +
|
"toggleRadio#$radioToggleCount next=$next prevActive=${_radioActive.value} " +
|
||||||
"radioChannelId=$radioChannelId cur=${ch.id} " +
|
"radioChannelId=$radioChannelId cur=${ch.id} " +
|
||||||
"thread=${Thread.currentThread().name} t=${System.currentTimeMillis()} caller=$caller")
|
"thread=${Thread.currentThread().name} t=${System.currentTimeMillis()} caller=$caller")
|
||||||
|
if (next && CallController.blocksMedia()) {
|
||||||
|
LogManager.addLog("WARN", "radio", "enable rejected: call owns audio group=$groupId")
|
||||||
|
android.widget.Toast.makeText(ChatApplication.instance, R.string.call_media_busy, android.widget.Toast.LENGTH_SHORT).show()
|
||||||
|
onReady?.invoke(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (next) {
|
if (next) {
|
||||||
radioChannelId?.takeIf { it != ch.id }?.toULongOrNull()?.toLong()
|
radioChannelId?.takeIf { it != ch.id }?.toULongOrNull()?.toLong()
|
||||||
?.let { NativeLib.radioSetActive(it, false) } /* выключаем старый канал */
|
?.let { NativeLib.radioSetActive(it, false) } /* выключаем старый канал */
|
||||||
radioChannelId = ch.id
|
|
||||||
_radioTalkState.value = RadioTalkState(groupId)
|
_radioTalkState.value = RadioTalkState(groupId)
|
||||||
_radioTalking.value = null
|
_radioTalking.value = null
|
||||||
NativeLib.radioSetActive(groupId, true)
|
NativeLib.radioSetActive(groupId, true)
|
||||||
@@ -1029,14 +1058,6 @@ class ChatViewModel : ViewModel() {
|
|||||||
if (!ready && radioChannelId == ch.id && !radioAudio.isActive()) turnRadioOff()
|
if (!ready && radioChannelId == ch.id && !radioAudio.isActive()) turnRadioOff()
|
||||||
onReady?.invoke(ready)
|
onReady?.invoke(ready)
|
||||||
}
|
}
|
||||||
radioAudio.router.onChanged = { r, headset ->
|
|
||||||
_radioRoute.value = r
|
|
||||||
_radioHeadsetAvailable.value = headset
|
|
||||||
}
|
|
||||||
radioAudio.router.onInputDevicesChanged = { wired, bt ->
|
|
||||||
_wiredInputAvailable.value = wired
|
|
||||||
_btInputAvailable.value = bt
|
|
||||||
}
|
|
||||||
_radioRoute.value = radioAudio.route()
|
_radioRoute.value = radioAudio.route()
|
||||||
_radioHeadsetAvailable.value = radioAudio.headsetAvailable()
|
_radioHeadsetAvailable.value = radioAudio.headsetAvailable()
|
||||||
_wiredInputAvailable.value = radioAudio.router.wiredInputAvailable
|
_wiredInputAvailable.value = radioAudio.router.wiredInputAvailable
|
||||||
@@ -1044,7 +1065,6 @@ class ChatViewModel : ViewModel() {
|
|||||||
_radioInputSource.value = radioAudio.inputSource()
|
_radioInputSource.value = radioAudio.inputSource()
|
||||||
_radioAuto.value = _radioInputSource.value == RadioAudioEngine.INPUT_AUTO
|
_radioAuto.value = _radioInputSource.value == RadioAudioEngine.INPUT_AUTO
|
||||||
} else {
|
} else {
|
||||||
radioChannelId = null
|
|
||||||
NativeLib.radioSetActive(groupId, false)
|
NativeLib.radioSetActive(groupId, false)
|
||||||
radioAudio.stop()
|
radioAudio.stop()
|
||||||
_radioTalking.value = null
|
_radioTalking.value = null
|
||||||
@@ -1156,7 +1176,12 @@ class ChatViewModel : ViewModel() {
|
|||||||
private fun turnRadioOff() {
|
private fun turnRadioOff() {
|
||||||
val chId = radioChannelId ?: return
|
val chId = radioChannelId ?: return
|
||||||
chId.toULongOrNull()?.toLong()?.let { NativeLib.radioSetActive(it, false) }
|
chId.toULongOrNull()?.toLong()?.let { NativeLib.radioSetActive(it, false) }
|
||||||
radioChannelId = null
|
radioAudio.stop()
|
||||||
|
clearRadioControls()
|
||||||
|
syncRadioOverlay()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clearRadioControls() {
|
||||||
_radioActive.value = false
|
_radioActive.value = false
|
||||||
_radioTalking.value = null
|
_radioTalking.value = null
|
||||||
_radioTalkState.value = RadioTalkState()
|
_radioTalkState.value = RadioTalkState()
|
||||||
@@ -1166,8 +1191,6 @@ class ChatViewModel : ViewModel() {
|
|||||||
_btInputAvailable.value = false
|
_btInputAvailable.value = false
|
||||||
_radioInputSource.value = RadioAudioEngine.INPUT_AUTO
|
_radioInputSource.value = RadioAudioEngine.INPUT_AUTO
|
||||||
_radioAuto.value = false
|
_radioAuto.value = false
|
||||||
radioAudio.stop()
|
|
||||||
syncRadioOverlay()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun syncRadioOverlay() {
|
private fun syncRadioOverlay() {
|
||||||
|
|||||||
@@ -18,6 +18,20 @@
|
|||||||
<string name="delete_conversation_confirm">Удалить переписку с %1$s и её локальные файлы? У собеседника история сохранится.</string>
|
<string name="delete_conversation_confirm">Удалить переписку с %1$s и её локальные файлы? У собеседника история сохранится.</string>
|
||||||
<string name="delete_conversation_failed">Не удалось удалить переписку. Повторите попытку.</string>
|
<string name="delete_conversation_failed">Не удалось удалить переписку. Повторите попытку.</string>
|
||||||
<string name="call_settings">Настройки звонка</string>
|
<string name="call_settings">Настройки звонка</string>
|
||||||
|
<string name="call_stop_media_title">Начать звонок?</string>
|
||||||
|
<string name="call_stop_media_confirm">Продолжить</string>
|
||||||
|
<string name="call_outgoing_stop_radio">Для звонка необходимо выключить рацию. После звонка она включится снова.</string>
|
||||||
|
<string name="call_outgoing_stop_stream">Для звонка необходимо завершить трансляцию или её просмотр.</string>
|
||||||
|
<string name="call_outgoing_stop_media">Для звонка необходимо выключить рацию и завершить трансляцию или её просмотр. После звонка рация включится снова.</string>
|
||||||
|
<string name="call_incoming_stop_radio">При принятии звонка рация выключится. После звонка она включится снова.</string>
|
||||||
|
<string name="call_incoming_stop_stream">При принятии звонка трансляция или её просмотр завершится.</string>
|
||||||
|
<string name="call_incoming_stop_media">При принятии звонка рация выключится, трансляция или её просмотр завершится. После звонка рация включится снова.</string>
|
||||||
|
<string name="call_preparing_audio">Освобождаем аудиоустройства…</string>
|
||||||
|
<string name="call_media_stop_failed">Не удалось освободить аудиоустройства для звонка.</string>
|
||||||
|
<string name="call_media_busy">Во время звонка звук рации недоступен.</string>
|
||||||
|
<string name="call_video_settings">Настройки видео</string>
|
||||||
|
<string name="call_video_enable">Включить видео</string>
|
||||||
|
<string name="call_video_disable">Выключить видео</string>
|
||||||
<string name="call_controls">Управление звонком</string>
|
<string name="call_controls">Управление звонком</string>
|
||||||
<string name="call_video_local">Своё видео</string>
|
<string name="call_video_local">Своё видео</string>
|
||||||
<string name="call_video_remote">Видео собеседника</string>
|
<string name="call_video_remote">Видео собеседника</string>
|
||||||
|
|||||||
@@ -18,6 +18,20 @@
|
|||||||
<string name="delete_conversation_confirm">Delete the conversation with %1$s and its local files? The other person keeps their history.</string>
|
<string name="delete_conversation_confirm">Delete the conversation with %1$s and its local files? The other person keeps their history.</string>
|
||||||
<string name="delete_conversation_failed">Could not delete the conversation. Please try again.</string>
|
<string name="delete_conversation_failed">Could not delete the conversation. Please try again.</string>
|
||||||
<string name="call_settings">Call settings</string>
|
<string name="call_settings">Call settings</string>
|
||||||
|
<string name="call_stop_media_title">Start call?</string>
|
||||||
|
<string name="call_stop_media_confirm">Continue</string>
|
||||||
|
<string name="call_outgoing_stop_radio">The radio must be turned off for the call. It will turn back on after the call.</string>
|
||||||
|
<string name="call_outgoing_stop_stream">The broadcast or stream playback must end before starting the call.</string>
|
||||||
|
<string name="call_outgoing_stop_media">The radio must be turned off and the broadcast or stream playback must end before starting the call. The radio will turn back on after the call.</string>
|
||||||
|
<string name="call_incoming_stop_radio">Answering will turn off the radio. It will turn back on after the call.</string>
|
||||||
|
<string name="call_incoming_stop_stream">Answering will end the broadcast or stream playback.</string>
|
||||||
|
<string name="call_incoming_stop_media">Answering will turn off the radio and end the broadcast or stream playback. The radio will turn back on after the call.</string>
|
||||||
|
<string name="call_preparing_audio">Releasing audio devices…</string>
|
||||||
|
<string name="call_media_stop_failed">Could not release the audio devices for the call.</string>
|
||||||
|
<string name="call_media_busy">Radio audio is unavailable during a call.</string>
|
||||||
|
<string name="call_video_settings">Video settings</string>
|
||||||
|
<string name="call_video_enable">Turn video on</string>
|
||||||
|
<string name="call_video_disable">Turn video off</string>
|
||||||
<string name="call_controls">Call controls</string>
|
<string name="call_controls">Call controls</string>
|
||||||
<string name="call_video_local">Your video</string>
|
<string name="call_video_local">Your video</string>
|
||||||
<string name="call_video_remote">Peer video</string>
|
<string name="call_video_remote">Peer video</string>
|
||||||
|
|||||||
Reference in new issue
Block a user