10 Commits

Author SHA1 Message Date
ua1zbe
f23d8e35bb 1.0.12: UI — убрана HOLD TO TALK, PTT крупнее, строка говорящего между блоками
- Удалена надпись HOLD TO TALK (lbl_ptt_hint).
- PTT-кнопка увеличена 130→170 dp.
- Строка говорящего перенесена между блоками «на связи» и
  индикаторами; единый формат: «Говорит UA1ZBE TG12» при приёме,
  «UA1ZBE TG12» когда приёма нет (шрифт прежний).
2026-08-21 11:54:44 +03:00
ua1zbe
587d46578e 1.0.12: PTT-кнопка в стиле Zello (градиент, тень, глянец, ободок) 2026-08-21 11:39:46 +03:00
ua1zbe
7342dddd0b 1.0.12: читаемое имя записи (дата/время вместо hex)
rec_YYYY-MM-DD_HH-mm-SS.ogg вместо rec_<hex>.ogg.
2026-08-21 11:31:58 +03:00
ua1zbe
e32d6967aa 1.0.12: список записей по долгому тапу на кнопку записи
- Long-press (3 с) кнопки записи открывает диалог со списком
  файлов recordings/*.ogg.
- В диалоге: воспроизведение (MediaPlayer, переключатель
  play/stop) и удаление с подтверждением.
- Пустой список — тост «Нет записей».
- Иконки ic_play/ic_stop/ic_delete, разметка dialog_recording_item.
2026-08-21 11:28:51 +03:00
ua1zbe
99a867df6f 1.0.12: запись приёма в OGG с пропуском тишины
- RxRecorder: MediaCodec(Opus) + MediaMuxer(OGG), пишет декодированный
  RX PCM 48 кГц; pts не продвигается во время тишины, поэтому паузы
  в эфире не попадают в файл.
- OpusEngine.rxTap: перехват PCM из выхода декодера.
- SvxService: startRecording()/stopRecording()/isRecording().
- MainActivity: кнопка btn_record между позывным и монитором,
  индикация (иконка краснеет), тосты о старте/сохранении.
- Файлы: Android/data/ru.ua1zbe.svxremote/files/recordings/rec_*.ogg.
  Пустой файл (весь сеанс — тишина) удаляется.
2026-08-21 11:09:01 +03:00
ua1zbe
29734e6203 Секции с фоном: блок индикаторов и блок APRS/статуса 2026-08-21 08:29:41 +03:00
ua1zbe
af9bb48f7e Bump to 1.0.11 2026-08-19 09:40:48 +03:00
ua1zbe
c8daa276fc Фикс подсчёта потерь: повторы/инверсии seq говорящего и паузы не считаются потерями 2026-08-19 09:37:44 +03:00
ua1zbe
1bfac034cd RX-статистика включена по умолчанию 2026-08-19 09:08:32 +03:00
ua1zbe
7b8e897e8a RX-статистика (потери/джиттер) на главном экране, галочка в скрытых настройках 2026-08-19 08:57:39 +03:00
18 changed files with 673 additions and 104 deletions

View File

@@ -11,8 +11,8 @@ android {
applicationId = "ru.ua1zbe.svxremote" applicationId = "ru.ua1zbe.svxremote"
minSdk = 26 minSdk = 26
targetSdk = 34 targetSdk = 34
versionCode = 12 versionCode = 14
versionName = "1.0.10" versionName = "1.0.12"
} }
buildTypes { buildTypes {

View File

@@ -4,6 +4,7 @@ import android.app.Activity
import android.os.Bundle import android.os.Bundle
import android.widget.ArrayAdapter import android.widget.ArrayAdapter
import android.widget.Button import android.widget.Button
import android.widget.CheckBox
import android.widget.SeekBar import android.widget.SeekBar
import android.widget.Spinner import android.widget.Spinner
import android.widget.TextView import android.widget.TextView
@@ -78,11 +79,15 @@ class AudioSettingsActivity : Activity() {
seekRxBuffer.setOnSeekBarChangeListener(listener) seekRxBuffer.setOnSeekBarChangeListener(listener)
seekJitter.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 { findViewById<Button>(R.id.btn_audio_save).setOnClickListener {
Prefs.setMicGain(this, 1.0f + seekMicGain.progress * 0.1f) Prefs.setMicGain(this, 1.0f + seekMicGain.progress * 0.1f)
Prefs.setRxBufferMs(this, 100 + seekRxBuffer.progress * 20) Prefs.setRxBufferMs(this, 100 + seekRxBuffer.progress * 20)
Prefs.setRxJitterMs(this, seekJitter.progress * 20) Prefs.setRxJitterMs(this, seekJitter.progress * 20)
Prefs.setTxBitrateKbps(this, bitrates[spinBitrate.selectedItemPosition]) Prefs.setTxBitrateKbps(this, bitrates[spinBitrate.selectedItemPosition])
Prefs.setRxStatsEnabled(this, chkRxStats.isChecked)
Toast.makeText(this, getString(R.string.saved), Toast.LENGTH_SHORT).show() Toast.makeText(this, getString(R.string.saved), Toast.LENGTH_SHORT).show()
finish() finish()
} }

View File

@@ -8,7 +8,11 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.ServiceConnection import android.content.ServiceConnection
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.media.MediaPlayer
import android.net.Uri import android.net.Uri
import android.view.ViewGroup
import android.widget.ArrayAdapter
import android.widget.ListView
import android.content.res.ColorStateList import android.content.res.ColorStateList
import android.graphics.Color import android.graphics.Color
import android.graphics.PixelFormat import android.graphics.PixelFormat
@@ -53,18 +57,30 @@ class MainActivity : Activity(), SvxService.Listener {
private lateinit var lblSubtitle: TextView private lateinit var lblSubtitle: TextView
private lateinit var lblCallsign: TextView private lateinit var lblCallsign: TextView
private lateinit var lblTg: TextView private lateinit var lblTg: TextView
private lateinit var lblTalkerTitle: TextView
private lateinit var lblTalker: TextView private lateinit var lblTalker: TextView
private lateinit var lblDuplex: 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 lblTxState: TextView
private lateinit var lblConnectHint: TextView private lateinit var lblConnectHint: TextView
private lateinit var connectZone: View private lateinit var connectZone: View
private lateinit var rxMeter: SMeterView private lateinit var rxMeter: SMeterView
private lateinit var micMeter: SMeterView private lateinit var micMeter: SMeterView
private lateinit var lblRxStats: TextView
private lateinit var pttButton: PttButton private lateinit var pttButton: PttButton
private lateinit var btnSettings: ImageButton private lateinit var btnSettings: ImageButton
private lateinit var btnMonitor: 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 val mainHandler = Handler(Looper.getMainLooper())
private var titleHoldRunnable: Runnable? = null private var titleHoldRunnable: Runnable? = null
@@ -90,6 +106,7 @@ class MainActivity : Activity(), SvxService.Listener {
override fun onServiceConnected(name: ComponentName, binder: IBinder) { override fun onServiceConnected(name: ComponentName, binder: IBinder) {
svc = (binder as SvxService.LocalBinder).service() svc = (binder as SvxService.LocalBinder).service()
svc?.listener = this@MainActivity svc?.listener = this@MainActivity
updateRecordUi(svc?.isRecording() ?: false)
} }
override fun onServiceDisconnected(name: ComponentName) { override fun onServiceDisconnected(name: ComponentName) {
@@ -130,10 +147,8 @@ class MainActivity : Activity(), SvxService.Listener {
statusChip = findViewById(R.id.status_chip) statusChip = findViewById(R.id.status_chip)
lblAppTitle = findViewById(R.id.lbl_app_title) lblAppTitle = findViewById(R.id.lbl_app_title)
lblSubtitle = findViewById(R.id.lbl_subtitle) lblSubtitle = findViewById(R.id.lbl_subtitle)
lblTalkerTitle = findViewById(R.id.lbl_talker_title)
lblTalker = findViewById(R.id.lbl_talker) lblTalker = findViewById(R.id.lbl_talker)
lblDuplex = findViewById(R.id.lbl_duplex) lblDuplex = findViewById(R.id.lbl_duplex)
lblPttHint = findViewById(R.id.lbl_ptt_hint)
lblTxState = findViewById(R.id.lbl_tx_state) lblTxState = findViewById(R.id.lbl_tx_state)
lblConnectHint = findViewById(R.id.lbl_connect_hint) lblConnectHint = findViewById(R.id.lbl_connect_hint)
connectZone = findViewById(R.id.connect_zone) connectZone = findViewById(R.id.connect_zone)
@@ -142,12 +157,33 @@ class MainActivity : Activity(), SvxService.Listener {
rxMeter = findViewById(R.id.rx_meter) rxMeter = findViewById(R.id.rx_meter)
micMeter = findViewById(R.id.mic_meter) micMeter = findViewById(R.id.mic_meter)
micMeter.setColor(0xFFF85149.toInt()) 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) pttButton = findViewById(R.id.ptt_button)
btnSettings = findViewById(R.id.btn_settings) btnSettings = findViewById(R.id.btn_settings)
btnMonitor = findViewById(R.id.btn_monitor) btnMonitor = findViewById(R.id.btn_monitor)
btnRecord = findViewById(R.id.btn_record)
pttButton.enabled2 = false pttButton.enabled2 = false
btnMonitor.setOnClickListener { toggleMonitoring() } 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() updateMonitorButton()
val testText = intent?.getStringExtra("node_toast_test") val testText = intent?.getStringExtra("node_toast_test")
if (testText != null) { if (testText != null) {
@@ -236,7 +272,6 @@ class MainActivity : Activity(), SvxService.Listener {
toast(getString(R.string.perm_mic)) toast(getString(R.string.perm_mic))
return return
} }
lblPttHint.text = getString(R.string.ptt_talking)
lblTxState.text = getString(R.string.status_big_talking) lblTxState.text = getString(R.string.status_big_talking)
showDuplex(getString(R.string.duplex_tx), R.color.danger, null) showDuplex(getString(R.string.duplex_tx), R.color.danger, null)
svc?.startTx() svc?.startTx()
@@ -245,7 +280,6 @@ class MainActivity : Activity(), SvxService.Listener {
override fun onPttUp() { override fun onPttUp() {
Log.d("SVXRemote", "PTT up") Log.d("SVXRemote", "PTT up")
svc?.stopTx() svc?.stopTx()
lblPttHint.text = getString(R.string.hold_to_talk)
lblTxState.text = lblTxState.text =
if (connected) getString(R.string.status_big_connected) if (connected) getString(R.string.status_big_connected)
else getString(R.string.status_big_disconnected) else getString(R.string.status_big_disconnected)
@@ -301,6 +335,8 @@ class MainActivity : Activity(), SvxService.Listener {
hideNodeOverlay() hideNodeOverlay()
aprsMeteo?.stop() aprsMeteo?.stop()
aprsMeteo = null aprsMeteo = null
mainHandler.removeCallbacks(recLongPressTask)
stopRecordingPlayback()
mainHandler.removeCallbacksAndMessages(null) mainHandler.removeCallbacksAndMessages(null)
SplashActivity.mainAlive = false SplashActivity.mainAlive = false
svc?.listener = null svc?.listener = null
@@ -336,7 +372,6 @@ class MainActivity : Activity(), SvxService.Listener {
lblTxState.text = getString(R.string.status_big_disconnected) lblTxState.text = getString(R.string.status_big_disconnected)
lblTxState.setTextColor(getColor(R.color.danger)) lblTxState.setTextColor(getColor(R.color.danger))
lblConnectHint.text = getString(R.string.hint_connect) lblConnectHint.text = getString(R.string.hint_connect)
lblPttHint.text = getString(R.string.hold_to_talk)
} }
ReflectorClient.STATE_CONNECTING, ReflectorClient.STATE_CONNECTING,
@@ -414,9 +449,9 @@ class MainActivity : Activity(), SvxService.Listener {
} }
resetMonitorIdle() resetMonitorIdle()
} }
lblTalkerTitle.text = getString(R.string.talker_now, tg) lastTalkerCs = callsign
lblTalkerTitle.setTextColor(getColor(R.color.text_secondary)) lastTalkerTg = tg
lblTalker.text = callsign updateTalkerLine(active = true)
if (!pttButton.isPressed2) { if (!pttButton.isPressed2) {
showDuplex(getString(R.string.duplex_rx), R.color.accent, DUPLEX_RX_HIDE_MS) 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) { 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?) { 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() { private fun resetMonitorIdle() {
monitorIdleTask?.let { mainHandler.removeCallbacks(it) } 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) 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) { private fun switchGroup(tg: Long) {
settings = settings.copy(talkGroup = tg) settings = settings.copy(talkGroup = tg)

View File

@@ -10,6 +10,7 @@ import android.os.Binder
import android.os.IBinder import android.os.IBinder
import android.util.Log import android.util.Log
import ru.ua1zbe.svxremote.audio.OpusEngine import ru.ua1zbe.svxremote.audio.OpusEngine
import ru.ua1zbe.svxremote.audio.RxRecorder
import ru.ua1zbe.svxremote.prefs.Prefs import ru.ua1zbe.svxremote.prefs.Prefs
import ru.ua1zbe.svxremote.proto.MsgParser import ru.ua1zbe.svxremote.proto.MsgParser
import ru.ua1zbe.svxremote.proto.ReflectorClient import ru.ua1zbe.svxremote.proto.ReflectorClient
@@ -31,15 +32,22 @@ class SvxService : Service(), ReflectorClient.Callbacks {
fun onAudioFlush() fun onAudioFlush()
fun onError(detail: String) fun onError(detail: String)
fun onLevel(source: Int, level: Float) fun onLevel(source: Int, level: Float)
fun onRxStats(lossPct: Double, jitterMs: Double)
} }
var listener: Listener? = null var listener: Listener? = null
private val engine by lazy { 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) } val client: ReflectorClient by lazy { ReflectorClient(this) }
/** Запись приёма в OGG (управляется из UI). */
private val recorder = RxRecorder()
private val binder = LocalBinder() private val binder = LocalBinder()
inner class LocalBinder : Binder() { inner class LocalBinder : Binder() {
@@ -185,4 +193,27 @@ class SvxService : Service(), ReflectorClient.Callbacks {
override fun onError(detail: String) { override fun onError(detail: String) {
listener?.onError(detail) 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()
}
} }

View File

@@ -20,7 +20,8 @@ import kotlin.math.log10
* SVXLink: моно, 8 кГц, 16 бит PCM, кадры 20 мс. Поток уровня сигнала 0..1. * SVXLink: моно, 8 кГц, 16 бит PCM, кадры 20 мс. Поток уровня сигнала 0..1.
*/ */
class OpusEngine( 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 { companion object {
@@ -71,6 +72,9 @@ class OpusEngine(
// Скорость передачи Opus, кбит/с. 0 = Авто (адаптация под интернет). // Скорость передачи Opus, кбит/с. 0 = Авто (адаптация под интернет).
@Volatile @Volatile
var txBitrateKbps = 0 var txBitrateKbps = 0
// Перехват декодированного RX PCM (для записи в файл). Аргументы: PCM, реальный приём?
@Volatile
var rxTap: ((pcm: ByteArray, isReal: Boolean) -> Unit)? = null
// Текущий битрейт в режиме Авто (пересчитывается по статистике приёма) // Текущий битрейт в режиме Авто (пересчитывается по статистике приёма)
@Volatile @Volatile
private var autoKbps = 16 private var autoKbps = 16
@@ -241,6 +245,7 @@ class OpusEngine(
// Уровень показываем только для реальных пакетов: во время // Уровень показываем только для реальных пакетов: во время
// скрытия потерь (conceal) шкала должна быть в нуле // скрытия потерь (conceal) шкала должна быть в нуле
val real = System.currentTimeMillis() - lastRealPacketTime < 400 val real = System.currentTimeMillis() - lastRealPacketTime < 400
rxTap?.invoke(pcm, real)
val lvl = if (real) pcmLevel(pcm) else 0f val lvl = if (real) pcmLevel(pcm) else 0f
// Порог тишины: шум декодера и паузы речи — в ноль // Порог тишины: шум декодера и паузы речи — в ноль
onLevel(SRC_SPEAKER, if (lvl < 0.02f) 0f else lvl) onLevel(SRC_SPEAKER, if (lvl < 0.02f) 0f else lvl)
@@ -330,10 +335,15 @@ class OpusEngine(
private fun updateRxStats(seq: Int) { private fun updateRxStats(seq: Int) {
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
if (lastSeq >= 0) { if (lastSeq >= 0) {
val gap = ((seq - lastSeq - 1) and 0xFFFF).toLong()
if (gap > 0) lossCount += gap
pktCount++
val dt = now - lastPktTime 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() val j = abs(dt - 20).toDouble()
if (j < 100) { if (j < 100) {
jitterAccum += j jitterAccum += j
@@ -349,6 +359,7 @@ class OpusEngine(
if (now - lastEvalTime < 5000 || pktCount < 30) return if (now - lastEvalTime < 5000 || pktCount < 30) return
val lossPct = lossCount * 100.0 / pktCount val lossPct = lossCount * 100.0 / pktCount
val jitterMs = if (jitterN > 0) jitterAccum / jitterN else 0.0 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 (lossPct > 3.0 || jitterMs > 60.0) {
if (autoKbps > AUTO_BITRATE_MIN) { if (autoKbps > AUTO_BITRATE_MIN) {
autoKbps -= AUTO_BITRATE_STEP autoKbps -= AUTO_BITRATE_STEP
@@ -370,6 +381,7 @@ class OpusEngine(
jitterAccum = 0.0 jitterAccum = 0.0
jitterN = 0 jitterN = 0
lastEvalTime = now lastEvalTime = now
onRxStats?.invoke(lossPct, jitterMs)
} }
/** Джиттер-гейт открыт: накопили кадры или вышло 300 мс с первого пакета. */ /** Джиттер-гейт открыт: накопили кадры или вышло 300 мс с первого пакета. */

View 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) {}
}
}
}

View File

@@ -170,4 +170,12 @@ object Prefs {
fun setTxBitrateKbps(context: Context, kbps: Int) { fun setTxBitrateKbps(context: Context, kbps: Int) {
sp(context).edit().putInt("tx_bitrate_kbps", kbps.coerceIn(0, 32)).apply() 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()
}
} }

View File

@@ -541,7 +541,7 @@ class ReflectorClient(
if (data.size < 6) return if (data.size < 6) return
val r = BinReader(data) val r = BinReader(data)
val type = r.u16() val type = r.u16()
r.u16() // client id val cid = r.u16() // client id
val seq = r.u16() val seq = r.u16()
val payload = data.copyOfRange(6, data.size) val payload = data.copyOfRange(6, data.size)
when (type) { when (type) {
@@ -549,7 +549,7 @@ class ReflectorClient(
ReflectorProtocol.MSG_UDP_AUDIO -> { ReflectorProtocol.MSG_UDP_AUDIO -> {
val audio = MsgParser.udpAudio(payload) val audio = MsgParser.udpAudio(payload)
if (audio != null && audio.isNotEmpty()) { 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) callbacks.onAudioPacket(audio, seq)
} else { } else {
Log.w(TAG, "UDP RX audio empty/null len=${payload.size}") Log.w(TAG, "UDP RX audio empty/null len=${payload.size}")

View File

@@ -3,15 +3,18 @@ package ru.ua1zbe.svxremote.ui
import android.content.Context import android.content.Context
import android.graphics.Canvas import android.graphics.Canvas
import android.graphics.Color import android.graphics.Color
import android.graphics.LinearGradient
import android.graphics.Paint import android.graphics.Paint
import android.graphics.Path import android.graphics.Path
import android.graphics.Shader
import android.util.AttributeSet import android.util.AttributeSet
import android.view.MotionEvent import android.view.MotionEvent
import android.view.View import android.view.View
import ru.ua1zbe.svxremote.R import ru.ua1zbe.svxremote.R
/** /**
* Большая круглая кнопка PTT. Удерживание — передача. * Большая круглая кнопка PTT в стиле Zello: градиентная заливка с глянцем,
* мягкая тень (плавающая кнопка) и тонкий ободок.
* Состояния: idle (синяя), нажата (красная), отключена (серая). * Состояния: idle (синяя), нажата (красная), отключена (серая).
*/ */
class PttButton @JvmOverloads constructor( class PttButton @JvmOverloads constructor(
@@ -19,8 +22,11 @@ class PttButton @JvmOverloads constructor(
attrs: AttributeSet? = null attrs: AttributeSet? = null
) : View(context, attrs) { ) : 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 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 radius = 0f
private var centerX = 0f private var centerX = 0f
@@ -31,7 +37,6 @@ class PttButton @JvmOverloads constructor(
set(value) { set(value) {
field = value field = value
if (!value) setPressed(false) if (!value) setPressed(false)
refreshColors()
invalidate() invalidate()
} }
@@ -50,22 +55,28 @@ class PttButton @JvmOverloads constructor(
} }
init { 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.style = Paint.Style.STROKE
ringPaint.strokeWidth = dp(3).toFloat() ringPaint.strokeWidth = dp(2).toFloat()
ringPaint.color = Color.parseColor("#3D444D") glossPaint.style = Paint.Style.FILL
paint.style = Paint.Style.FILL micPaint.style = Paint.Style.FILL
refreshColors() micPaint.color = Color.WHITE
isClickable = true isClickable = true
isFocusable = true isFocusable = true
} }
private fun refreshColors() { /** (верхний цвет, нижний цвет) градиента заливки. */
paint.color = when { private fun fillColors(): Pair<Int, Int> = when {
!enabled2 -> Color.parseColor("#484F58") !enabled2 -> 0x565E68 to 0x3C424B
isPressed -> Color.parseColor("#F85149") isPressed -> 0xFF8A80 to 0xDB3A30
else -> Color.parseColor("#1F6FEB") else -> 0x4A9BFF to 0x1561D6
} }
invalidate()
private fun ringColor(): Int = when {
!enabled2 -> 0x6B727B
isPressed -> 0xFFA79F
else -> 0x6FB0FF
} }
private fun dp(v: Int): Int = (v * resources.displayMetrics.density).toInt() 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) super.onSizeChanged(w, h, oldw, oldh)
centerX = w / 2f centerX = w / 2f
centerY = h / 2f centerY = h / 2f
radius = minOf(w, h) / 2f - dp(16) radius = minOf(w, h) / 2f - dp(18)
refreshColors()
} }
override fun onDraw(canvas: Canvas) { override fun onDraw(canvas: Canvas) {
super.onDraw(canvas) super.onDraw(canvas)
val r = radius / (1f + if (isPressed) 0.06f else 0f) val r = radius / (1f + if (isPressed) 0.06f else 0f)
canvas.drawCircle(centerX, centerY, r, paint) val cx = centerX
canvas.drawCircle(centerX, centerY, r, ringPaint) val cy = centerY
val (top, bottom) = fillColors()
// Иконка микрофона (как icons8: капсула + чашка + ножка + основание) // Заливка с вертикальным градиентом (глянец)
val micPaint = Paint(Paint.ANTI_ALIAS_FLAG) fillPaint.shader = LinearGradient(
micPaint.color = Color.WHITE cx, cy - r, cx, cy + r, top, bottom, Shader.TileMode.CLAMP
micPaint.style = Paint.Style.FILL )
canvas.drawCircle(cx, cy, r, fillPaint)
val x = centerX // Тонкий ободок по краю
val y = centerY ringPaint.color = ringColor()
val s = r * 0.5f 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 val halfW = s * 0.34f
canvas.drawRoundRect( canvas.drawRoundRect(
x - halfW, y - s * 0.85f, x + halfW, y + s * 0.10f, x - halfW, y - s * 0.85f, x + halfW, y + s * 0.10f,
halfW, halfW, micPaint halfW, halfW, micPaint
) )
// чашка-держатель (U-образная, заполненная) // чашка-держатель (U-образная)
val cup = Path() val cup = Path()
cup.fillType = Path.FillType.EVEN_ODD cup.fillType = Path.FillType.EVEN_ODD
cup.moveTo(x - s * 0.52f, y + s * 0.06f) cup.moveTo(x - s * 0.52f, y + s * 0.06f)

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -135,6 +135,15 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginBottom="24dp" /> 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 <Button
android:id="@+id/btn_audio_save" android:id="@+id/btn_audio_save"
android:layout_width="match_parent" android:layout_width="match_parent"

View File

@@ -85,10 +85,22 @@
android:ellipsize="end" android:ellipsize="end"
android:text="@string/settings_not_configured" /> 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 <ImageButton
android:id="@+id/btn_monitor" android:id="@+id/btn_monitor"
android:layout_width="44dp" android:layout_width="44dp"
android:layout_height="44dp" android:layout_height="44dp"
android:layout_marginStart="8dp"
android:src="@drawable/ic_headset" android:src="@drawable/ic_headset"
android:background="?android:attr/selectableItemBackgroundBorderless" android:background="?android:attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/btn_monitor" android:contentDescription="@string/btn_monitor"
@@ -104,38 +116,68 @@
android:textStyle="bold" android:textStyle="bold"
android:fontFamily="monospace" /> android:fontFamily="monospace" />
</LinearLayout> </LinearLayout>
</LinearLayout>
</LinearLayout>
<!-- Секция: APRS/подключение + статус -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
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 <TextView
android:id="@+id/lbl_subtitle" android:id="@+id/lbl_subtitle"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:gravity="center" android:gravity="center"
android:textColor="@color/text_secondary" android:textColor="@color/text_secondary"
android:textSize="14sp" android:textSize="14sp"
android:singleLine="true" android:singleLine="true"
android:ellipsize="end" /> android:ellipsize="end" />
</LinearLayout>
</LinearLayout>
<!-- Статус соединения --> <!-- Статус соединения -->
<TextView <TextView
android:id="@+id/status_chip" android:id="@+id/status_chip"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="20dp" android:layout_marginTop="8dp"
android:gravity="center" android:gravity="center"
android:textColor="@color/danger" android:textColor="@color/danger"
android:textSize="18sp" android:textSize="18sp"
android:textStyle="bold" android:textStyle="bold"
android:text="@string/status_disconnected" /> 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/accent"
android:textSize="22sp"
android:textStyle="bold"
android:singleLine="true"
android:ellipsize="end" />
<!-- S-метры --> <!-- S-метры -->
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="28dp" android:layout_marginTop="16dp"
android:orientation="vertical"> android:orientation="vertical"
android:background="@drawable/bg_section"
android:paddingStart="14dp"
android:paddingEnd="14dp"
android:paddingTop="12dp"
android:paddingBottom="12dp">
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
@@ -181,29 +223,18 @@
android:layout_height="52dp" android:layout_height="52dp"
android:layout_weight="1" /> android:layout_weight="1" />
</LinearLayout> </LinearLayout>
</LinearLayout>
<!-- Последний говоривший --> <!-- Статистика приёма (скрытые настройки, по умолчанию выключено) -->
<TextView <TextView
android:id="@+id/lbl_talker_title" android:id="@+id/lbl_rx_stats"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="24dp" android:layout_marginTop="8dp"
android:gravity="center" android:gravity="center"
android:textColor="@color/text_secondary" android:textColor="@color/text_secondary"
android:textSize="13sp" android:textSize="13sp"
android:text="@string/talker_last_none" /> android:visibility="gone" />
</LinearLayout>
<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 (передача) --> <!-- Индикация направления: RX (приём) / TX (передача) -->
<TextView <TextView
@@ -224,19 +255,8 @@
<!-- Кнопка PTT --> <!-- Кнопка PTT -->
<ru.ua1zbe.svxremote.ui.PttButton <ru.ua1zbe.svxremote.ui.PttButton
android:id="@+id/ptt_button" android:id="@+id/ptt_button"
android:layout_width="130dp" android:layout_width="170dp"
android:layout_height="130dp" /> android:layout_height="170dp" />
<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" />
<Space <Space
android:layout_width="match_parent" android:layout_width="match_parent"

View 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>

View File

@@ -27,6 +27,8 @@
<string name="audio_tx_bitrate">Скорость передачи (Opus)</string> <string name="audio_tx_bitrate">Скорость передачи (Opus)</string>
<string name="audio_tx_bitrate_auto">Авто</string> <string name="audio_tx_bitrate_auto">Авто</string>
<string name="audio_tx_bitrate_fmt">%1$d кбит/с</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_joined">Подключился: %1$s%2$s</string>
<string name="toast_node_left">Отключился: %1$s%2$s</string> <string name="toast_node_left">Отключился: %1$s%2$s</string>
<string name="node_tg_suffix"> (группа %1$d)</string> <string name="node_tg_suffix"> (группа %1$d)</string>
@@ -39,12 +41,22 @@
<string name="status_disconnected">Отключено</string> <string name="status_disconnected">Отключено</string>
<string name="app_title">SVXHomyak</string> <string name="app_title">SVXHomyak</string>
<string name="btn_power">Выключить: отключиться и закрыть</string> <string name="btn_power">Выключить: отключиться и закрыть</string>
<string name="btn_record">Запись приёма в файл (нажмите, чтобы начать/остановить)</string>
<string name="btn_monitor">Мониторинг групп (нажмите, чтобы включить)</string> <string name="btn_monitor">Мониторинг групп (нажмите, чтобы включить)</string>
<string name="monitoring_dev_warning">Мониторинг выбранных групп!</string> <string name="monitoring_dev_warning">Мониторинг выбранных групп!</string>
<string name="monitor_off">Обычный режим</string> <string name="monitor_off">Обычный режим</string>
<string name="btn_monitor_on">Мониторинг групп включен (нажмите, чтобы выключить)</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="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_rx">RX</string>
<string name="duplex_tx">TX</string> <string name="duplex_tx">TX</string>
<string name="status_big_disconnected">DISCONNECTED</string> <string name="status_big_disconnected">DISCONNECTED</string>
@@ -52,7 +64,6 @@
<string name="status_big_talking">ПЕРЕДАЧА</string> <string name="status_big_talking">ПЕРЕДАЧА</string>
<string name="hint_connect">Нажмите, чтобы подключиться</string> <string name="hint_connect">Нажмите, чтобы подключиться</string>
<string name="hint_disconnect">Нажмите, чтобы отключиться</string> <string name="hint_disconnect">Нажмите, чтобы отключиться</string>
<string name="hold_to_talk">HOLD TO TALK</string>
<string name="meter_rx">RX</string> <string name="meter_rx">RX</string>
<string name="meter_mic">MIC</string> <string name="meter_mic">MIC</string>
<string name="meter_idle">---</string> <string name="meter_idle">---</string>