feat: Android VoIP client — Phase 2 (JNI bridge, Compose UI, AEC pipeline wiring)
- JNI bridge with 8 extern functions (init, startCall, stopCall, setMute, setSpeaker, getStats, forceProfile, destroy) with panic catching - Kotlin engine layer: WzpEngine JNI wrapper, WzpCallback interface, CallStats data class with JSON deserialization - Jetpack Compose UI: InCallScreen with quality indicator (green/yellow/red), mute/speaker/hangup buttons, stats overlay, duration timer - CallActivity with RECORD_AUDIO permission handling, Material3 theme - CallService foreground service with WakeLock, WiFi lock, notification - AudioRouteManager for speaker/earpiece/Bluetooth SCO switching - AEC wired into CallEncoder pipeline: AEC → AGC → denoise → silence → encode - AEC farend reference fed from decode path to encode path in pipeline - Engine exposes set_aec_enabled/set_agc_enabled via AtomicBool flags Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
135
android/app/src/main/java/com/wzp/ui/call/CallActivity.kt
Normal file
135
android/app/src/main/java/com/wzp/ui/call/CallActivity.kt
Normal file
@@ -0,0 +1,135 @@
|
||||
package com.wzp.ui.call
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Bundle
|
||||
import android.widget.Toast
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.wzp.service.CallService
|
||||
|
||||
/**
|
||||
* Main activity hosting the in-call Compose UI.
|
||||
*
|
||||
* Requests RECORD_AUDIO permission, starts the foreground [CallService],
|
||||
* and launches the call via [CallViewModel].
|
||||
*/
|
||||
class CallActivity : ComponentActivity() {
|
||||
|
||||
private val viewModel: CallViewModel by viewModels()
|
||||
|
||||
// -- Permission request ---------------------------------------------------
|
||||
|
||||
private val audioPermissionLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { granted ->
|
||||
if (granted) {
|
||||
startCallFlow()
|
||||
} else {
|
||||
Toast.makeText(this, "Microphone permission is required for calls", Toast.LENGTH_LONG).show()
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
// -- Lifecycle ------------------------------------------------------------
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
setContent {
|
||||
WzpTheme {
|
||||
InCallScreen(
|
||||
viewModel = viewModel,
|
||||
onHangUp = {
|
||||
viewModel.stopCall()
|
||||
CallService.stop(this@CallActivity)
|
||||
finish()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Check audio permission
|
||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO)
|
||||
== PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
startCallFlow()
|
||||
} else {
|
||||
audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
if (isFinishing) {
|
||||
viewModel.stopCall()
|
||||
CallService.stop(this)
|
||||
}
|
||||
}
|
||||
|
||||
// -- Call setup ------------------------------------------------------------
|
||||
|
||||
private fun startCallFlow() {
|
||||
// Extract parameters from intent extras, with test defaults.
|
||||
val relayAddr = intent.getStringExtra(EXTRA_RELAY_ADDR) ?: DEFAULT_RELAY
|
||||
val room = intent.getStringExtra(EXTRA_ROOM) ?: DEFAULT_ROOM
|
||||
val seedHex = intent.getStringExtra(EXTRA_SEED_HEX) ?: DEFAULT_SEED_HEX
|
||||
val token = intent.getStringExtra(EXTRA_TOKEN) ?: DEFAULT_TOKEN
|
||||
|
||||
// Start foreground service
|
||||
CallService.start(this)
|
||||
|
||||
// Start the call
|
||||
viewModel.startCall(relayAddr, room, seedHex, token)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val EXTRA_RELAY_ADDR = "relay_addr"
|
||||
const val EXTRA_ROOM = "room"
|
||||
const val EXTRA_SEED_HEX = "seed_hex"
|
||||
const val EXTRA_TOKEN = "token"
|
||||
|
||||
// Test defaults — replaced by real values in production
|
||||
private const val DEFAULT_RELAY = "127.0.0.1:7777"
|
||||
private const val DEFAULT_ROOM = "test-room"
|
||||
private const val DEFAULT_SEED_HEX =
|
||||
"0000000000000000000000000000000000000000000000000000000000000001"
|
||||
private const val DEFAULT_TOKEN = "test-token"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WarzonePhone Material3 theme with dynamic colour support (Android 12+)
|
||||
* and dark mode.
|
||||
*/
|
||||
@Composable
|
||||
fun WzpTheme(content: @Composable () -> Unit) {
|
||||
val darkTheme = isSystemInDarkTheme()
|
||||
val context = LocalContext.current
|
||||
|
||||
val colorScheme = when {
|
||||
// Dynamic colour is available on Android 12+
|
||||
android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S -> {
|
||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
}
|
||||
darkTheme -> darkColorScheme()
|
||||
else -> lightColorScheme()
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
142
android/app/src/main/java/com/wzp/ui/call/CallViewModel.kt
Normal file
142
android/app/src/main/java/com/wzp/ui/call/CallViewModel.kt
Normal file
@@ -0,0 +1,142 @@
|
||||
package com.wzp.ui.call
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.wzp.engine.CallStats
|
||||
import com.wzp.engine.WzpCallback
|
||||
import com.wzp.engine.WzpEngine
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* ViewModel managing the call lifecycle and exposing observable state to the UI.
|
||||
*
|
||||
* Owns the [WzpEngine] instance, implements [WzpCallback] to receive engine events,
|
||||
* and polls call statistics every 500 ms while the call is active.
|
||||
*/
|
||||
class CallViewModel : ViewModel(), WzpCallback {
|
||||
|
||||
// -- Engine ---------------------------------------------------------------
|
||||
|
||||
private val engine = WzpEngine(this)
|
||||
|
||||
// -- Observable state -----------------------------------------------------
|
||||
|
||||
private val _callState = MutableStateFlow(0) // CallStateConstants.IDLE
|
||||
val callState: StateFlow<Int> = _callState.asStateFlow()
|
||||
|
||||
private val _isMuted = MutableStateFlow(false)
|
||||
val isMuted: StateFlow<Boolean> = _isMuted.asStateFlow()
|
||||
|
||||
private val _isSpeaker = MutableStateFlow(false)
|
||||
val isSpeaker: StateFlow<Boolean> = _isSpeaker.asStateFlow()
|
||||
|
||||
private val _stats = MutableStateFlow(CallStats())
|
||||
val stats: StateFlow<CallStats> = _stats.asStateFlow()
|
||||
|
||||
private val _qualityTier = MutableStateFlow(0)
|
||||
val qualityTier: StateFlow<Int> = _qualityTier.asStateFlow()
|
||||
|
||||
private val _errorMessage = MutableStateFlow<String?>(null)
|
||||
val errorMessage: StateFlow<String?> = _errorMessage.asStateFlow()
|
||||
|
||||
// -- Stats polling --------------------------------------------------------
|
||||
|
||||
private var statsJob: Job? = null
|
||||
|
||||
// -- Public API -----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Initialise the native engine and start a call.
|
||||
*
|
||||
* @param relayAddr relay server address (host:port)
|
||||
* @param room room identifier
|
||||
* @param seedHex 64-char hex-encoded 32-byte identity seed
|
||||
* @param token authentication token
|
||||
*/
|
||||
fun startCall(relayAddr: String, room: String, seedHex: String, token: String) {
|
||||
engine.init()
|
||||
val result = engine.startCall(relayAddr, room, seedHex, token)
|
||||
if (result == 0) {
|
||||
startStatsPolling()
|
||||
}
|
||||
}
|
||||
|
||||
/** End the current call and clean up resources. */
|
||||
fun stopCall() {
|
||||
stopStatsPolling()
|
||||
engine.stopCall()
|
||||
}
|
||||
|
||||
/** Toggle microphone mute. */
|
||||
fun toggleMute() {
|
||||
val newMuted = !_isMuted.value
|
||||
_isMuted.value = newMuted
|
||||
engine.setMute(newMuted)
|
||||
}
|
||||
|
||||
/** Toggle speaker (loudspeaker) mode. */
|
||||
fun toggleSpeaker() {
|
||||
val newSpeaker = !_isSpeaker.value
|
||||
_isSpeaker.value = newSpeaker
|
||||
engine.setSpeaker(newSpeaker)
|
||||
}
|
||||
|
||||
/** Clear the current error message. */
|
||||
fun clearError() {
|
||||
_errorMessage.value = null
|
||||
}
|
||||
|
||||
// -- WzpCallback ----------------------------------------------------------
|
||||
|
||||
override fun onCallStateChanged(state: Int) {
|
||||
_callState.value = state
|
||||
}
|
||||
|
||||
override fun onQualityTierChanged(tier: Int) {
|
||||
_qualityTier.value = tier
|
||||
}
|
||||
|
||||
override fun onError(code: Int, message: String) {
|
||||
_errorMessage.value = "Error $code: $message"
|
||||
}
|
||||
|
||||
// -- Stats polling --------------------------------------------------------
|
||||
|
||||
private fun startStatsPolling() {
|
||||
statsJob?.cancel()
|
||||
statsJob = viewModelScope.launch {
|
||||
while (isActive) {
|
||||
val json = engine.getStats()
|
||||
val parsed = CallStats.fromJson(json)
|
||||
_stats.value = parsed
|
||||
_callState.value = parsed.state
|
||||
_qualityTier.value = parsed.qualityTier
|
||||
delay(STATS_POLL_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopStatsPolling() {
|
||||
statsJob?.cancel()
|
||||
statsJob = null
|
||||
}
|
||||
|
||||
// -- Cleanup --------------------------------------------------------------
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
stopStatsPolling()
|
||||
engine.stopCall()
|
||||
engine.destroy()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val STATS_POLL_INTERVAL_MS = 500L
|
||||
}
|
||||
}
|
||||
328
android/app/src/main/java/com/wzp/ui/call/InCallScreen.kt
Normal file
328
android/app/src/main/java/com/wzp/ui/call/InCallScreen.kt
Normal file
@@ -0,0 +1,328 @@
|
||||
package com.wzp.ui.call
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.FilledIconButton
|
||||
import androidx.compose.material3.FilledTonalIconButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.wzp.engine.CallStats
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Main in-call Compose screen.
|
||||
*
|
||||
* Displays call duration, quality indicator, audio controls, and live statistics.
|
||||
*/
|
||||
@Composable
|
||||
fun InCallScreen(
|
||||
viewModel: CallViewModel,
|
||||
onHangUp: () -> Unit
|
||||
) {
|
||||
val callState by viewModel.callState.collectAsState()
|
||||
val isMuted by viewModel.isMuted.collectAsState()
|
||||
val isSpeaker by viewModel.isSpeaker.collectAsState()
|
||||
val stats by viewModel.stats.collectAsState()
|
||||
val qualityTier by viewModel.qualityTier.collectAsState()
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(48.dp))
|
||||
|
||||
// -- Call state label ---------------------------------------------
|
||||
CallStateLabel(callState)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// -- Duration -----------------------------------------------------
|
||||
DurationDisplay(stats.durationSecs)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// -- Quality indicator --------------------------------------------
|
||||
QualityIndicator(qualityTier, stats.qualityLabel)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
// -- Audio level placeholder bar ----------------------------------
|
||||
AudioLevelBar(stats.framesEncoded)
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
// -- Control buttons ----------------------------------------------
|
||||
ControlRow(
|
||||
isMuted = isMuted,
|
||||
isSpeaker = isSpeaker,
|
||||
onToggleMute = viewModel::toggleMute,
|
||||
onToggleSpeaker = viewModel::toggleSpeaker,
|
||||
onHangUp = onHangUp
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
// -- Stats overlay ------------------------------------------------
|
||||
StatsOverlay(stats)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@Composable
|
||||
private fun CallStateLabel(state: Int) {
|
||||
val label = when (state) {
|
||||
0 -> "Idle"
|
||||
1 -> "Connecting..."
|
||||
2 -> "Active"
|
||||
3 -> "Reconnecting..."
|
||||
4 -> "Call Ended"
|
||||
else -> "Unknown"
|
||||
}
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DurationDisplay(durationSecs: Double) {
|
||||
val totalSeconds = durationSecs.roundToInt()
|
||||
val minutes = totalSeconds / 60
|
||||
val seconds = totalSeconds % 60
|
||||
Text(
|
||||
text = "%02d:%02d".format(minutes, seconds),
|
||||
style = MaterialTheme.typography.displayLarge.copy(
|
||||
fontWeight = FontWeight.Light,
|
||||
letterSpacing = 4.sp
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onBackground
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun QualityIndicator(tier: Int, label: String) {
|
||||
val dotColor = when (tier) {
|
||||
0 -> Color(0xFF4CAF50) // green
|
||||
1 -> Color(0xFFFFC107) // yellow
|
||||
2 -> Color(0xFFF44336) // red
|
||||
else -> Color.Gray
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(12.dp)
|
||||
.clip(CircleShape)
|
||||
.background(dotColor)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AudioLevelBar(framesEncoded: Long) {
|
||||
// Placeholder: derive a fake "level" from frame count to show the bar is alive.
|
||||
// In production this would be driven by actual RMS audio levels from the engine.
|
||||
val level = if (framesEncoded > 0) {
|
||||
((framesEncoded % 100).toFloat() / 100f).coerceIn(0.05f, 1f)
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = "Audio Level",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
LinearProgressIndicator(
|
||||
progress = { level },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.6f)
|
||||
.height(6.dp)
|
||||
.clip(RoundedCornerShape(3.dp)),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
trackColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ControlRow(
|
||||
isMuted: Boolean,
|
||||
isSpeaker: Boolean,
|
||||
onToggleMute: () -> Unit,
|
||||
onToggleSpeaker: () -> Unit,
|
||||
onHangUp: () -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Mute button
|
||||
FilledTonalIconButton(
|
||||
onClick = onToggleMute,
|
||||
modifier = Modifier.size(56.dp),
|
||||
colors = if (isMuted) {
|
||||
IconButtonDefaults.filledTonalIconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onErrorContainer
|
||||
)
|
||||
} else {
|
||||
IconButtonDefaults.filledTonalIconButtonColors()
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
text = if (isMuted) "MIC\nOFF" else "MIC",
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
lineHeight = 12.sp
|
||||
)
|
||||
}
|
||||
|
||||
// Hang up button
|
||||
FilledIconButton(
|
||||
onClick = onHangUp,
|
||||
modifier = Modifier.size(72.dp),
|
||||
shape = CircleShape,
|
||||
colors = IconButtonDefaults.filledIconButtonColors(
|
||||
containerColor = Color(0xFFF44336),
|
||||
contentColor = Color.White
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
text = "END",
|
||||
style = MaterialTheme.typography.titleMedium.copy(
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Speaker button
|
||||
FilledTonalIconButton(
|
||||
onClick = onToggleSpeaker,
|
||||
modifier = Modifier.size(56.dp),
|
||||
colors = if (isSpeaker) {
|
||||
IconButtonDefaults.filledTonalIconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
} else {
|
||||
IconButtonDefaults.filledTonalIconButtonColors()
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
text = if (isSpeaker) "SPK\nON" else "SPK",
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
lineHeight = 12.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatsOverlay(stats: CallStats) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = "Network Stats",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly
|
||||
) {
|
||||
StatItem("Loss", "%.1f%%".format(stats.lossPct))
|
||||
StatItem("RTT", "${stats.rttMs}ms")
|
||||
StatItem("Jitter", "${stats.jitterMs}ms")
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly
|
||||
) {
|
||||
StatItem("Enc", "${stats.framesEncoded}")
|
||||
StatItem("Dec", "${stats.framesDecoded}")
|
||||
StatItem("JB Depth", "${stats.jitterBufferDepth}")
|
||||
StatItem("Under", "${stats.underruns}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatItem(label: String, value: String) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.Medium),
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user