call/radio: mic AGC compressor + selectable input device; proximity lock
This commit is contained in:
1 parent
f12f61d7ee
commit
556b0bc7b7
10 files changed
+541
-72
No files matched your search
@@ -52,6 +52,11 @@ class CallAudioEngine {
|
||||
|
||||
@Volatile private var muted = false
|
||||
|
||||
/* Переключение источника захвата: проводная гарнитура → MIC + wired-устройство,
|
||||
* иначе VOICE_COMMUNICATION (дефолт). captureLoop сам меняет запись при рассинхроне. */
|
||||
@Volatile private var wantedWired = false
|
||||
@Volatile private var wiredActive = false
|
||||
|
||||
/* Для замера задержки AEC (getTimestamp) — счётчики кадров play/capture. */
|
||||
@Volatile private var playFramesWritten = 0L
|
||||
@Volatile private var capFramesRead = 0L
|
||||
@@ -87,11 +92,14 @@ class CallAudioEngine {
|
||||
prevSpeaker = am.isSpeakerphoneOn
|
||||
try {
|
||||
am.mode = AudioManager.MODE_IN_COMMUNICATION
|
||||
am.isSpeakerphoneOn = true
|
||||
} catch (e: Exception) {
|
||||
LogManager.addLog("WARN", "CallAudio", "set audio mode failed: ${e.message}")
|
||||
}
|
||||
|
||||
/* Маршрут выхода: автоследование за гарнитурой (воткнули → HEADSET,
|
||||
* вынули → сохранённый маршрут, по умолчанию тихая связь). */
|
||||
router.fallbackRoute = if (router.preferredRoute == AudioRoute.SPEAKER) AudioRoute.SPEAKER else AudioRoute.EARPIECE
|
||||
router.setAutoHeadset(true)
|
||||
router.start()
|
||||
|
||||
/* AEC: включён только на громкой связи (спикерфон); на гарнитуре/разговорном
|
||||
@@ -101,20 +109,10 @@ class CallAudioEngine {
|
||||
syncAecForRoute(router.route)
|
||||
|
||||
// ── AudioRecord (захват) ──
|
||||
val recMin = AudioRecord.getMinBufferSize(
|
||||
SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT)
|
||||
audioRecord = try {
|
||||
AudioRecord(
|
||||
MediaRecorder.AudioSource.VOICE_COMMUNICATION,
|
||||
SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT,
|
||||
(recMin * 2).coerceAtLeast(FRAME_SAMPLES * 4))
|
||||
} catch (e: Exception) {
|
||||
LogManager.addLog("ERROR", "CallAudio", "AudioRecord create failed: ${e.message}")
|
||||
NativeLib.callAudioStop(); router.stop(); restoreAudioMode(); return false
|
||||
}
|
||||
if (audioRecord?.state != AudioRecord.STATE_INITIALIZED) {
|
||||
LogManager.addLog("ERROR", "CallAudio", "AudioRecord not initialized")
|
||||
audioRecord?.release(); audioRecord = null
|
||||
wiredActive = router.route == AudioRoute.HEADSET && router.wiredInputDevice() != null
|
||||
wantedWired = wiredActive
|
||||
audioRecord = createAudioRecord(wiredActive)
|
||||
if (audioRecord == null) {
|
||||
NativeLib.callAudioStop(); router.stop(); restoreAudioMode(); return false
|
||||
}
|
||||
|
||||
@@ -143,6 +141,11 @@ class CallAudioEngine {
|
||||
router.onOutputDevice = { dev -> applyTrackOutputDevice(dev) }
|
||||
applyTrackOutputDevice(router.currentOutputDevice)
|
||||
|
||||
/* Смена маршрута → пересчитать, нужен ли захват с проводной гарнитуры. */
|
||||
router.onInputDevice = { _ ->
|
||||
wantedWired = router.route == AudioRoute.HEADSET && router.wiredInputDevice() != null
|
||||
}
|
||||
|
||||
active = true
|
||||
audioRecord?.startRecording()
|
||||
audioTrack?.play()
|
||||
@@ -157,7 +160,7 @@ class CallAudioEngine {
|
||||
/* Через ~300мс буферы устройства заполнены — замеряем реальную задержку AEC. */
|
||||
mainHandler.postDelayed(aecDelayRunnable, 300)
|
||||
|
||||
LogManager.addLog("INFO", "CallAudio", "started id=$id recBuf=$recMin trackBuf=$trackBuf")
|
||||
LogManager.addLog("INFO", "CallAudio", "started id=$id wired=$wiredActive trackBuf=$trackBuf")
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -176,6 +179,7 @@ class CallAudioEngine {
|
||||
|
||||
NativeLib.callAudioStop()
|
||||
router.onOutputDevice = null
|
||||
router.onInputDevice = null
|
||||
router.stop()
|
||||
restoreAudioMode()
|
||||
|
||||
@@ -187,14 +191,37 @@ class CallAudioEngine {
|
||||
private fun captureLoop() {
|
||||
val buf = ShortArray(FRAME_SAMPLES)
|
||||
val silence = ShortArray(FRAME_SAMPLES)
|
||||
while (active && audioRecord?.recordingState == AudioRecord.RECORDSTATE_RECORDING) {
|
||||
var rec = audioRecord
|
||||
var frame = 0L
|
||||
var sinceSwap = 0
|
||||
while (active) {
|
||||
/* проводная гарнитура появилась/исчезла — меняем источник захвата */
|
||||
if (wantedWired != wiredActive) {
|
||||
audioRecord = null /* защита measureAecDelay от UAF на освобождаемой записи */
|
||||
rec?.apply { try { stop() } catch (_: Exception) {}; release() }
|
||||
wiredActive = wantedWired
|
||||
rec = createAudioRecord(wiredActive)
|
||||
if (rec == null) return
|
||||
audioRecord = rec
|
||||
rec.startRecording()
|
||||
sinceSwap = 0
|
||||
}
|
||||
val r = rec ?: return
|
||||
if (r.recordingState != AudioRecord.RECORDSTATE_RECORDING) return
|
||||
var read = 0
|
||||
while (read < FRAME_SAMPLES) {
|
||||
if (!active) return
|
||||
val n = audioRecord?.read(buf, read, FRAME_SAMPLES - read) ?: return
|
||||
val n = r.read(buf, read, FRAME_SAMPLES - read)
|
||||
if (n <= 0) return
|
||||
read += n
|
||||
}
|
||||
/* диагностика: уровень захваченного сигнала (тишина vs звук) */
|
||||
if (sinceSwap < 10 || frame % 100L == 0L) {
|
||||
LogManager.addLog("WARN", "CallAudio",
|
||||
"cap wired=$wiredActive frame=$frame rmsDb=${rmsDb(buf)}")
|
||||
}
|
||||
sinceSwap++
|
||||
frame++
|
||||
val out = if (muted) silence else buf
|
||||
capFramesRead += FRAME_SAMPLES
|
||||
if (!NativeLib.callAudioFeed(callId, out)) {
|
||||
@@ -204,6 +231,50 @@ class CallAudioEngine {
|
||||
LogManager.addLog("DEBUG", "CallAudio", "capture loop done")
|
||||
}
|
||||
|
||||
/** RMS захваченного кадра в dBFS (0 = полная шкала, ниже = тише; тишина ~ -90). */
|
||||
private fun rmsDb(buf: ShortArray): Int {
|
||||
var sum = 0L
|
||||
for (s in buf) sum += s.toLong() * s.toLong()
|
||||
val rms = Math.sqrt(sum.toDouble() / buf.size)
|
||||
return if (rms < 1.0) -120 else (20.0 * Math.log10(rms / 32768.0)).toInt()
|
||||
}
|
||||
|
||||
/** Создать AudioRecord: всегда VOICE_COMMUNICATION (звонковый тракт «запитывает»
|
||||
* микрофон проводной гарнитуры; AudioSource.MIC на ней даёт тишину). Для проводной
|
||||
* гарнитуры дополнительно роутим setPreferredDevice на её микрофон. */
|
||||
private fun createAudioRecord(wired: Boolean): AudioRecord? {
|
||||
val recMin = AudioRecord.getMinBufferSize(
|
||||
SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT)
|
||||
val rec = try {
|
||||
AudioRecord(
|
||||
MediaRecorder.AudioSource.VOICE_COMMUNICATION,
|
||||
SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT,
|
||||
(recMin * 2).coerceAtLeast(FRAME_SAMPLES * 4))
|
||||
} catch (e: Exception) {
|
||||
LogManager.addLog("ERROR", "CallAudio", "AudioRecord create failed (wired=$wired): ${e.message}")
|
||||
return null
|
||||
}
|
||||
if (rec.state != AudioRecord.STATE_INITIALIZED) {
|
||||
LogManager.addLog("ERROR", "CallAudio", "AudioRecord not initialized (wired=$wired)")
|
||||
rec.release()
|
||||
return null
|
||||
}
|
||||
if (wired) {
|
||||
val dev = router.wiredInputDevice()
|
||||
val ok = try {
|
||||
rec.setPreferredDevice(dev)
|
||||
} catch (e: Exception) {
|
||||
LogManager.addLog("ERROR", "CallAudio", "record setPreferredDevice(wired) threw: ${e.message}")
|
||||
false
|
||||
}
|
||||
LogManager.addLog("WARN", "CallAudio",
|
||||
"record input -> VOICE_COMMUNICATION dev=${dev?.type ?: "-"} setPreferredDevice=$ok")
|
||||
} else {
|
||||
LogManager.addLog("WARN", "CallAudio", "record input -> VOICE_COMMUNICATION (default)")
|
||||
}
|
||||
return rec
|
||||
}
|
||||
|
||||
private fun playLoop() {
|
||||
val buf = ShortArray(FRAME_SAMPLES)
|
||||
val silence = ShortArray(FRAME_SAMPLES)
|
||||
|
||||
@@ -42,6 +42,12 @@ class CallAudioRouter {
|
||||
@Volatile var hasEarpiece = true
|
||||
private set
|
||||
|
||||
/* Доступность входных устройств захвата (для строки аудиоисточников рации). */
|
||||
@Volatile var wiredInputAvailable = false
|
||||
private set
|
||||
@Volatile var btInputAvailable = false
|
||||
private set
|
||||
|
||||
/** Куда откатываться при потере гарнитуры (звонок — EARPIECE, рация — SPEAKER). */
|
||||
@Volatile var fallbackRoute = AudioRoute.EARPIECE
|
||||
|
||||
@@ -53,12 +59,22 @@ class CallAudioRouter {
|
||||
/** Уведомление об изменении маршрута или доступности гарнитуры. */
|
||||
var onChanged: ((AudioRoute, Boolean) -> Unit)? = null
|
||||
|
||||
/** Уведомление об изменении доступности входных устройств (проводная, BT). */
|
||||
var onInputDevicesChanged: ((Boolean, Boolean) -> Unit)? = null
|
||||
|
||||
/** Целевое устройство вывода (для прямого роутирования AudioTrack через setPreferredDevice). */
|
||||
var onOutputDevice: ((AudioDeviceInfo?) -> Unit)? = null
|
||||
|
||||
@Volatile var currentOutputDevice: AudioDeviceInfo? = null
|
||||
private set
|
||||
|
||||
/** Целевое устройство захвата (для прямого роутирования AudioRecord через setPreferredDevice).
|
||||
* non-null только для гарнитуры (проводная/USB/BT-SCO); для динамика/громкой — null (дефолт). */
|
||||
var onInputDevice: ((AudioDeviceInfo?) -> Unit)? = null
|
||||
|
||||
@Volatile var currentInputDevice: AudioDeviceInfo? = null
|
||||
private set
|
||||
|
||||
private val am: AudioManager =
|
||||
ChatApplication.instance.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
|
||||
@@ -131,10 +147,17 @@ class CallAudioRouter {
|
||||
LogManager.addLog("INFO", "CallAudioRouter", "autoHeadset -> $enabled")
|
||||
}
|
||||
|
||||
/** Немедленно переоценить маршрут (например, после включения автоследования). */
|
||||
fun reapply() {
|
||||
if (!started) return
|
||||
refresh()
|
||||
}
|
||||
|
||||
/* ── применение маршрута ── */
|
||||
private fun applyRoute(r: AudioRoute) {
|
||||
try {
|
||||
var dev: AudioDeviceInfo? = null
|
||||
var inDev: AudioDeviceInfo? = null
|
||||
when (r) {
|
||||
AudioRoute.EARPIECE -> {
|
||||
stopBluetoothSco()
|
||||
@@ -168,6 +191,7 @@ class CallAudioRouter {
|
||||
startBluetoothSco()
|
||||
}
|
||||
dev = bt
|
||||
inDev = btInputDevice()
|
||||
} else if (isBtHeadsetProfileConnected()) {
|
||||
/* классический BT: SCO ещё не поднят — запускаем и ждём
|
||||
* AudioDeviceCallback, который пере-применит маршрут */
|
||||
@@ -181,13 +205,16 @@ class CallAudioRouter {
|
||||
am.isSpeakerphoneOn = false
|
||||
}
|
||||
dev = wired
|
||||
inDev = wiredInputDevice()
|
||||
}
|
||||
}
|
||||
}
|
||||
currentOutputDevice = dev
|
||||
onOutputDevice?.invoke(dev)
|
||||
currentInputDevice = inDev
|
||||
onInputDevice?.invoke(inDev)
|
||||
LogManager.addLog("INFO", "CallAudioRouter",
|
||||
"route -> $r dev=${dev?.type}/${dev?.productName ?: "-"} mode=${am.mode} speaker=${am.isSpeakerphoneOn}")
|
||||
"route -> $r dev=${dev?.type}/${dev?.productName ?: "-"} inDev=${inDev?.type ?: "-"} mode=${am.mode} speaker=${am.isSpeakerphoneOn}")
|
||||
} catch (e: Exception) {
|
||||
LogManager.addLog("ERROR", "CallAudioRouter", "applyRoute $r failed: ${e.message}")
|
||||
}
|
||||
@@ -203,6 +230,32 @@ class CallAudioRouter {
|
||||
it.type == AudioDeviceInfo.TYPE_BUILTIN_EARPIECE
|
||||
}
|
||||
|
||||
/* ── входные устройства захвата (для строки аудиоисточников рации) ── */
|
||||
fun builtinMicDevice(): AudioDeviceInfo? =
|
||||
am.getDevices(AudioManager.GET_DEVICES_INPUTS).firstOrNull {
|
||||
it.type == AudioDeviceInfo.TYPE_BUILTIN_MIC
|
||||
}
|
||||
|
||||
fun wiredInputDevice(): AudioDeviceInfo? =
|
||||
am.getDevices(AudioManager.GET_DEVICES_INPUTS).firstOrNull { isWiredType(it.type) }
|
||||
|
||||
fun btInputDevice(): AudioDeviceInfo? =
|
||||
am.getDevices(AudioManager.GET_DEVICES_INPUTS).firstOrNull {
|
||||
it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO
|
||||
}
|
||||
|
||||
private fun refreshInputDevices() {
|
||||
val wired = wiredInputDevice() != null
|
||||
val bt = btInputDevice() != null || isBtHeadsetProfileConnected()
|
||||
val changed = wired != wiredInputAvailable || bt != btInputAvailable
|
||||
wiredInputAvailable = wired
|
||||
btInputAvailable = bt
|
||||
if (changed) {
|
||||
LogManager.addLog("INFO", "CallAudioRouter", "inputDevices wired=$wired bt=$bt")
|
||||
onInputDevicesChanged?.invoke(wired, bt)
|
||||
}
|
||||
}
|
||||
|
||||
/* ── мониторинг гарнитуры ── */
|
||||
private fun onDevicesChanged() {
|
||||
/* если в режиме HEADSET появилось SCO-устройство — пере-применяем маршрут,
|
||||
@@ -243,6 +296,7 @@ class CallAudioRouter {
|
||||
if (now != prev || route == AudioRoute.HEADSET) {
|
||||
onChanged?.invoke(route, now)
|
||||
}
|
||||
refreshInputDevices()
|
||||
}
|
||||
|
||||
private fun isHeadsetConnected(): Boolean =
|
||||
|
||||
@@ -57,6 +57,7 @@ class ConfigProvider(private val context: Context) {
|
||||
private val radioVolumePttKey = booleanPreferencesKey("radio_volume_ptt")
|
||||
private val radioOverlayKey = booleanPreferencesKey("radio_overlay")
|
||||
private val radioMicSourceKey = intPreferencesKey("radio_mic_source")
|
||||
private val radioInputSourceKey = intPreferencesKey("radio_input_source")
|
||||
private val radioOpusModeKey = intPreferencesKey("radio_opus_mode")
|
||||
private val radioOpusBitrateKey = intPreferencesKey("radio_opus_bitrate")
|
||||
private val radioCaptureStereoKey = intPreferencesKey("radio_capture_stereo")
|
||||
@@ -273,7 +274,7 @@ class ConfigProvider(private val context: Context) {
|
||||
|
||||
fun getCallAudioRoute(): AudioRoute = runBlocking {
|
||||
val s = context.dataStore.data.first()[callAudioRouteKey]
|
||||
AudioRoute.entries.firstOrNull { it.name == s } ?: AudioRoute.SPEAKER
|
||||
AudioRoute.entries.firstOrNull { it.name == s } ?: AudioRoute.EARPIECE
|
||||
}
|
||||
|
||||
suspend fun setCallAudioRoute(r: AudioRoute) {
|
||||
@@ -335,6 +336,23 @@ class ConfigProvider(private val context: Context) {
|
||||
context.dataStore.edit { prefs -> prefs[radioMicSourceKey] = source }
|
||||
}
|
||||
|
||||
@Volatile private var radioInputSourceCached = 0
|
||||
@Volatile private var radioInputSourceLoaded = false
|
||||
|
||||
fun getRadioInputSource(): Int {
|
||||
if (!radioInputSourceLoaded) {
|
||||
radioInputSourceCached = runBlocking { context.dataStore.data.first()[radioInputSourceKey] ?: 0 }
|
||||
radioInputSourceLoaded = true
|
||||
}
|
||||
return radioInputSourceCached
|
||||
}
|
||||
|
||||
suspend fun setRadioInputSource(source: Int) {
|
||||
radioInputSourceCached = source
|
||||
radioInputSourceLoaded = true
|
||||
context.dataStore.edit { prefs -> prefs[radioInputSourceKey] = source }
|
||||
}
|
||||
|
||||
@Volatile private var radioCaptureStereoCached = 0
|
||||
@Volatile private var radioCaptureStereoLoaded = false
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.utun.chat.data
|
||||
|
||||
import android.content.Context
|
||||
import android.hardware.Sensor
|
||||
import android.hardware.SensorEvent
|
||||
import android.hardware.SensorEventListener
|
||||
import android.hardware.SensorManager
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* Датчик приближения: отслеживает «поднесли к уху» (near) / «отвели» (far).
|
||||
*
|
||||
* Используется во время звонка на разговорном динамике (EARPIECE), чтобы заблокировать
|
||||
* экран, когда телефон у уха, и разблокировать, когда его отвели. Не требует
|
||||
* runtime-разрешений. На устройствах без датчика — тихий no-op (WARN в лог).
|
||||
*/
|
||||
class ProximityManager(private val context: Context) {
|
||||
|
||||
private val sensorManager: SensorManager =
|
||||
context.getSystemService(Context.SENSOR_SERVICE) as SensorManager
|
||||
private val sensor: Sensor? = sensorManager.getDefaultSensor(Sensor.TYPE_PROXIMITY)
|
||||
|
||||
private val _near = MutableStateFlow(false)
|
||||
val near: StateFlow<Boolean> = _near
|
||||
|
||||
private var listener: SensorEventListener? = null
|
||||
private var started = false
|
||||
|
||||
/** Возвращает true, если датчик присутствует и удалось зарегистрировать слушатель. */
|
||||
val available: Boolean
|
||||
get() = sensor != null
|
||||
|
||||
fun start() {
|
||||
if (started) return
|
||||
started = true
|
||||
val s = sensor
|
||||
if (s == null) {
|
||||
LogManager.addLog("WARN", "Proximity", "no proximity sensor on this device")
|
||||
return
|
||||
}
|
||||
val l = object : SensorEventListener {
|
||||
override fun onSensorChanged(event: SensorEvent) {
|
||||
val nearNow = event.values[0] < event.sensor.maximumRange
|
||||
if (_near.value != nearNow) {
|
||||
_near.value = nearNow
|
||||
LogManager.addLog("INFO", "Proximity",
|
||||
"${if (nearNow) "NEAR" else "FAR"} v=${event.values[0]} max=${event.sensor.maximumRange}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {}
|
||||
}
|
||||
listener = l
|
||||
sensorManager.registerListener(l, s, SensorManager.SENSOR_DELAY_NORMAL)
|
||||
LogManager.addLog("INFO", "Proximity", "started (maxRange=${s.maximumRange})")
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
if (!started) return
|
||||
started = false
|
||||
listener?.let { sensorManager.unregisterListener(it) }
|
||||
listener = null
|
||||
_near.value = false
|
||||
LogManager.addLog("INFO", "Proximity", "stopped")
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,12 @@ class RadioAudioEngine {
|
||||
fun instance(): RadioAudioEngine = inst ?: synchronized(this) {
|
||||
inst ?: RadioAudioEngine().also { inst = it }
|
||||
}
|
||||
|
||||
/* Входное устройство захвата (значение radio_input_source). */
|
||||
const val INPUT_AUTO = 0
|
||||
const val INPUT_BUILTIN = 1
|
||||
const val INPUT_WIRED = 2
|
||||
const val INPUT_BT = 3
|
||||
}
|
||||
|
||||
@Volatile private var audioRecord: AudioRecord? = null
|
||||
@@ -82,6 +88,9 @@ class RadioAudioEngine {
|
||||
/** Текущий маршрут выхода рации (SPEAKER/HEADSET). */
|
||||
fun route(): AudioRoute = router.route
|
||||
|
||||
/** Текущее входное устройство захвата (INPUT_AUTO/BUILTIN/WIRED/BT). */
|
||||
fun inputSource(): Int = ChatApplication.instance.configProvider.getRadioInputSource()
|
||||
|
||||
/** Доступна ли гарнитура (проводная/USB/BT). */
|
||||
fun headsetAvailable(): Boolean = router.headsetAvailable
|
||||
|
||||
@@ -197,6 +206,11 @@ class RadioAudioEngine {
|
||||
router.onOutputDevice = { dev -> applyTrackOutputDevice(dev) }
|
||||
applyTrackOutputDevice(router.currentOutputDevice)
|
||||
|
||||
/* AUTO-вход: смена маршрута выхода меняет и входное устройство — пересоздаём захват. */
|
||||
router.onInputDevice = { _ ->
|
||||
if (ChatApplication.instance.configProvider.getRadioInputSource() == INPUT_AUTO) reconfigure()
|
||||
}
|
||||
|
||||
rec.startRecording()
|
||||
track.play()
|
||||
|
||||
@@ -210,12 +224,28 @@ class RadioAudioEngine {
|
||||
LogManager.addLog("INFO", "RadioAudio", "started group=$group captureCh=$captureChannels trackBuf=$trackBuf")
|
||||
}
|
||||
|
||||
/* Создать AudioRecord: сначала желаемые каналы (стерео), при неудаче — откат на моно.
|
||||
* Источник: выбранный в настройках, UNPROCESSED с fallback на MIC. Возвращает (record, каналы). */
|
||||
/* Создать AudioRecord с маршрутизацией входного устройства: встроенный микрофон
|
||||
* (стерео + предобработка из настроек), проводная/BT-гарнитура (моно, MIC),
|
||||
* авто — системный дефолт. Возвращает (record, каналы). */
|
||||
private fun createAudioRecord(): Pair<AudioRecord?, Int> {
|
||||
val ctx = ChatApplication.instance
|
||||
val inputSource = ctx.configProvider.getRadioInputSource()
|
||||
val pref = ctx.configProvider.getRadioMicSource()
|
||||
val wantStereo = ctx.configProvider.getRadioCaptureStereo()
|
||||
|
||||
/* гарнитурный вход: моно + VOICE_COMMUNICATION (звонковый тракт «запитывает»
|
||||
* микрофон гарнитуры; AudioSource.MIC на ней даёт тишину). AUTO: следуем за
|
||||
* маршрутом выхода (currentInputDevice из applyRoute). */
|
||||
val autoDev = router.currentInputDevice
|
||||
val headsetInput = inputSource == INPUT_WIRED || inputSource == INPUT_BT ||
|
||||
(inputSource == INPUT_AUTO && autoDev != null)
|
||||
val preferredDev = when (inputSource) {
|
||||
INPUT_BUILTIN -> router.builtinMicDevice()
|
||||
INPUT_WIRED -> router.wiredInputDevice()
|
||||
INPUT_BT -> router.btInputDevice()
|
||||
else -> autoDev
|
||||
}
|
||||
|
||||
val primary = when (pref) {
|
||||
1 -> MediaRecorder.AudioSource.MIC
|
||||
2 -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N)
|
||||
@@ -223,7 +253,9 @@ class RadioAudioEngine {
|
||||
3 -> MediaRecorder.AudioSource.CAMCORDER
|
||||
else -> MediaRecorder.AudioSource.VOICE_COMMUNICATION
|
||||
}
|
||||
val sources = if (wantStereo && pref == 0) {
|
||||
val sources = if (headsetInput) {
|
||||
intArrayOf(MediaRecorder.AudioSource.VOICE_COMMUNICATION)
|
||||
} else if (wantStereo && pref == 0) {
|
||||
/* voice-источник (AEC/NS/beamforming) коллапсирует в моно/один луч —
|
||||
* для стерео используем CAMCORDER (тот же тракт, что у видеозаписи). */
|
||||
LogManager.addLog("WARN", "RadioAudio", "voice source не даёт стерео — использую CAMCORDER")
|
||||
@@ -233,7 +265,7 @@ class RadioAudioEngine {
|
||||
} else {
|
||||
intArrayOf(primary)
|
||||
}
|
||||
val masks = if (wantStereo)
|
||||
val masks = if (!headsetInput && wantStereo)
|
||||
intArrayOf(AudioFormat.CHANNEL_IN_STEREO, AudioFormat.CHANNEL_IN_MONO)
|
||||
else intArrayOf(AudioFormat.CHANNEL_IN_MONO)
|
||||
|
||||
@@ -246,7 +278,9 @@ class RadioAudioEngine {
|
||||
if (rec != null) {
|
||||
if (wantStereo && channels == 1)
|
||||
LogManager.addLog("WARN", "RadioAudio", "stereo capture unavailable — fallback to mono")
|
||||
LogManager.addLog("INFO", "RadioAudio", "AudioRecord source=$src ch=$channels")
|
||||
applyRecordPreferredDevice(rec, preferredDev)
|
||||
LogManager.addLog("INFO", "RadioAudio",
|
||||
"AudioRecord source=$src ch=$channels input=$inputSource dev=${preferredDev?.type ?: "default"}")
|
||||
return rec to channels
|
||||
}
|
||||
}
|
||||
@@ -254,6 +288,19 @@ class RadioAudioEngine {
|
||||
return null to 1
|
||||
}
|
||||
|
||||
/* Прямое роутирование захвата на выбранное входное устройство (обход глючной
|
||||
* глобальной маршрутизации); null — системный дефолт. */
|
||||
private fun applyRecordPreferredDevice(rec: AudioRecord, dev: AudioDeviceInfo?) {
|
||||
if (dev == null) return
|
||||
val ok = try {
|
||||
rec.setPreferredDevice(dev)
|
||||
} catch (e: Exception) {
|
||||
LogManager.addLog("ERROR", "RadioAudio", "setPreferredDevice(record) threw: ${e.message}")
|
||||
false
|
||||
}
|
||||
LogManager.addLog("WARN", "RadioAudio", "record input -> dev=${dev.type} setPreferredDevice=$ok")
|
||||
}
|
||||
|
||||
private fun tryCreateRecord(source: Int, bufferSize: Int, channelMask: Int): AudioRecord? {
|
||||
val rec = try {
|
||||
AudioRecord(source, SAMPLE_RATE, channelMask, AudioFormat.ENCODING_PCM_16BIT, bufferSize)
|
||||
@@ -285,6 +332,7 @@ class RadioAudioEngine {
|
||||
|
||||
NativeLib.radioAudioStop()
|
||||
router.onOutputDevice = null
|
||||
router.onInputDevice = null
|
||||
router.stop()
|
||||
restoreAudioMode()
|
||||
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
package com.utun.chat.ui.screens
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.background
|
||||
@@ -13,26 +17,39 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.VolumeUp
|
||||
import androidx.compose.material.icons.filled.CallEnd
|
||||
import androidx.compose.material.icons.filled.Headset
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
import androidx.compose.material.icons.filled.MicOff
|
||||
import androidx.compose.material.icons.filled.PhoneInTalk
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import com.utun.chat.data.AudioRoute
|
||||
import com.utun.chat.data.LogManager
|
||||
import com.utun.chat.viewmodel.ChatViewModel
|
||||
import com.utun.chat.viewmodel.ChatViewModel.CallPhase
|
||||
|
||||
private tailrec fun Context.findActivity(): Activity? = when (this) {
|
||||
is Activity -> this
|
||||
is ContextWrapper -> baseContext.findActivity()
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun fmtDuration(sec: Int): String {
|
||||
val m = sec / 60
|
||||
val s = sec % 60
|
||||
@@ -82,12 +99,15 @@ fun CallScreen(
|
||||
val route by viewModel.audioRoute.collectAsState()
|
||||
val headsetAvailable by viewModel.headsetAvailable.collectAsState()
|
||||
val earpieceAvailable by viewModel.earpieceAvailable.collectAsState()
|
||||
val proximityNear by viewModel.proximityNear.collectAsState()
|
||||
|
||||
val phase = st?.phase ?: CallPhase.NONE
|
||||
val duration = st?.durationMs ?: 0
|
||||
val peerNodeId = st?.peerNodeId ?: 0L
|
||||
val name = st?.peerName?.ifEmpty { "0x%016x".format(peerNodeId) } ?: ""
|
||||
|
||||
val locked = phase == CallPhase.ACTIVE && route == AudioRoute.EARPIECE && proximityNear
|
||||
|
||||
val context = LocalContext.current
|
||||
val hasBtPermission = Build.VERSION.SDK_INT < Build.VERSION_CODES.S ||
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) ==
|
||||
@@ -98,6 +118,26 @@ fun CallScreen(
|
||||
if (granted) viewModel.setAudioRoute(AudioRoute.HEADSET)
|
||||
}
|
||||
|
||||
/* Пока телефон у уха (proximity) — глотаем Back, чтобы не свернуть звонок. */
|
||||
BackHandler(enabled = locked) {}
|
||||
|
||||
/* При блокировке скрываем системные панели (иначе статус-бар светится поверх оверлея). */
|
||||
DisposableEffect(locked) {
|
||||
val act = context.findActivity()
|
||||
val controller = act?.let { WindowCompat.getInsetsController(it.window, it.window.decorView) }
|
||||
if (locked) {
|
||||
controller?.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
controller?.hide(WindowInsetsCompat.Type.systemBars())
|
||||
LogManager.addLog("INFO", "CallScreen", "system bars hidden (proximity lock)")
|
||||
}
|
||||
onDispose {
|
||||
if (locked) {
|
||||
controller?.show(WindowInsetsCompat.Type.systemBars())
|
||||
LogManager.addLog("INFO", "CallScreen", "system bars restored (proximity unlock)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().background(Color(0xFF0F1B22)),
|
||||
contentAlignment = Alignment.Center
|
||||
@@ -210,5 +250,27 @@ fun CallScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (locked) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color(0xFF0F1B22))
|
||||
.pointerInput(Unit) {
|
||||
awaitPointerEventScope {
|
||||
while (true) awaitPointerEvent()
|
||||
}
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Icon(Icons.Filled.Lock, "Экран заблокирован", tint = Color.White, modifier = Modifier.size(48.dp))
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text("Экран заблокирован", color = Color.White, fontSize = 20.sp, fontWeight = FontWeight.Medium)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text("Отведите телефон от уха, чтобы разблокировать", color = Color(0xFF9BA6AD), fontSize = 14.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,15 +7,16 @@ import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.VolumeUp
|
||||
import androidx.compose.material.icons.filled.Headset
|
||||
import androidx.compose.material.icons.filled.Link
|
||||
import androidx.compose.material.icons.filled.People
|
||||
import androidx.compose.material3.*
|
||||
@@ -24,6 +25,7 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
@@ -72,8 +74,6 @@ fun ChatScreen(
|
||||
val radioTalking by viewModel.radioTalking.collectAsState()
|
||||
val overlayShown by RadioOverlayService.isShown.collectAsState()
|
||||
val localTalking by RadioAudioEngine.instance().talkingFlow.collectAsState()
|
||||
val radioRoute by viewModel.radioRoute.collectAsState()
|
||||
val radioHeadsetAvailable by viewModel.radioHeadsetAvailable.collectAsState()
|
||||
|
||||
/* звук сообщений канала */
|
||||
val soundOn by viewModel.soundOn.collectAsState()
|
||||
@@ -158,6 +158,9 @@ fun ChatScreen(
|
||||
IconButton(onClick = onMembersClick) { Icon(Icons.Filled.People, "Members") }
|
||||
}
|
||||
)
|
||||
if (radioActive) {
|
||||
RadioSourceBar(viewModel)
|
||||
}
|
||||
if (radioTalking != null) {
|
||||
Surface(color = MaterialTheme.colorScheme.primaryContainer) {
|
||||
Text(
|
||||
@@ -190,31 +193,12 @@ fun ChatScreen(
|
||||
}
|
||||
}
|
||||
if (radioActive && !overlayShown) {
|
||||
Column(
|
||||
RadioPttButton(
|
||||
modifier = Modifier.align(Alignment.TopCenter).padding(top = 12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
RadioRouteButton(
|
||||
active = radioRoute == AudioRoute.SPEAKER,
|
||||
icon = { Icon(Icons.AutoMirrored.Filled.VolumeUp, "Динамик", tint = Color.White, modifier = Modifier.size(22.dp)) },
|
||||
onClick = { viewModel.setRadioRoute(AudioRoute.SPEAKER) }
|
||||
)
|
||||
if (radioHeadsetAvailable) {
|
||||
RadioRouteButton(
|
||||
active = radioRoute == AudioRoute.HEADSET,
|
||||
icon = { Icon(Icons.Filled.Headset, "Гарнитура", tint = Color.White, modifier = Modifier.size(22.dp)) },
|
||||
onClick = { viewModel.setRadioRoute(AudioRoute.HEADSET) }
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
RadioPttButton(
|
||||
isTalking = localTalking,
|
||||
onTalkBegin = { viewModel.radioTalkBegin() },
|
||||
onTalkEnd = { viewModel.radioTalkEnd() }
|
||||
)
|
||||
}
|
||||
isTalking = localTalking,
|
||||
onTalkBegin = { viewModel.radioTalkBegin() },
|
||||
onTalkEnd = { viewModel.radioTalkEnd() }
|
||||
)
|
||||
}
|
||||
}
|
||||
InputBar(
|
||||
@@ -322,23 +306,69 @@ private fun RadioPttButton(
|
||||
}
|
||||
}
|
||||
|
||||
/* Круглая кнопка выбора выхода рации (динамик/гарнитура). */
|
||||
/* Строка аудиоисточников рации: вход (авто/встроенный/проводная/беспроводная) + выход. */
|
||||
@Composable
|
||||
private fun RadioRouteButton(
|
||||
active: Boolean,
|
||||
icon: @Composable () -> Unit,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.size(44.dp)
|
||||
.clickable(onClick = onClick),
|
||||
shape = CircleShape,
|
||||
color = if (active) Color(0xFF1F6FEB) else Color(0xFF24323D),
|
||||
shadowElevation = 3.dp
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
icon()
|
||||
private fun RadioSourceBar(viewModel: ChatViewModel) {
|
||||
val radioAuto by viewModel.radioAuto.collectAsState()
|
||||
val radioInputSource by viewModel.radioInputSource.collectAsState()
|
||||
val wiredInputAvailable by viewModel.wiredInputAvailable.collectAsState()
|
||||
val btInputAvailable by viewModel.btInputAvailable.collectAsState()
|
||||
val radioRoute by viewModel.radioRoute.collectAsState()
|
||||
val radioHeadsetAvailable by viewModel.radioHeadsetAvailable.collectAsState()
|
||||
|
||||
Surface(color = Color(0xFF0F1B22)) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
RadioSourceGroupLabel("Вход")
|
||||
RadioSourceChip("Авто", radioAuto, { viewModel.setRadioAuto() })
|
||||
RadioSourceChip("Встроенный", radioInputSource == RadioAudioEngine.INPUT_BUILTIN,
|
||||
{ viewModel.setRadioInputSource(RadioAudioEngine.INPUT_BUILTIN) })
|
||||
if (wiredInputAvailable) {
|
||||
RadioSourceChip("Проводная", radioInputSource == RadioAudioEngine.INPUT_WIRED,
|
||||
{ viewModel.setRadioInputSource(RadioAudioEngine.INPUT_WIRED) })
|
||||
}
|
||||
if (btInputAvailable) {
|
||||
RadioSourceChip("Беспроводная", radioInputSource == RadioAudioEngine.INPUT_BT,
|
||||
{ viewModel.setRadioInputSource(RadioAudioEngine.INPUT_BT) })
|
||||
}
|
||||
RadioSourceGroupLabel("Выход")
|
||||
RadioSourceChip("Динамик", radioRoute == AudioRoute.SPEAKER,
|
||||
{ viewModel.setRadioRoute(AudioRoute.SPEAKER) })
|
||||
if (radioHeadsetAvailable) {
|
||||
RadioSourceChip("Гарнитура", radioRoute == AudioRoute.HEADSET,
|
||||
{ viewModel.setRadioRoute(AudioRoute.HEADSET) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RadioSourceChip(
|
||||
label: String,
|
||||
active: Boolean,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.clickable(onClick = onClick),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = if (active) Color(0xFF1F6FEB) else Color(0xFF24323D)
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
color = if (active) Color.White else Color(0xFF9BA6AD),
|
||||
fontSize = 12.sp,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RadioSourceGroupLabel(text: String) {
|
||||
Text(text, color = Color(0xFF5A6A75), fontSize = 11.sp, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
@@ -56,6 +56,8 @@ class ChatViewModel : ViewModel() {
|
||||
|
||||
val callAudio = CallAudioEngine()
|
||||
|
||||
val proximity = ProximityManager(ChatApplication.instance)
|
||||
|
||||
val radioAudio = RadioAudioEngine.instance()
|
||||
|
||||
/* рация: активна ли для текущего канала, кто говорит (src node_id, null = никто) */
|
||||
@@ -71,6 +73,18 @@ class ChatViewModel : ViewModel() {
|
||||
private val _radioHeadsetAvailable = MutableStateFlow(false)
|
||||
val radioHeadsetAvailable: StateFlow<Boolean> = _radioHeadsetAvailable
|
||||
|
||||
private val _radioInputSource = MutableStateFlow(RadioAudioEngine.INPUT_AUTO)
|
||||
val radioInputSource: StateFlow<Int> = _radioInputSource
|
||||
|
||||
private val _wiredInputAvailable = MutableStateFlow(false)
|
||||
val wiredInputAvailable: StateFlow<Boolean> = _wiredInputAvailable
|
||||
|
||||
private val _btInputAvailable = MutableStateFlow(false)
|
||||
val btInputAvailable: StateFlow<Boolean> = _btInputAvailable
|
||||
|
||||
private val _radioAuto = MutableStateFlow(false)
|
||||
val radioAuto: StateFlow<Boolean> = _radioAuto
|
||||
|
||||
private var radioToggleCount = 0
|
||||
|
||||
/* рация: активный канал (null = выключена). Одна рация — один канал. */
|
||||
@@ -91,6 +105,9 @@ class ChatViewModel : ViewModel() {
|
||||
private val _earpieceAvailable = MutableStateFlow(true)
|
||||
val earpieceAvailable: StateFlow<Boolean> = _earpieceAvailable
|
||||
|
||||
private val _proximityNear = MutableStateFlow(false)
|
||||
val proximityNear: StateFlow<Boolean> = _proximityNear
|
||||
|
||||
private val _playbackState = MutableStateFlow(PlaybackState())
|
||||
val playbackState: StateFlow<PlaybackState> = _playbackState
|
||||
|
||||
@@ -291,6 +308,20 @@ class ChatViewModel : ViewModel() {
|
||||
delay(1000)
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
combine(_callState, _audioRoute) { st, route -> st to route }.collect { (st, route) ->
|
||||
val monitor = st != null && st.phase == CallPhase.ACTIVE && route == AudioRoute.EARPIECE
|
||||
if (monitor) {
|
||||
proximity.start()
|
||||
} else {
|
||||
proximity.stop()
|
||||
_proximityNear.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
proximity.near.collect { _proximityNear.value = it }
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleEvent(type: Int, data: ByteArray?) {
|
||||
@@ -775,8 +806,16 @@ class ChatViewModel : ViewModel() {
|
||||
_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
|
||||
_btInputAvailable.value = radioAudio.router.btInputAvailable
|
||||
_radioInputSource.value = radioAudio.inputSource()
|
||||
_radioAuto.value = _radioInputSource.value == RadioAudioEngine.INPUT_AUTO
|
||||
} else {
|
||||
radioChannelId = null
|
||||
NativeLib.radioSetActive(groupId, false)
|
||||
@@ -784,6 +823,10 @@ class ChatViewModel : ViewModel() {
|
||||
_radioTalking.value = null
|
||||
_radioRoute.value = AudioRoute.SPEAKER
|
||||
_radioHeadsetAvailable.value = false
|
||||
_wiredInputAvailable.value = false
|
||||
_btInputAvailable.value = false
|
||||
_radioInputSource.value = RadioAudioEngine.INPUT_AUTO
|
||||
_radioAuto.value = false
|
||||
}
|
||||
_radioActive.value = next
|
||||
syncRadioOverlay()
|
||||
@@ -803,11 +846,43 @@ class ChatViewModel : ViewModel() {
|
||||
/** Ручной выбор выхода рации (динамик/гарнитура). */
|
||||
fun setRadioRoute(r: AudioRoute) {
|
||||
if (!_radioActive.value) return
|
||||
_radioAuto.value = false
|
||||
radioAudio.setRoute(r)
|
||||
_radioRoute.value = radioAudio.route()
|
||||
LogManager.addLog("INFO", "VM", "setRadioRoute -> ${radioAudio.route()}")
|
||||
}
|
||||
|
||||
/** Ручной выбор входного устройства рации (выключает авто). */
|
||||
fun setRadioInputSource(src: Int) {
|
||||
if (!_radioActive.value) return
|
||||
val clamped = src.coerceIn(RadioAudioEngine.INPUT_AUTO, RadioAudioEngine.INPUT_BT)
|
||||
if (clamped == _radioInputSource.value) return
|
||||
_radioAuto.value = false
|
||||
_radioInputSource.value = clamped
|
||||
viewModelScope.launch {
|
||||
ChatApplication.instance.configProvider.setRadioInputSource(clamped)
|
||||
radioAudio.reconfigure()
|
||||
}
|
||||
LogManager.addLog("INFO", "VM", "setRadioInputSource -> $clamped")
|
||||
}
|
||||
|
||||
/** Авто: выход следует за гарнитурой, вход — автовыбор. */
|
||||
fun setRadioAuto() {
|
||||
if (!_radioActive.value) return
|
||||
_radioAuto.value = true
|
||||
radioAudio.router.setAutoHeadset(true)
|
||||
radioAudio.router.reapply()
|
||||
_radioRoute.value = radioAudio.route()
|
||||
if (_radioInputSource.value != RadioAudioEngine.INPUT_AUTO) {
|
||||
_radioInputSource.value = RadioAudioEngine.INPUT_AUTO
|
||||
viewModelScope.launch {
|
||||
ChatApplication.instance.configProvider.setRadioInputSource(RadioAudioEngine.INPUT_AUTO)
|
||||
radioAudio.reconfigure()
|
||||
}
|
||||
}
|
||||
LogManager.addLog("INFO", "VM", "setRadioAuto -> route=${radioAudio.route()}")
|
||||
}
|
||||
|
||||
/* Полностью выключить рацию (используется при удалении активного канала). */
|
||||
private fun turnRadioOff() {
|
||||
val chId = radioChannelId ?: return
|
||||
@@ -817,6 +892,10 @@ class ChatViewModel : ViewModel() {
|
||||
_radioTalking.value = null
|
||||
_radioRoute.value = AudioRoute.SPEAKER
|
||||
_radioHeadsetAvailable.value = false
|
||||
_wiredInputAvailable.value = false
|
||||
_btInputAvailable.value = false
|
||||
_radioInputSource.value = RadioAudioEngine.INPUT_AUTO
|
||||
_radioAuto.value = false
|
||||
radioAudio.stop()
|
||||
syncRadioOverlay()
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user