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
@@ -542,6 +542,18 @@ class MainActivity : AppCompatActivity() {
|
||||
pmInvite?.let { state ->
|
||||
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()
|
||||
localInvitations.firstOrNull()?.let { invitation ->
|
||||
AlertDialog(
|
||||
|
||||
@@ -83,8 +83,21 @@ class CallAudioEngine {
|
||||
private val trackRouteListener = AudioRouting.OnRoutingChangedListener { output ->
|
||||
synchronized(trackLock) {
|
||||
val track = audioTrack
|
||||
if (active && track != null && output === track.track && Build.VERSION.SDK_INT < 31) {
|
||||
confirmedDeviceId = track.routedDevice?.id ?: 0
|
||||
if (active && track != null && output === track.track) {
|
||||
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 звук) */
|
||||
if (sinceSwap < 10 || frame % 100L == 0L) {
|
||||
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++
|
||||
frame++
|
||||
@@ -478,6 +492,7 @@ class CallAudioEngine {
|
||||
}
|
||||
LogManager.addLog("INFO", "CallAudio", "capture device ready id=$callId minQueryMs=$queryMs constructorMs=$constructorMs " +
|
||||
"routingMs=${SystemClock.elapsedRealtime() - routeAt} minBytes=$recMin")
|
||||
rec.addOnRoutingChangedListener(recordRouteListener, mainHandler)
|
||||
return rec
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -23,6 +25,8 @@ import kotlinx.coroutines.launch
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
|
||||
data class OutgoingCallRequest(val channelId: String, val peerNodeId: Long, val peerName: String)
|
||||
|
||||
/**
|
||||
* Единственный владелец звонковой сессии в процессе приложения. Все операции — main thread.
|
||||
* Сервис владеет аудио и wake lock, ядро — сигналингом, ViewModel только читает StateFlow.
|
||||
@@ -32,6 +36,37 @@ object CallController : TelecomCallManager.Listener {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||
private val _state = MutableStateFlow<CallState?>(null)
|
||||
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)
|
||||
val video: StateFlow<CallVideoEngine?> = _video
|
||||
private val _videoActive = MutableStateFlow(false)
|
||||
@@ -59,6 +94,7 @@ object CallController : TelecomCallManager.Listener {
|
||||
|
||||
fun refreshLanguage() { service?.refreshLanguage() }
|
||||
private var serviceRequested = false
|
||||
private var signallingStarted = false
|
||||
private var accepting = false
|
||||
private var acceptSent = false
|
||||
private var answerWhenVisible = 0L
|
||||
@@ -105,13 +141,12 @@ object CallController : TelecomCallManager.Listener {
|
||||
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()
|
||||
if (RadioCallController.busy()) {
|
||||
LogManager.addLog("WARN", "Call", "start rejected: pending radio invitation")
|
||||
return
|
||||
}
|
||||
if (StreamController.blocksCapture()) return
|
||||
if (channelId.isEmpty() || peerNodeId == 0L) {
|
||||
LogManager.addLog("ERROR", "Call", "invalid outgoing call channel=$channelId peer=$peerNodeId")
|
||||
return
|
||||
@@ -120,20 +155,27 @@ object CallController : TelecomCallManager.Listener {
|
||||
LogManager.addLog("WARN", "Call", "start rejected: busy=${_state.value?.ongoing} core=$coreReady native=${NativeLib.isReady()}")
|
||||
return
|
||||
}
|
||||
if (!mediaConfirmed && hasMedia()) {
|
||||
mutableOutgoing.value = OutgoingCallRequest(channelId, peerNodeId, peerName)
|
||||
LogManager.addLog("INFO", "Call", "outgoing requires media confirmation peer=$peerNodeId")
|
||||
return
|
||||
}
|
||||
if (!MicrophonePermission.granted()) {
|
||||
MicrophonePermission.request("Outgoing call") { start(channelId, peerNodeId, peerName) }
|
||||
MicrophonePermission.request("Outgoing call") { start(channelId, peerNodeId, peerName, mediaConfirmed) }
|
||||
return
|
||||
}
|
||||
var id = java.security.SecureRandom().nextLong()
|
||||
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()
|
||||
requestService(id)
|
||||
if (_state.value?.callId == id && _state.value?.ongoing == true) prepareMedia(id)
|
||||
}
|
||||
|
||||
fun accept() {
|
||||
checkMain()
|
||||
if (StreamController.blocksCapture()) return
|
||||
val st = _state.value ?: return
|
||||
if (st.phase != CallPhase.INCOMING || accepting) {
|
||||
LogManager.addLog("WARN", "Call", "accept ignored id=${st.callId} phase=${st.phase} accepting=$accepting")
|
||||
@@ -147,7 +189,41 @@ object CallController : TelecomCallManager.Listener {
|
||||
return
|
||||
}
|
||||
accepting = true
|
||||
_state.value = st.copy(preparing = true)
|
||||
IncomingCallManager.stopRinging()
|
||||
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) {
|
||||
@@ -174,16 +250,26 @@ object CallController : TelecomCallManager.Listener {
|
||||
}
|
||||
service = owner
|
||||
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) {
|
||||
signallingStarted = true
|
||||
NativeLib.callStart(st.channelId, st.peerNodeId, id)
|
||||
TelecomCallManager.placeCall(id, st.peerNodeId, st.peerName)
|
||||
CallRingback.startConnecting()
|
||||
} else if (st.phase == CallPhase.INCOMING && accepting) {
|
||||
signallingStarted = true
|
||||
IncomingCallManager.stopRinging()
|
||||
acceptSent = true
|
||||
NativeLib.callAccept(id)
|
||||
}
|
||||
return 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
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -223,6 +310,7 @@ object CallController : TelecomCallManager.Listener {
|
||||
val owner = service
|
||||
service = null
|
||||
serviceRequested = false
|
||||
signallingStarted = false
|
||||
accepting = false
|
||||
acceptSent = false
|
||||
answerWhenVisible = 0L
|
||||
@@ -238,10 +326,18 @@ object CallController : TelecomCallManager.Listener {
|
||||
_route.value = AudioRoute.SPEAKER
|
||||
_headset.value = false
|
||||
_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)
|
||||
syncCoreActivity()
|
||||
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) {
|
||||
val app = ChatApplication.instance
|
||||
val text = AppLanguage.text(R.string.phone_busy_warning)
|
||||
@@ -268,6 +364,8 @@ object CallController : TelecomCallManager.Listener {
|
||||
/** Перед stop/restart ядра освобождаем аудио, чтобы workers больше не обращались к native-ресурсам. */
|
||||
fun reset() {
|
||||
checkMain()
|
||||
mutableOutgoing.value = null
|
||||
radioToRestore = null
|
||||
coreReady = false
|
||||
CoreActivity.reset()
|
||||
end()
|
||||
@@ -357,6 +455,7 @@ object CallController : TelecomCallManager.Listener {
|
||||
return
|
||||
}
|
||||
incomingAt = SystemClock.elapsedRealtime()
|
||||
mutableOutgoing.value = null
|
||||
// Однократное чтение на main завершается до reset; фоновый JNI-запрос не переживает ядро.
|
||||
val name = ChatRepository().getMembers(channel).firstOrNull { it.nodeId == peer }?.name
|
||||
?.takeIf { it.isNotEmpty() } ?: "0x%016x".format(peer)
|
||||
|
||||
@@ -34,7 +34,8 @@ data class CallState(
|
||||
val stats: CallStats? = null,
|
||||
val startedAtElapsedRealtime: Long = 0,
|
||||
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
|
||||
}
|
||||
@@ -150,6 +150,11 @@ object IncomingCallManager {
|
||||
.setContentIntent(contentPi)
|
||||
.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) {
|
||||
val answerPi = PendingIntent.getActivity(ctx, 2, Intent(launchIntent).apply {
|
||||
action = "com.utun.chat.ANSWER_RADIO"
|
||||
|
||||
@@ -21,6 +21,7 @@ import com.utun.chat.ChatApplication
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.Future
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
/**
|
||||
@@ -63,7 +64,6 @@ class RadioAudioEngine {
|
||||
private var captureThread: Thread? = null
|
||||
@Volatile private var audioTrack: AudioTrack? = null
|
||||
private var playThread: Thread? = null
|
||||
private var prevMode: Int = AudioManager.MODE_NORMAL
|
||||
private var prevSpeaker: Boolean = false
|
||||
private var audioModeOwned = false
|
||||
private val streamPriorities = AtomicLong()
|
||||
@@ -73,6 +73,8 @@ class RadioAudioEngine {
|
||||
|
||||
@Volatile private var active = false
|
||||
@Volatile private var groupId = 0L
|
||||
private val mutableGroup = MutableStateFlow<Long?>(null)
|
||||
val activeGroup: StateFlow<Long?> = mutableGroup
|
||||
@Volatile private var talking = false
|
||||
@Volatile private var captureRunning = false
|
||||
@Volatile private var playbackRunning = false
|
||||
@@ -180,6 +182,11 @@ class RadioAudioEngine {
|
||||
/** Начать прослушивание канала (переключатель рации вкл).
|
||||
* Тяжёлая инициализация аудио выполняется на io-потоке, не блокируя main. */
|
||||
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) {
|
||||
val version = generation
|
||||
io.execute { mainHandler.post { onReady?.invoke(active && generation == version && audioTrack != null) } }
|
||||
@@ -189,6 +196,7 @@ class RadioAudioEngine {
|
||||
val version = ++generation
|
||||
active = true
|
||||
groupId = group
|
||||
mutableGroup.value = group
|
||||
requestedTalk = false
|
||||
pttHolds.cancel()
|
||||
_talkingFlow.value = false
|
||||
@@ -210,14 +218,55 @@ class RadioAudioEngine {
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
fun stop(): Future<*> {
|
||||
mainHandler.removeCallbacks(releaseTalk)
|
||||
active = false
|
||||
mutableGroup.value = null
|
||||
requestedTalk = false
|
||||
pttHolds.cancel()
|
||||
_talkingFlow.value = false
|
||||
++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() {
|
||||
loadCaptureSettings()
|
||||
val group = audioGroup
|
||||
val ctx = ChatApplication.instance
|
||||
if (streamPriority == 0L) acquireAudioMode()
|
||||
router.setGlobalRouting(streamPriority == 0L)
|
||||
|
||||
@@ -799,7 +847,7 @@ class RadioAudioEngine {
|
||||
private fun acquireAudioMode() {
|
||||
if (audioModeOwned) return
|
||||
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 }
|
||||
catch (e: Exception) { LogManager.addLog("WARN", "radio", "set audio mode failed: ${e.message}") }
|
||||
}
|
||||
@@ -810,7 +858,8 @@ class RadioAudioEngine {
|
||||
try {
|
||||
val am = ChatApplication.instance.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
am.isSpeakerphoneOn = prevSpeaker
|
||||
am.mode = prevMode
|
||||
// MODE_NORMAL снимает нашу заявку; прежний глобальный mode мог принадлежать завершаемому Telecom-звонку.
|
||||
am.mode = AudioManager.MODE_NORMAL
|
||||
} catch (e: Exception) {
|
||||
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.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
enum class StreamPhase { STARTING, LIVE, STOPPING, ENDED, FAILED }
|
||||
@@ -131,6 +132,16 @@ object StreamController {
|
||||
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) {
|
||||
if (service === owner) service = null
|
||||
val st = mutable.value ?: return
|
||||
|
||||
@@ -5,10 +5,14 @@ import android.media.MediaCodecInfo
|
||||
import android.media.MediaCodecList
|
||||
import android.media.MediaFormat
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.view.Surface
|
||||
import com.utun.chat.R
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import java.util.concurrent.locks.ReentrantLock
|
||||
@@ -18,7 +22,26 @@ import kotlin.concurrent.withLock
|
||||
* общий native controller ведёт минутную историю и темп, AudioTrack задаёт фактические часы.
|
||||
* Временная нехватка сохраняет decoder references; GAP/вытеснение начинают новую точку KEY. */
|
||||
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 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,
|
||||
@@ -33,8 +56,17 @@ class StreamPlayerEngine(private val group: Long, private val source: Long, priv
|
||||
|
||||
fun start(surface: Surface) {
|
||||
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
|
||||
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) }
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import android.content.ContextWrapper
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
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.PhoneInTalk
|
||||
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.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
@@ -35,6 +38,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
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.data.CallPhase
|
||||
import com.utun.chat.data.CallController
|
||||
import com.utun.chat.data.CallVideoEngine
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
private tailrec fun Context.findActivity(): Activity? = when (this) {
|
||||
@@ -89,6 +94,7 @@ private fun CallControlButton(
|
||||
active: Boolean,
|
||||
activeColor: Color = Color(0xFF1F6FEB),
|
||||
showLabel: Boolean = true,
|
||||
enabled: Boolean = true,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
@@ -97,8 +103,8 @@ private fun CallControlButton(
|
||||
.background(if (active) activeColor else Color(0xFF24323D), CircleShape),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
IconButton(onClick = onClick) {
|
||||
Icon(icon, contentDescription, tint = Color.White, modifier = Modifier.size(28.dp))
|
||||
IconButton(onClick = onClick, enabled = enabled) {
|
||||
Icon(icon, contentDescription, tint = Color.White.copy(alpha = if (enabled) 1f else 0.38f), modifier = Modifier.size(28.dp))
|
||||
}
|
||||
}
|
||||
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
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
fun CallScreen(
|
||||
@@ -150,6 +177,18 @@ fun CallScreen(
|
||||
val locked = phase == CallPhase.ACTIVE && !videoActive && route == AudioRoute.EARPIECE && proximityNear == true
|
||||
|
||||
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
|
||||
DisposableEffect(view, videoActive) {
|
||||
val previous = view.keepScreenOn
|
||||
@@ -213,11 +252,11 @@ fun CallScreen(
|
||||
onTap = { controlsVisible = !controlsVisible }, modifier = Modifier.matchParentSize())
|
||||
}
|
||||
if (!viewingVideo || controlsVisible) {
|
||||
if (!viewingVideo) Column(
|
||||
modifier = Modifier.fillMaxWidth().align(Alignment.Center)
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().align(if (viewingVideo) Alignment.BottomCenter else Alignment.Center)
|
||||
.windowInsetsPadding(controlInsets)
|
||||
.padding(top = if (hasVideo) 224.dp else 56.dp)
|
||||
.background(Color(0xFF0F1B22)).verticalScroll(rememberScrollState())
|
||||
.padding(top = if (viewingVideo) 0.dp else if (hasVideo) 224.dp else 56.dp)
|
||||
.background(Color(0xFF0F1B22).copy(alpha = if (viewingVideo) 0.75f else 1f)).verticalScroll(rememberScrollState())
|
||||
.padding(bottom = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
@@ -237,7 +276,8 @@ fun CallScreen(
|
||||
Text(name, color = Color.White, fontSize = 24.sp, fontWeight = FontWeight.Medium)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
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),
|
||||
fontSize = 16.sp
|
||||
)
|
||||
@@ -320,7 +360,7 @@ fun CallScreen(
|
||||
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) {
|
||||
CallControlButton(
|
||||
icon = if (muted) Icons.Filled.MicOff else Icons.Filled.Mic,
|
||||
@@ -348,6 +388,25 @@ fun CallScreen(
|
||||
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),
|
||||
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(
|
||||
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)) } },
|
||||
text = {
|
||||
Column(Modifier.verticalScroll(rememberScrollState())) {
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
package com.utun.chat.ui.screens
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.Context
|
||||
import android.graphics.Matrix
|
||||
import android.graphics.SurfaceTexture
|
||||
import android.view.Surface
|
||||
import android.view.TextureView
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
@@ -17,12 +13,10 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.utun.chat.data.CallController
|
||||
import com.utun.chat.data.CallVideoBitrateRange
|
||||
import com.utun.chat.data.CallVideoEngine
|
||||
@@ -32,23 +26,8 @@ import com.utun.chat.R
|
||||
@Composable
|
||||
internal fun CallVideoPanel(video: CallVideoEngine) {
|
||||
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)) {
|
||||
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 ->
|
||||
val camera = state.cameras[index]
|
||||
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.sp
|
||||
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-окно):
|
||||
@@ -29,6 +32,9 @@ fun IncomingCallDialog(
|
||||
) {
|
||||
val st = viewModel.callState.collectAsState().value ?: return
|
||||
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(
|
||||
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)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
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))
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(48.dp)) {
|
||||
@@ -73,7 +87,7 @@ fun IncomingCallDialog(
|
||||
modifier = Modifier.size(64.dp).background(Color(0xFF43A047), CircleShape),
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,7 +195,7 @@ class ChatViewModel : ViewModel() {
|
||||
private var radioToggleCount = 0
|
||||
|
||||
/* рация: активный канал (null = выключена). Одна рация — один канал. */
|
||||
private var radioChannelId: String? = null
|
||||
private val radioChannelId: String? get() = radioAudio.activeGroup.value?.toULong()?.toString()
|
||||
|
||||
val muted = CallController.muted
|
||||
val audioRoute = CallController.route
|
||||
@@ -357,6 +357,31 @@ class ChatViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
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 {
|
||||
_cpuLoadVisible.collectLatest { visible ->
|
||||
if (!visible) return@collectLatest
|
||||
@@ -576,7 +601,6 @@ class ChatViewModel : ViewModel() {
|
||||
_radioTalkState.value = RadioTalkState()
|
||||
_radioTalking.value = null
|
||||
_radioActive.value = false
|
||||
radioChannelId = null
|
||||
radioAudio.stop()
|
||||
memberRadios.clear()
|
||||
memberRadioMutes.clear()
|
||||
@@ -1018,10 +1042,15 @@ class ChatViewModel : ViewModel() {
|
||||
"toggleRadio#$radioToggleCount next=$next prevActive=${_radioActive.value} " +
|
||||
"radioChannelId=$radioChannelId cur=${ch.id} " +
|
||||
"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) {
|
||||
radioChannelId?.takeIf { it != ch.id }?.toULongOrNull()?.toLong()
|
||||
?.let { NativeLib.radioSetActive(it, false) } /* выключаем старый канал */
|
||||
radioChannelId = ch.id
|
||||
_radioTalkState.value = RadioTalkState(groupId)
|
||||
_radioTalking.value = null
|
||||
NativeLib.radioSetActive(groupId, true)
|
||||
@@ -1029,14 +1058,6 @@ class ChatViewModel : ViewModel() {
|
||||
if (!ready && radioChannelId == ch.id && !radioAudio.isActive()) turnRadioOff()
|
||||
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()
|
||||
_radioHeadsetAvailable.value = radioAudio.headsetAvailable()
|
||||
_wiredInputAvailable.value = radioAudio.router.wiredInputAvailable
|
||||
@@ -1044,7 +1065,6 @@ class ChatViewModel : ViewModel() {
|
||||
_radioInputSource.value = radioAudio.inputSource()
|
||||
_radioAuto.value = _radioInputSource.value == RadioAudioEngine.INPUT_AUTO
|
||||
} else {
|
||||
radioChannelId = null
|
||||
NativeLib.radioSetActive(groupId, false)
|
||||
radioAudio.stop()
|
||||
_radioTalking.value = null
|
||||
@@ -1156,7 +1176,12 @@ class ChatViewModel : ViewModel() {
|
||||
private fun turnRadioOff() {
|
||||
val chId = radioChannelId ?: return
|
||||
chId.toULongOrNull()?.toLong()?.let { NativeLib.radioSetActive(it, false) }
|
||||
radioChannelId = null
|
||||
radioAudio.stop()
|
||||
clearRadioControls()
|
||||
syncRadioOverlay()
|
||||
}
|
||||
|
||||
private fun clearRadioControls() {
|
||||
_radioActive.value = false
|
||||
_radioTalking.value = null
|
||||
_radioTalkState.value = RadioTalkState()
|
||||
@@ -1166,8 +1191,6 @@ class ChatViewModel : ViewModel() {
|
||||
_btInputAvailable.value = false
|
||||
_radioInputSource.value = RadioAudioEngine.INPUT_AUTO
|
||||
_radioAuto.value = false
|
||||
radioAudio.stop()
|
||||
syncRadioOverlay()
|
||||
}
|
||||
|
||||
private fun syncRadioOverlay() {
|
||||
|
||||
Reference in new issue
Block a user