Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f23d8e35bb | ||
|
|
587d46578e | ||
|
|
7342dddd0b | ||
|
|
e32d6967aa | ||
|
|
99a867df6f | ||
|
|
29734e6203 | ||
|
|
af9bb48f7e | ||
|
|
c8daa276fc | ||
|
|
1bfac034cd | ||
|
|
7b8e897e8a |
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "ru.ua1zbe.svxremote"
|
||||
minSdk = 26
|
||||
targetSdk = 34
|
||||
versionCode = 12
|
||||
versionName = "1.0.10"
|
||||
versionCode = 14
|
||||
versionName = "1.0.12"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.app.Activity
|
||||
import android.os.Bundle
|
||||
import android.widget.ArrayAdapter
|
||||
import android.widget.Button
|
||||
import android.widget.CheckBox
|
||||
import android.widget.SeekBar
|
||||
import android.widget.Spinner
|
||||
import android.widget.TextView
|
||||
@@ -78,11 +79,15 @@ class AudioSettingsActivity : Activity() {
|
||||
seekRxBuffer.setOnSeekBarChangeListener(listener)
|
||||
seekJitter.setOnSeekBarChangeListener(listener)
|
||||
|
||||
val chkRxStats = findViewById<CheckBox>(R.id.chk_rx_stats)
|
||||
chkRxStats.isChecked = Prefs.rxStatsEnabled(this)
|
||||
|
||||
findViewById<Button>(R.id.btn_audio_save).setOnClickListener {
|
||||
Prefs.setMicGain(this, 1.0f + seekMicGain.progress * 0.1f)
|
||||
Prefs.setRxBufferMs(this, 100 + seekRxBuffer.progress * 20)
|
||||
Prefs.setRxJitterMs(this, seekJitter.progress * 20)
|
||||
Prefs.setTxBitrateKbps(this, bitrates[spinBitrate.selectedItemPosition])
|
||||
Prefs.setRxStatsEnabled(this, chkRxStats.isChecked)
|
||||
Toast.makeText(this, getString(R.string.saved), Toast.LENGTH_SHORT).show()
|
||||
finish()
|
||||
}
|
||||
|
||||
@@ -8,7 +8,11 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.content.pm.PackageManager
|
||||
import android.media.MediaPlayer
|
||||
import android.net.Uri
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ArrayAdapter
|
||||
import android.widget.ListView
|
||||
import android.content.res.ColorStateList
|
||||
import android.graphics.Color
|
||||
import android.graphics.PixelFormat
|
||||
@@ -53,18 +57,30 @@ class MainActivity : Activity(), SvxService.Listener {
|
||||
private lateinit var lblSubtitle: TextView
|
||||
private lateinit var lblCallsign: TextView
|
||||
private lateinit var lblTg: TextView
|
||||
private lateinit var lblTalkerTitle: TextView
|
||||
private lateinit var lblTalker: TextView
|
||||
private lateinit var lblDuplex: TextView
|
||||
private lateinit var lblPttHint: TextView
|
||||
private var lastTalkerCs = ""
|
||||
private var lastTalkerTg = -1L
|
||||
private lateinit var lblTxState: TextView
|
||||
private lateinit var lblConnectHint: TextView
|
||||
private lateinit var connectZone: View
|
||||
private lateinit var rxMeter: SMeterView
|
||||
private lateinit var micMeter: SMeterView
|
||||
private lateinit var lblRxStats: TextView
|
||||
private lateinit var pttButton: PttButton
|
||||
private lateinit var btnSettings: ImageButton
|
||||
private lateinit var btnMonitor: ImageButton
|
||||
private lateinit var btnRecord: ImageButton
|
||||
// Список записей: воспроизведение/удаление
|
||||
private var recPlayer: MediaPlayer? = null
|
||||
private var recPlayingPath: String? = null
|
||||
private var recLongPressFired = false
|
||||
private var recListAdapter: ArrayAdapter<File>? = null
|
||||
private val recLongPressTask = Runnable {
|
||||
recLongPressFired = true
|
||||
Log.d(TAG, "recBtn long-press FIRED")
|
||||
openRecordingsList()
|
||||
}
|
||||
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private var titleHoldRunnable: Runnable? = null
|
||||
@@ -90,6 +106,7 @@ class MainActivity : Activity(), SvxService.Listener {
|
||||
override fun onServiceConnected(name: ComponentName, binder: IBinder) {
|
||||
svc = (binder as SvxService.LocalBinder).service()
|
||||
svc?.listener = this@MainActivity
|
||||
updateRecordUi(svc?.isRecording() ?: false)
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(name: ComponentName) {
|
||||
@@ -130,10 +147,8 @@ class MainActivity : Activity(), SvxService.Listener {
|
||||
statusChip = findViewById(R.id.status_chip)
|
||||
lblAppTitle = findViewById(R.id.lbl_app_title)
|
||||
lblSubtitle = findViewById(R.id.lbl_subtitle)
|
||||
lblTalkerTitle = findViewById(R.id.lbl_talker_title)
|
||||
lblTalker = findViewById(R.id.lbl_talker)
|
||||
lblDuplex = findViewById(R.id.lbl_duplex)
|
||||
lblPttHint = findViewById(R.id.lbl_ptt_hint)
|
||||
lblTxState = findViewById(R.id.lbl_tx_state)
|
||||
lblConnectHint = findViewById(R.id.lbl_connect_hint)
|
||||
connectZone = findViewById(R.id.connect_zone)
|
||||
@@ -142,12 +157,33 @@ class MainActivity : Activity(), SvxService.Listener {
|
||||
rxMeter = findViewById(R.id.rx_meter)
|
||||
micMeter = findViewById(R.id.mic_meter)
|
||||
micMeter.setColor(0xFFF85149.toInt())
|
||||
lblRxStats = findViewById(R.id.lbl_rx_stats)
|
||||
lblRxStats.visibility = if (Prefs.rxStatsEnabled(this)) View.VISIBLE else View.GONE
|
||||
pttButton = findViewById(R.id.ptt_button)
|
||||
btnSettings = findViewById(R.id.btn_settings)
|
||||
btnMonitor = findViewById(R.id.btn_monitor)
|
||||
btnRecord = findViewById(R.id.btn_record)
|
||||
pttButton.enabled2 = false
|
||||
|
||||
btnMonitor.setOnClickListener { toggleMonitoring() }
|
||||
btnRecord.setOnClickListener { toggleRecording() }
|
||||
btnRecord.setOnTouchListener { _, event ->
|
||||
when (event.action) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
recLongPressFired = false
|
||||
Log.d(TAG, "recBtn DOWN, scheduling long-press")
|
||||
mainHandler.postDelayed(recLongPressTask, 3000)
|
||||
false
|
||||
}
|
||||
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
|
||||
Log.d(TAG, "recBtn UP/CANCEL, longFired=$recLongPressFired")
|
||||
mainHandler.removeCallbacks(recLongPressTask)
|
||||
// Если сработал долгий тап — гасим клик (иначе переключит запись)
|
||||
recLongPressFired
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
updateMonitorButton()
|
||||
val testText = intent?.getStringExtra("node_toast_test")
|
||||
if (testText != null) {
|
||||
@@ -236,7 +272,6 @@ class MainActivity : Activity(), SvxService.Listener {
|
||||
toast(getString(R.string.perm_mic))
|
||||
return
|
||||
}
|
||||
lblPttHint.text = getString(R.string.ptt_talking)
|
||||
lblTxState.text = getString(R.string.status_big_talking)
|
||||
showDuplex(getString(R.string.duplex_tx), R.color.danger, null)
|
||||
svc?.startTx()
|
||||
@@ -245,7 +280,6 @@ class MainActivity : Activity(), SvxService.Listener {
|
||||
override fun onPttUp() {
|
||||
Log.d("SVXRemote", "PTT up")
|
||||
svc?.stopTx()
|
||||
lblPttHint.text = getString(R.string.hold_to_talk)
|
||||
lblTxState.text =
|
||||
if (connected) getString(R.string.status_big_connected)
|
||||
else getString(R.string.status_big_disconnected)
|
||||
@@ -301,6 +335,8 @@ class MainActivity : Activity(), SvxService.Listener {
|
||||
hideNodeOverlay()
|
||||
aprsMeteo?.stop()
|
||||
aprsMeteo = null
|
||||
mainHandler.removeCallbacks(recLongPressTask)
|
||||
stopRecordingPlayback()
|
||||
mainHandler.removeCallbacksAndMessages(null)
|
||||
SplashActivity.mainAlive = false
|
||||
svc?.listener = null
|
||||
@@ -336,7 +372,6 @@ class MainActivity : Activity(), SvxService.Listener {
|
||||
lblTxState.text = getString(R.string.status_big_disconnected)
|
||||
lblTxState.setTextColor(getColor(R.color.danger))
|
||||
lblConnectHint.text = getString(R.string.hint_connect)
|
||||
lblPttHint.text = getString(R.string.hold_to_talk)
|
||||
}
|
||||
|
||||
ReflectorClient.STATE_CONNECTING,
|
||||
@@ -414,9 +449,9 @@ class MainActivity : Activity(), SvxService.Listener {
|
||||
}
|
||||
resetMonitorIdle()
|
||||
}
|
||||
lblTalkerTitle.text = getString(R.string.talker_now, tg)
|
||||
lblTalkerTitle.setTextColor(getColor(R.color.text_secondary))
|
||||
lblTalker.text = callsign
|
||||
lastTalkerCs = callsign
|
||||
lastTalkerTg = tg
|
||||
updateTalkerLine(active = true)
|
||||
if (!pttButton.isPressed2) {
|
||||
showDuplex(getString(R.string.duplex_rx), R.color.accent, DUPLEX_RX_HIDE_MS)
|
||||
}
|
||||
@@ -424,7 +459,20 @@ class MainActivity : Activity(), SvxService.Listener {
|
||||
}
|
||||
|
||||
override fun onTalkerStop(tg: Long, callsign: String) {
|
||||
// последний говоривший остаётся на экране
|
||||
// последний говоривший остаётся на экране, но без префикса «Говорит»
|
||||
mainHandler.post { updateTalkerLine(active = false) }
|
||||
}
|
||||
|
||||
/** Единая строка говорящего: «Говорит …» при приёме, иначе позывной+TG. */
|
||||
private fun updateTalkerLine(active: Boolean) {
|
||||
if (lastTalkerCs.isEmpty()) {
|
||||
lblTalker.text = ""
|
||||
return
|
||||
}
|
||||
lblTalker.text = if (active)
|
||||
getString(R.string.talker_active_fmt, lastTalkerCs, lastTalkerTg)
|
||||
else
|
||||
getString(R.string.talker_idle_fmt, lastTalkerCs, lastTalkerTg)
|
||||
}
|
||||
|
||||
override fun onNodeJoined(callsign: String, tg: Long?) {
|
||||
@@ -498,6 +546,14 @@ class MainActivity : Activity(), SvxService.Listener {
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRxStats(lossPct: Double, jitterMs: Double) {
|
||||
mainHandler.post {
|
||||
if (!Prefs.rxStatsEnabled(this)) return@post
|
||||
lblRxStats.visibility = View.VISIBLE
|
||||
lblRxStats.text = getString(R.string.rx_stats_fmt, lossPct, jitterMs.toInt())
|
||||
}
|
||||
}
|
||||
|
||||
/** Сбросить таймер молчания: группа жива — продолжаем её слушать. */
|
||||
private fun resetMonitorIdle() {
|
||||
monitorIdleTask?.let { mainHandler.removeCallbacks(it) }
|
||||
@@ -658,6 +714,127 @@ class MainActivity : Activity(), SvxService.Listener {
|
||||
getString(if (active) R.string.btn_monitor_on else R.string.btn_monitor)
|
||||
}
|
||||
|
||||
private fun toggleRecording() {
|
||||
val s = svc ?: return
|
||||
if (s.isRecording()) {
|
||||
val path = s.stopRecording()
|
||||
updateRecordUi(false)
|
||||
toast(getString(R.string.record_saved, path ?: ""))
|
||||
} else {
|
||||
val path = s.startRecording()
|
||||
if (path != null) {
|
||||
updateRecordUi(true)
|
||||
toast(getString(R.string.record_started))
|
||||
} else {
|
||||
toast(getString(R.string.record_failed))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateRecordUi(active: Boolean) {
|
||||
btnRecord.imageTintList = ColorStateList.valueOf(
|
||||
if (active) Color.parseColor("#F85149") else Color.parseColor("#8B949E")
|
||||
)
|
||||
btnRecord.contentDescription = getString(R.string.btn_record)
|
||||
}
|
||||
|
||||
// ---------- Список записей: воспроизведение / удаление ----------
|
||||
|
||||
private fun recordingsDir(): File =
|
||||
File(getExternalFilesDir(null) ?: filesDir, "recordings")
|
||||
|
||||
private fun openRecordingsList() {
|
||||
Log.d(TAG, "openRecordingsList")
|
||||
val dir = recordingsDir()
|
||||
Log.d(TAG, "recordings dir exists=${dir.exists()} path=${dir.absolutePath}")
|
||||
val files = (dir.listFiles { f -> f.extension.equals("ogg", true) }
|
||||
?: emptyArray()).toMutableList()
|
||||
.apply { sortByDescending { it.name } }
|
||||
Log.d(TAG, "recordings count=${files.size}")
|
||||
if (files.isEmpty()) {
|
||||
toast("Нет записей")
|
||||
return
|
||||
}
|
||||
val dialog = AlertDialog.Builder(this)
|
||||
.setTitle(R.string.recordings_title)
|
||||
.create()
|
||||
val listView = ListView(this).apply {
|
||||
val adapter = object : ArrayAdapter<File>(this@MainActivity, 0, files) {
|
||||
override fun getView(pos: Int, convertView: View?, parent: ViewGroup): View {
|
||||
val file = getItem(pos)!!
|
||||
val row = convertView
|
||||
?: layoutInflater.inflate(R.layout.dialog_recording_item, parent, false)
|
||||
row.findViewById<TextView>(R.id.rec_name).text = file.name
|
||||
val playBtn = row.findViewById<ImageButton>(R.id.rec_play)
|
||||
val delBtn = row.findViewById<ImageButton>(R.id.rec_delete)
|
||||
val playing = recPlayingPath == file.absolutePath && recPlayer?.isPlaying == true
|
||||
playBtn.setImageResource(if (playing) R.drawable.ic_stop else R.drawable.ic_play)
|
||||
playBtn.setOnClickListener { togglePlayRecording(file.absolutePath) }
|
||||
delBtn.setOnClickListener { confirmDeleteRecording(file, this, dialog) }
|
||||
return row
|
||||
}
|
||||
}
|
||||
recListAdapter = adapter
|
||||
this.adapter = adapter
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
(resources.displayMetrics.heightPixels * 0.6).toInt()
|
||||
)
|
||||
}
|
||||
dialog.setView(listView)
|
||||
dialog.setOnDismissListener { stopRecordingPlayback() }
|
||||
dialog.show()
|
||||
}
|
||||
|
||||
private fun togglePlayRecording(path: String) {
|
||||
if (recPlayingPath == path && recPlayer?.isPlaying == true) {
|
||||
stopRecordingPlayback()
|
||||
refreshRecordingList()
|
||||
return
|
||||
}
|
||||
stopRecordingPlayback()
|
||||
try {
|
||||
val mp = MediaPlayer().apply {
|
||||
setDataSource(path)
|
||||
setOnCompletionListener { stopRecordingPlayback(); refreshRecordingList() }
|
||||
prepare()
|
||||
start()
|
||||
}
|
||||
recPlayer = mp
|
||||
recPlayingPath = path
|
||||
refreshRecordingList()
|
||||
} catch (e: Exception) {
|
||||
toast(getString(R.string.record_play_failed))
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopRecordingPlayback() {
|
||||
try { recPlayer?.stop() } catch (_: Exception) {}
|
||||
try { recPlayer?.release() } catch (_: Exception) {}
|
||||
recPlayer = null
|
||||
recPlayingPath = null
|
||||
}
|
||||
|
||||
private fun refreshRecordingList() {
|
||||
recListAdapter?.notifyDataSetChanged()
|
||||
}
|
||||
|
||||
private fun confirmDeleteRecording(file: File, adapter: ArrayAdapter<File>, dialog: AlertDialog) {
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle(R.string.record_delete_title)
|
||||
.setMessage(file.name)
|
||||
.setPositiveButton(R.string.record_delete_yes) { _, _ ->
|
||||
if (recPlayingPath == file.absolutePath) stopRecordingPlayback()
|
||||
file.delete()
|
||||
adapter.remove(file)
|
||||
adapter.notifyDataSetChanged()
|
||||
toast(getString(R.string.record_deleted))
|
||||
if (adapter.count == 0) dialog.dismiss()
|
||||
}
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.show()
|
||||
}
|
||||
|
||||
/** Переключиться на другую группу: отправить серверу по живому соединению. */
|
||||
private fun switchGroup(tg: Long) {
|
||||
settings = settings.copy(talkGroup = tg)
|
||||
|
||||
@@ -10,6 +10,7 @@ import android.os.Binder
|
||||
import android.os.IBinder
|
||||
import android.util.Log
|
||||
import ru.ua1zbe.svxremote.audio.OpusEngine
|
||||
import ru.ua1zbe.svxremote.audio.RxRecorder
|
||||
import ru.ua1zbe.svxremote.prefs.Prefs
|
||||
import ru.ua1zbe.svxremote.proto.MsgParser
|
||||
import ru.ua1zbe.svxremote.proto.ReflectorClient
|
||||
@@ -31,15 +32,22 @@ class SvxService : Service(), ReflectorClient.Callbacks {
|
||||
fun onAudioFlush()
|
||||
fun onError(detail: String)
|
||||
fun onLevel(source: Int, level: Float)
|
||||
fun onRxStats(lossPct: Double, jitterMs: Double)
|
||||
}
|
||||
|
||||
var listener: Listener? = null
|
||||
|
||||
private val engine by lazy {
|
||||
OpusEngine { source, level -> listener?.onLevel(source, level) }
|
||||
OpusEngine(
|
||||
onLevel = { source, level -> listener?.onLevel(source, level) },
|
||||
onRxStats = { lossPct, jitterMs -> listener?.onRxStats(lossPct, jitterMs) }
|
||||
).also { it.rxTap = { pcm, real -> recorder.feed(pcm, real) } }
|
||||
}
|
||||
val client: ReflectorClient by lazy { ReflectorClient(this) }
|
||||
|
||||
/** Запись приёма в OGG (управляется из UI). */
|
||||
private val recorder = RxRecorder()
|
||||
|
||||
private val binder = LocalBinder()
|
||||
|
||||
inner class LocalBinder : Binder() {
|
||||
@@ -185,4 +193,27 @@ class SvxService : Service(), ReflectorClient.Callbacks {
|
||||
override fun onError(detail: String) {
|
||||
listener?.onError(detail)
|
||||
}
|
||||
|
||||
// ---------- Запись приёма в файл ----------
|
||||
|
||||
fun isRecording(): Boolean = recorder.isRecording()
|
||||
|
||||
/** Начать запись. Возвращает путь к файлу или null при ошибке. */
|
||||
fun startRecording(): String? {
|
||||
if (recorder.isRecording()) return recorder.getFilePath()
|
||||
val dir = getExternalFilesDir(null) ?: filesDir
|
||||
return if (recorder.start(dir)) recorder.getFilePath() else null
|
||||
}
|
||||
|
||||
/** Остановить запись. Возвращает путь к сохранённому файлу или null. */
|
||||
fun stopRecording(): String? {
|
||||
if (!recorder.isRecording()) return null
|
||||
recorder.stop()
|
||||
return recorder.getFilePath()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
recorder.stop()
|
||||
super.onDestroy()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,8 @@ import kotlin.math.log10
|
||||
* SVXLink: моно, 8 кГц, 16 бит PCM, кадры 20 мс. Поток уровня сигнала 0..1.
|
||||
*/
|
||||
class OpusEngine(
|
||||
private val onLevel: (source: Int, level: Float) -> Unit
|
||||
private val onLevel: (source: Int, level: Float) -> Unit,
|
||||
private val onRxStats: ((lossPct: Double, jitterMs: Double) -> Unit)? = null
|
||||
) {
|
||||
|
||||
companion object {
|
||||
@@ -71,6 +72,9 @@ class OpusEngine(
|
||||
// Скорость передачи Opus, кбит/с. 0 = Авто (адаптация под интернет).
|
||||
@Volatile
|
||||
var txBitrateKbps = 0
|
||||
// Перехват декодированного RX PCM (для записи в файл). Аргументы: PCM, реальный приём?
|
||||
@Volatile
|
||||
var rxTap: ((pcm: ByteArray, isReal: Boolean) -> Unit)? = null
|
||||
// Текущий битрейт в режиме Авто (пересчитывается по статистике приёма)
|
||||
@Volatile
|
||||
private var autoKbps = 16
|
||||
@@ -241,6 +245,7 @@ class OpusEngine(
|
||||
// Уровень показываем только для реальных пакетов: во время
|
||||
// скрытия потерь (conceal) шкала должна быть в нуле
|
||||
val real = System.currentTimeMillis() - lastRealPacketTime < 400
|
||||
rxTap?.invoke(pcm, real)
|
||||
val lvl = if (real) pcmLevel(pcm) else 0f
|
||||
// Порог тишины: шум декодера и паузы речи — в ноль
|
||||
onLevel(SRC_SPEAKER, if (lvl < 0.02f) 0f else lvl)
|
||||
@@ -330,10 +335,15 @@ class OpusEngine(
|
||||
private fun updateRxStats(seq: Int) {
|
||||
val now = System.currentTimeMillis()
|
||||
if (lastSeq >= 0) {
|
||||
val gap = ((seq - lastSeq - 1) and 0xFFFF).toLong()
|
||||
if (gap > 0) lossCount += gap
|
||||
pktCount++
|
||||
val dt = now - lastPktTime
|
||||
// seq — счётчик ГОВОРЯЩЕГО: при повторах кадров он может
|
||||
// повторяться или идти назад — это не потери. Пауза > 200 мс
|
||||
// между пакетами — смена реплики/говорящего, тоже не потери.
|
||||
if (dt <= 200) {
|
||||
val gap = (seq - lastSeq - 1).toLong()
|
||||
if (gap in 1..50000) lossCount += gap
|
||||
}
|
||||
pktCount++
|
||||
val j = abs(dt - 20).toDouble()
|
||||
if (j < 100) {
|
||||
jitterAccum += j
|
||||
@@ -349,6 +359,7 @@ class OpusEngine(
|
||||
if (now - lastEvalTime < 5000 || pktCount < 30) return
|
||||
val lossPct = lossCount * 100.0 / pktCount
|
||||
val jitterMs = if (jitterN > 0) jitterAccum / jitterN else 0.0
|
||||
Log.d(TAG, "stats window: pkt=$pktCount loss=$lossCount pct=$lossPct j=$jitterMs lastSeq=$lastSeq")
|
||||
if (lossPct > 3.0 || jitterMs > 60.0) {
|
||||
if (autoKbps > AUTO_BITRATE_MIN) {
|
||||
autoKbps -= AUTO_BITRATE_STEP
|
||||
@@ -370,6 +381,7 @@ class OpusEngine(
|
||||
jitterAccum = 0.0
|
||||
jitterN = 0
|
||||
lastEvalTime = now
|
||||
onRxStats?.invoke(lossPct, jitterMs)
|
||||
}
|
||||
|
||||
/** Джиттер-гейт открыт: накопили кадры или вышло 300 мс с первого пакета. */
|
||||
|
||||
183
app/src/main/java/ru/ua1zbe/svxremote/audio/RxRecorder.kt
Normal file
183
app/src/main/java/ru/ua1zbe/svxremote/audio/RxRecorder.kt
Normal file
@@ -0,0 +1,183 @@
|
||||
package ru.ua1zbe.svxremote.audio
|
||||
|
||||
import android.media.MediaCodec
|
||||
import android.media.MediaCodecInfo
|
||||
import android.media.MediaFormat
|
||||
import android.media.MediaMuxer
|
||||
import android.util.Log
|
||||
import java.io.File
|
||||
import java.nio.ByteBuffer
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.concurrent.LinkedBlockingQueue
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
/**
|
||||
* Запись декодированного RX-аудио (PCM 48 кГц моно 16 бит) в OGG/Opus.
|
||||
* Тишина (отсутствие приёма) не пишется: во время пауз pts не продвигается,
|
||||
* поэтому речь в файле склеивается без пропусков/пустых участков.
|
||||
*/
|
||||
class RxRecorder {
|
||||
|
||||
companion object {
|
||||
const val TAG = "SVXRemote"
|
||||
const val SAMPLE_RATE = 48000
|
||||
const val CHANNELS = 1
|
||||
}
|
||||
|
||||
@Volatile
|
||||
private var muxer: MediaMuxer? = null
|
||||
@Volatile
|
||||
private var encoder: MediaCodec? = null
|
||||
private val recording = AtomicBoolean(false)
|
||||
private val queue = LinkedBlockingQueue<ByteArray>()
|
||||
private var worker: Thread? = null
|
||||
private var filePath = ""
|
||||
// Накопленный pts (мкс). Растёт только при подаче реального аудио (без тишины).
|
||||
private var ptsUs = 0L
|
||||
private var trackIndex = -1
|
||||
private var muxStarted = false
|
||||
private var wroteAny = false
|
||||
private val info = MediaCodec.BufferInfo()
|
||||
|
||||
fun isRecording(): Boolean = recording.get()
|
||||
|
||||
fun getFilePath(): String = filePath
|
||||
|
||||
/** Начать запись. dir — базовая директория (используется подкаталог recordings). */
|
||||
fun start(dir: File): Boolean {
|
||||
if (recording.get()) return true
|
||||
val outDir = File(dir, "recordings")
|
||||
if (!outDir.exists()) outDir.mkdirs()
|
||||
val ts = LocalDateTime.now()
|
||||
.format(DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss"))
|
||||
val f = File(outDir, "rec_$ts.ogg")
|
||||
filePath = f.absolutePath
|
||||
val mux = try {
|
||||
MediaMuxer(filePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_OGG)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "recorder muxer create failed: ${e.message}")
|
||||
return false
|
||||
}
|
||||
val fmt = MediaFormat()
|
||||
fmt.setString(MediaFormat.KEY_MIME, MediaFormat.MIMETYPE_AUDIO_OPUS)
|
||||
fmt.setInteger(MediaFormat.KEY_SAMPLE_RATE, SAMPLE_RATE)
|
||||
fmt.setInteger(MediaFormat.KEY_CHANNEL_COUNT, CHANNELS)
|
||||
fmt.setInteger(MediaFormat.KEY_BIT_RATE, 32000)
|
||||
fmt.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, 1920)
|
||||
val enc = try {
|
||||
MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_AUDIO_OPUS).also {
|
||||
it.configure(fmt, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
|
||||
it.start()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "recorder encoder create failed: ${e.message}")
|
||||
try { mux.release() } catch (_: Exception) {}
|
||||
return false
|
||||
}
|
||||
muxer = mux
|
||||
encoder = enc
|
||||
trackIndex = -1
|
||||
muxStarted = false
|
||||
ptsUs = 0L
|
||||
queue.clear()
|
||||
recording.set(true)
|
||||
worker = Thread({ workerLoop() }, "rx-recorder").also { it.isDaemon = true; it.start() }
|
||||
Log.d(TAG, "recorder started -> $filePath")
|
||||
return true
|
||||
}
|
||||
|
||||
/** Подача декодированного PCM. isReal == false (тишина/скрытие потерь) — не пишем. */
|
||||
fun feed(pcm: ByteArray, isReal: Boolean) {
|
||||
if (!recording.get() || !isReal) return
|
||||
queue.offer(pcm)
|
||||
}
|
||||
|
||||
private fun workerLoop() {
|
||||
val enc = encoder ?: return
|
||||
val mux = muxer ?: return
|
||||
val timeout = 1000L
|
||||
try {
|
||||
while (recording.get() || queue.isNotEmpty()) {
|
||||
val pcm = queue.poll()
|
||||
if (pcm != null) {
|
||||
var inIdx = enc.dequeueInputBuffer(timeout)
|
||||
if (inIdx >= 0) {
|
||||
val buf = enc.getInputBuffer(inIdx) ?: continue
|
||||
buf.clear()
|
||||
buf.put(pcm)
|
||||
val pts = ptsUs
|
||||
enc.queueInputBuffer(inIdx, 0, pcm.size, pts, 0)
|
||||
// Продвигаем pts только для реального аудио (без тишины)
|
||||
ptsUs += (pcm.size / 2) * 1_000_000L / SAMPLE_RATE
|
||||
} else {
|
||||
queue.offer(pcm)
|
||||
Thread.sleep(2)
|
||||
}
|
||||
}
|
||||
drainEncoder(enc, mux, false)
|
||||
if (!recording.get() && queue.isEmpty()) break
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "recorder worker error: ${e.message}")
|
||||
} finally {
|
||||
// Дренаж остатков после остановки
|
||||
try {
|
||||
drainEncoder(enc, mux, true)
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
try { enc.stop() } catch (_: Exception) {}
|
||||
try { enc.release() } catch (_: Exception) {}
|
||||
if (muxStarted) {
|
||||
try { mux.stop() } catch (_: Exception) {}
|
||||
}
|
||||
try { mux.release() } catch (_: Exception) {}
|
||||
encoder = null
|
||||
muxer = null
|
||||
Log.d(TAG, "recorder stopped -> $filePath")
|
||||
}
|
||||
}
|
||||
|
||||
private fun drainEncoder(enc: MediaCodec, mux: MediaMuxer, drainAll: Boolean) {
|
||||
val timeout = if (drainAll) 0L else 1000L
|
||||
var outIdx = enc.dequeueOutputBuffer(info, timeout)
|
||||
while (outIdx >= 0) {
|
||||
if (info.size > 0) {
|
||||
if (trackIndex < 0) {
|
||||
trackIndex = mux.addTrack(enc.outputFormat)
|
||||
mux.start()
|
||||
muxStarted = true
|
||||
Log.d(TAG, "recorder muxer track added, started")
|
||||
}
|
||||
val outBuf = enc.getOutputBuffer(outIdx) ?: run {
|
||||
enc.releaseOutputBuffer(outIdx, false)
|
||||
outIdx = enc.dequeueOutputBuffer(info, timeout)
|
||||
continue
|
||||
}
|
||||
outBuf.position(info.offset)
|
||||
outBuf.limit(info.offset + info.size)
|
||||
mux.writeSampleData(trackIndex, outBuf, info)
|
||||
wroteAny = true
|
||||
}
|
||||
enc.releaseOutputBuffer(outIdx, false)
|
||||
outIdx = enc.dequeueOutputBuffer(info, timeout)
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
if (!recording.get()) return
|
||||
recording.set(false)
|
||||
val w = worker
|
||||
if (w != null && w.isAlive) {
|
||||
try { w.join(4000) } catch (_: InterruptedException) {}
|
||||
}
|
||||
worker = null
|
||||
// Если ни одного кадра не записалось (весь сеанс — тишина) — удаляем пустой файл.
|
||||
if (!wroteAny) {
|
||||
try {
|
||||
val f = File(filePath)
|
||||
if (f.exists()) f.delete()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -170,4 +170,12 @@ object Prefs {
|
||||
fun setTxBitrateKbps(context: Context, kbps: Int) {
|
||||
sp(context).edit().putInt("tx_bitrate_kbps", kbps.coerceIn(0, 32)).apply()
|
||||
}
|
||||
|
||||
/** Показывать строку статистики приёма (потери/джиттер) на главном экране. */
|
||||
fun rxStatsEnabled(context: Context): Boolean =
|
||||
sp(context).getBoolean("rx_stats_enabled", true)
|
||||
|
||||
fun setRxStatsEnabled(context: Context, enabled: Boolean) {
|
||||
sp(context).edit().putBoolean("rx_stats_enabled", enabled).apply()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -541,7 +541,7 @@ class ReflectorClient(
|
||||
if (data.size < 6) return
|
||||
val r = BinReader(data)
|
||||
val type = r.u16()
|
||||
r.u16() // client id
|
||||
val cid = r.u16() // client id
|
||||
val seq = r.u16()
|
||||
val payload = data.copyOfRange(6, data.size)
|
||||
when (type) {
|
||||
@@ -549,7 +549,7 @@ class ReflectorClient(
|
||||
ReflectorProtocol.MSG_UDP_AUDIO -> {
|
||||
val audio = MsgParser.udpAudio(payload)
|
||||
if (audio != null && audio.isNotEmpty()) {
|
||||
Log.d(TAG, "UDP RX audio seq=$seq len=${audio.size}")
|
||||
Log.d(TAG, "UDP RX audio seq=$seq cid=$cid len=${audio.size}")
|
||||
callbacks.onAudioPacket(audio, seq)
|
||||
} else {
|
||||
Log.w(TAG, "UDP RX audio empty/null len=${payload.size}")
|
||||
|
||||
@@ -3,15 +3,18 @@ package ru.ua1zbe.svxremote.ui
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.LinearGradient
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Path
|
||||
import android.graphics.Shader
|
||||
import android.util.AttributeSet
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import ru.ua1zbe.svxremote.R
|
||||
|
||||
/**
|
||||
* Большая круглая кнопка PTT. Удерживание — передача.
|
||||
* Большая круглая кнопка PTT в стиле Zello: градиентная заливка с глянцем,
|
||||
* мягкая тень (плавающая кнопка) и тонкий ободок.
|
||||
* Состояния: idle (синяя), нажата (красная), отключена (серая).
|
||||
*/
|
||||
class PttButton @JvmOverloads constructor(
|
||||
@@ -19,8 +22,11 @@ class PttButton @JvmOverloads constructor(
|
||||
attrs: AttributeSet? = null
|
||||
) : View(context, attrs) {
|
||||
|
||||
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
private val fillPaint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
private val ringPaint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
private val glossPaint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
private val micPaint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
private val clip = Path()
|
||||
|
||||
private var radius = 0f
|
||||
private var centerX = 0f
|
||||
@@ -31,7 +37,6 @@ class PttButton @JvmOverloads constructor(
|
||||
set(value) {
|
||||
field = value
|
||||
if (!value) setPressed(false)
|
||||
refreshColors()
|
||||
invalidate()
|
||||
}
|
||||
|
||||
@@ -50,22 +55,28 @@ class PttButton @JvmOverloads constructor(
|
||||
}
|
||||
|
||||
init {
|
||||
fillPaint.style = Paint.Style.FILL
|
||||
fillPaint.setShadowLayer(dp(8).toFloat(), 0f, dp(4).toFloat(), Color.argb(80, 0, 0, 0))
|
||||
ringPaint.style = Paint.Style.STROKE
|
||||
ringPaint.strokeWidth = dp(3).toFloat()
|
||||
ringPaint.color = Color.parseColor("#3D444D")
|
||||
paint.style = Paint.Style.FILL
|
||||
refreshColors()
|
||||
ringPaint.strokeWidth = dp(2).toFloat()
|
||||
glossPaint.style = Paint.Style.FILL
|
||||
micPaint.style = Paint.Style.FILL
|
||||
micPaint.color = Color.WHITE
|
||||
isClickable = true
|
||||
isFocusable = true
|
||||
}
|
||||
|
||||
private fun refreshColors() {
|
||||
paint.color = when {
|
||||
!enabled2 -> Color.parseColor("#484F58")
|
||||
isPressed -> Color.parseColor("#F85149")
|
||||
else -> Color.parseColor("#1F6FEB")
|
||||
}
|
||||
invalidate()
|
||||
/** (верхний цвет, нижний цвет) градиента заливки. */
|
||||
private fun fillColors(): Pair<Int, Int> = when {
|
||||
!enabled2 -> 0x565E68 to 0x3C424B
|
||||
isPressed -> 0xFF8A80 to 0xDB3A30
|
||||
else -> 0x4A9BFF to 0x1561D6
|
||||
}
|
||||
|
||||
private fun ringColor(): Int = when {
|
||||
!enabled2 -> 0x6B727B
|
||||
isPressed -> 0xFFA79F
|
||||
else -> 0x6FB0FF
|
||||
}
|
||||
|
||||
private fun dp(v: Int): Int = (v * resources.displayMetrics.density).toInt()
|
||||
@@ -74,32 +85,51 @@ class PttButton @JvmOverloads constructor(
|
||||
super.onSizeChanged(w, h, oldw, oldh)
|
||||
centerX = w / 2f
|
||||
centerY = h / 2f
|
||||
radius = minOf(w, h) / 2f - dp(16)
|
||||
refreshColors()
|
||||
radius = minOf(w, h) / 2f - dp(18)
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
super.onDraw(canvas)
|
||||
val r = radius / (1f + if (isPressed) 0.06f else 0f)
|
||||
canvas.drawCircle(centerX, centerY, r, paint)
|
||||
canvas.drawCircle(centerX, centerY, r, ringPaint)
|
||||
val cx = centerX
|
||||
val cy = centerY
|
||||
val (top, bottom) = fillColors()
|
||||
|
||||
// Иконка микрофона (как icons8: капсула + чашка + ножка + основание)
|
||||
val micPaint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
micPaint.color = Color.WHITE
|
||||
micPaint.style = Paint.Style.FILL
|
||||
// Заливка с вертикальным градиентом (глянец)
|
||||
fillPaint.shader = LinearGradient(
|
||||
cx, cy - r, cx, cy + r, top, bottom, Shader.TileMode.CLAMP
|
||||
)
|
||||
canvas.drawCircle(cx, cy, r, fillPaint)
|
||||
|
||||
val x = centerX
|
||||
val y = centerY
|
||||
val s = r * 0.5f
|
||||
// Тонкий ободок по краю
|
||||
ringPaint.color = ringColor()
|
||||
canvas.drawCircle(cx, cy, r - dp(1).toFloat(), ringPaint)
|
||||
|
||||
// Верхний глянец (белое затухание сверху)
|
||||
glossPaint.shader = LinearGradient(
|
||||
cx, cy - r, cx, cy + r * 0.18f,
|
||||
Color.argb(70, 255, 255, 255), Color.TRANSPARENT, Shader.TileMode.CLAMP
|
||||
)
|
||||
clip.reset()
|
||||
clip.addCircle(cx, cy, r, Path.Direction.CW)
|
||||
canvas.save()
|
||||
canvas.clipPath(clip)
|
||||
canvas.drawRect(cx - r, cy - r, cx + r, cy + r, glossPaint)
|
||||
canvas.restore()
|
||||
glossPaint.shader = null
|
||||
|
||||
drawMic(canvas, cx, cy, r * 0.5f)
|
||||
}
|
||||
|
||||
/** Иконка микрофона (капсула + держатель + ножка + основание). */
|
||||
private fun drawMic(canvas: Canvas, x: Float, y: Float, s: Float) {
|
||||
// корпус-капсула
|
||||
val halfW = s * 0.34f
|
||||
canvas.drawRoundRect(
|
||||
x - halfW, y - s * 0.85f, x + halfW, y + s * 0.10f,
|
||||
halfW, halfW, micPaint
|
||||
)
|
||||
// чашка-держатель (U-образная, заполненная)
|
||||
// чашка-держатель (U-образная)
|
||||
val cup = Path()
|
||||
cup.fillType = Path.FillType.EVEN_ODD
|
||||
cup.moveTo(x - s * 0.52f, y + s * 0.06f)
|
||||
@@ -147,4 +177,4 @@ class PttButton @JvmOverloads constructor(
|
||||
else -> true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
6
app/src/main/res/drawable/bg_section.xml
Normal file
6
app/src/main/res/drawable/bg_section.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
||||
<solid android:color="@color/surface" />
|
||||
<corners android:radius="14dp" />
|
||||
<stroke android:width="1dp" android:color="@color/surface_light" />
|
||||
</shape>
|
||||
9
app/src/main/res/drawable/ic_delete.xml
Normal file
9
app/src/main/res/drawable/ic_delete.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M6,7h12v12a1,1 0,0 1,-1 1H7a1,1 0,0 1,-1 -1V7zM9,9v9h1V9H9zM14,9v9h1V9h-1zM4,5h16v2H4V5z" />
|
||||
</vector>
|
||||
9
app/src/main/res/drawable/ic_play.xml
Normal file
9
app/src/main/res/drawable/ic_play.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M8,5v14l11,-7z" />
|
||||
</vector>
|
||||
9
app/src/main/res/drawable/ic_record.xml
Normal file
9
app/src/main/res/drawable/ic_record.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#8B949E"
|
||||
android:pathData="M12,12m-7,0a7,7 0,1 1,14 0a7,7 0,1 1,-14 0" />
|
||||
</vector>
|
||||
9
app/src/main/res/drawable/ic_stop.xml
Normal file
9
app/src/main/res/drawable/ic_stop.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M7,7h10v10h-10z" />
|
||||
</vector>
|
||||
@@ -135,6 +135,15 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="24dp" />
|
||||
|
||||
<CheckBox
|
||||
android:id="@+id/chk_rx_stats"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="24dp"
|
||||
android:text="@string/audio_rx_stats"
|
||||
android:textColor="@color/text_primary"
|
||||
android:textSize="15sp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_audio_save"
|
||||
android:layout_width="match_parent"
|
||||
|
||||
@@ -85,10 +85,22 @@
|
||||
android:ellipsize="end"
|
||||
android:text="@string/settings_not_configured" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/btn_record"
|
||||
android:layout_width="44dp"
|
||||
android:layout_height="44dp"
|
||||
android:layout_marginStart="8dp"
|
||||
android:src="@drawable/ic_record"
|
||||
android:background="?android:attr/selectableItemBackgroundBorderless"
|
||||
android:contentDescription="@string/btn_record"
|
||||
android:scaleType="centerInside"
|
||||
android:tint="@color/text_secondary" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/btn_monitor"
|
||||
android:layout_width="44dp"
|
||||
android:layout_height="44dp"
|
||||
android:layout_marginStart="8dp"
|
||||
android:src="@drawable/ic_headset"
|
||||
android:background="?android:attr/selectableItemBackgroundBorderless"
|
||||
android:contentDescription="@string/btn_monitor"
|
||||
@@ -104,38 +116,68 @@
|
||||
android:textStyle="bold"
|
||||
android:fontFamily="monospace" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/lbl_subtitle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:gravity="center"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="14sp"
|
||||
android:singleLine="true"
|
||||
android:ellipsize="end" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Статус соединения -->
|
||||
<TextView
|
||||
android:id="@+id/status_chip"
|
||||
<!-- Секция: APRS/подключение + статус -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="20dp"
|
||||
android:layout_marginTop="12dp"
|
||||
android:orientation="vertical"
|
||||
android:background="@drawable/bg_section"
|
||||
android:paddingStart="14dp"
|
||||
android:paddingEnd="14dp"
|
||||
android:paddingTop="10dp"
|
||||
android:paddingBottom="10dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/lbl_subtitle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="14sp"
|
||||
android:singleLine="true"
|
||||
android:ellipsize="end" />
|
||||
|
||||
<!-- Статус соединения -->
|
||||
<TextView
|
||||
android:id="@+id/status_chip"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:gravity="center"
|
||||
android:textColor="@color/danger"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold"
|
||||
android:text="@string/status_disconnected" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Говорящий (единая строка): «Говорит …» при приёме, иначе позывной+TG -->
|
||||
<TextView
|
||||
android:id="@+id/lbl_talker"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="14dp"
|
||||
android:gravity="center"
|
||||
android:textColor="@color/danger"
|
||||
android:textSize="18sp"
|
||||
android:textColor="@color/accent"
|
||||
android:textSize="22sp"
|
||||
android:textStyle="bold"
|
||||
android:text="@string/status_disconnected" />
|
||||
android:singleLine="true"
|
||||
android:ellipsize="end" />
|
||||
|
||||
<!-- S-метры -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="28dp"
|
||||
android:orientation="vertical">
|
||||
android:layout_marginTop="16dp"
|
||||
android:orientation="vertical"
|
||||
android:background="@drawable/bg_section"
|
||||
android:paddingStart="14dp"
|
||||
android:paddingEnd="14dp"
|
||||
android:paddingTop="12dp"
|
||||
android:paddingBottom="12dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
@@ -181,30 +223,19 @@
|
||||
android:layout_height="52dp"
|
||||
android:layout_weight="1" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Статистика приёма (скрытые настройки, по умолчанию выключено) -->
|
||||
<TextView
|
||||
android:id="@+id/lbl_rx_stats"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:gravity="center"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="13sp"
|
||||
android:visibility="gone" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Последний говоривший -->
|
||||
<TextView
|
||||
android:id="@+id/lbl_talker_title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="24dp"
|
||||
android:gravity="center"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="13sp"
|
||||
android:text="@string/talker_last_none" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/lbl_talker"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center"
|
||||
android:textColor="@color/accent"
|
||||
android:textSize="22sp"
|
||||
android:textStyle="bold"
|
||||
android:singleLine="true"
|
||||
android:ellipsize="end" />
|
||||
|
||||
<!-- Индикация направления: RX (приём) / TX (передача) -->
|
||||
<TextView
|
||||
android:id="@+id/lbl_duplex"
|
||||
@@ -224,19 +255,8 @@
|
||||
<!-- Кнопка PTT -->
|
||||
<ru.ua1zbe.svxremote.ui.PttButton
|
||||
android:id="@+id/ptt_button"
|
||||
android:layout_width="130dp"
|
||||
android:layout_height="130dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/lbl_ptt_hint"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="14dp"
|
||||
android:gravity="center"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="14sp"
|
||||
android:textStyle="bold"
|
||||
android:text="@string/hold_to_talk" />
|
||||
android:layout_width="170dp"
|
||||
android:layout_height="170dp" />
|
||||
|
||||
<Space
|
||||
android:layout_width="match_parent"
|
||||
|
||||
41
app/src/main/res/layout/dialog_recording_item.xml
Normal file
41
app/src/main/res/layout/dialog_recording_item.xml
Normal file
@@ -0,0 +1,41 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:paddingStart="16dp"
|
||||
android:paddingEnd="8dp"
|
||||
android:paddingTop="10dp"
|
||||
android:paddingBottom="10dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/rec_name"
|
||||
android:layout_width="0dp"
|
||||
android:layout_weight="1"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="@color/text_primary"
|
||||
android:textSize="14sp"
|
||||
android:singleLine="true"
|
||||
android:ellipsize="middle" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/rec_play"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:src="@drawable/ic_play"
|
||||
android:tint="@color/accent"
|
||||
android:background="?android:attr/selectableItemBackgroundBorderless"
|
||||
android:scaleType="centerInside"
|
||||
android:contentDescription="Воспроизвести" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/rec_delete"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:src="@drawable/ic_delete"
|
||||
android:tint="@color/danger"
|
||||
android:background="?android:attr/selectableItemBackgroundBorderless"
|
||||
android:scaleType="centerInside"
|
||||
android:contentDescription="Удалить" />
|
||||
</LinearLayout>
|
||||
@@ -27,6 +27,8 @@
|
||||
<string name="audio_tx_bitrate">Скорость передачи (Opus)</string>
|
||||
<string name="audio_tx_bitrate_auto">Авто</string>
|
||||
<string name="audio_tx_bitrate_fmt">%1$d кбит/с</string>
|
||||
<string name="audio_rx_stats">Показывать статистику приёма (потери/джиттер)</string>
|
||||
<string name="rx_stats_fmt">RX: потери %1$.1f%%, джиттер %2$d мс</string>
|
||||
<string name="toast_node_joined">Подключился: %1$s%2$s</string>
|
||||
<string name="toast_node_left">Отключился: %1$s%2$s</string>
|
||||
<string name="node_tg_suffix"> (группа %1$d)</string>
|
||||
@@ -39,12 +41,22 @@
|
||||
<string name="status_disconnected">Отключено</string>
|
||||
<string name="app_title">SVXHomyak</string>
|
||||
<string name="btn_power">Выключить: отключиться и закрыть</string>
|
||||
<string name="btn_record">Запись приёма в файл (нажмите, чтобы начать/остановить)</string>
|
||||
<string name="btn_monitor">Мониторинг групп (нажмите, чтобы включить)</string>
|
||||
<string name="monitoring_dev_warning">Мониторинг выбранных групп!</string>
|
||||
<string name="monitor_off">Обычный режим</string>
|
||||
<string name="btn_monitor_on">Мониторинг групп включен (нажмите, чтобы выключить)</string>
|
||||
<string name="record_started">Запись приёма начата</string>
|
||||
<string name="record_saved">Сохранено: %1$s</string>
|
||||
<string name="record_failed">Не удалось начать запись (формат OGG недоступен)</string>
|
||||
<string name="recordings_title">Записи приёма</string>
|
||||
<string name="record_play_failed">Не удалось воспроизвести запись</string>
|
||||
<string name="record_delete_title">Удалить запись?</string>
|
||||
<string name="record_delete_yes">Удалить</string>
|
||||
<string name="record_deleted">Удалено</string>
|
||||
<string name="status_monitoring">Мониторинг: %1$s</string>
|
||||
<string name="talker_now">Говорит TG%1$d</string>
|
||||
<string name="talker_active_fmt">Говорит %1$s TG%2$d</string>
|
||||
<string name="talker_idle_fmt">%1$s TG%2$d</string>
|
||||
<string name="duplex_rx">RX</string>
|
||||
<string name="duplex_tx">TX</string>
|
||||
<string name="status_big_disconnected">DISCONNECTED</string>
|
||||
@@ -52,7 +64,6 @@
|
||||
<string name="status_big_talking">ПЕРЕДАЧА</string>
|
||||
<string name="hint_connect">Нажмите, чтобы подключиться</string>
|
||||
<string name="hint_disconnect">Нажмите, чтобы отключиться</string>
|
||||
<string name="hold_to_talk">HOLD TO TALK</string>
|
||||
<string name="meter_rx">RX</string>
|
||||
<string name="meter_mic">MIC</string>
|
||||
<string name="meter_idle">---</string>
|
||||
|
||||
Reference in New Issue
Block a user