feat: 集成 LiveKit 视频通话与状态指示器

- 添加 LiveKit SDK 依赖及 JitPack 仓库
- 新增 LiveKit 配置界面(URL、房间、Token、自动连接开关)
- 实现 LiveKitManager 管理连接状态
- 在 MainActivity 中动态生成 Token 并处理权限申请
- 添加状态指示器(statusIndicator)实时显示 MQTT/LiveKit 连接状态
- 新增监控脚本 monitor.py 用于远程查看视频流
- 更新版本号至 2603131822
This commit is contained in:
2026-03-14 10:47:38 +08:00
parent 9756e71a23
commit d8e875793d
14 changed files with 719 additions and 8 deletions

View File

@@ -44,9 +44,10 @@ dependencies {
implementation(libs.androidx.appcompat)
implementation(libs.material)
implementation(libs.androidx.emoji2.views)
implementation(libs.livekit.android)
implementation("org.eclipse.paho:org.eclipse.paho.android.service:1.1.1")
implementation("org.eclipse.paho:org.eclipse.paho.client.mqttv3:1.2.5")
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
}
}

View File

@@ -5,6 +5,9 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<application
android:allowBackup="true"

View File

@@ -0,0 +1,84 @@
package com.example.lzwcai_terminal_temi
import android.content.Context
import android.util.Log
import io.livekit.android.ConnectOptions
import io.livekit.android.LiveKit
import io.livekit.android.events.RoomEvent
import io.livekit.android.events.collect
import io.livekit.android.room.Room
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
enum class LiveKitStatus {
Connected,
Disconnected,
Failed
}
class LiveKitManager(appContext: Context, private val statusListener: (LiveKitStatus) -> Unit) {
private val context = appContext.applicationContext
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
private var room: Room? = null
private var eventsJob: Job? = null
fun connect(url: String, token: String, enableMic: Boolean, enableCamera: Boolean) {
val finalUrl = url.trim()
val finalToken = token.trim()
if (finalUrl.isEmpty() || finalToken.isEmpty()) {
Log.w("LiveKitManager", "LiveKit connect skipped: empty url or token.")
return
}
scope.launch {
val currentRoom = room ?: LiveKit.create(context).also { room = it }
eventsJob?.cancel()
eventsJob = launch {
currentRoom.events.collect { event ->
when (event) {
is RoomEvent.Connected -> {
Log.i("LiveKitManager", "LiveKit connected.")
statusListener(LiveKitStatus.Connected)
}
is RoomEvent.Disconnected -> {
Log.i("LiveKitManager", "LiveKit disconnected.")
statusListener(LiveKitStatus.Disconnected)
}
else -> {}
}
}
}
runCatching {
val options = ConnectOptions(
autoSubscribe = false,
audio = enableMic,
video = enableCamera
)
currentRoom.connect(finalUrl, finalToken, options)
}.onFailure { e ->
Log.e("LiveKitManager", "LiveKit connect error: ${e.message}", e)
statusListener(LiveKitStatus.Failed)
}
}
}
fun disconnect() {
scope.launch {
eventsJob?.cancel()
runCatching { room?.disconnect() }
statusListener(LiveKitStatus.Disconnected)
}
}
fun release() {
eventsJob?.cancel()
runCatching { room?.disconnect() }
room = null
scope.cancel()
statusListener(LiveKitStatus.Disconnected)
}
}

View File

@@ -1,13 +1,20 @@
package com.example.lzwcai_terminal_temi
import android.Manifest
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.content.pm.PackageManager
import android.os.Bundle
import android.os.Build
import android.util.Log
import android.view.WindowManager
import android.util.Base64
import android.graphics.drawable.GradientDrawable
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import com.example.lzwcai_terminal_temi.databinding.ActivityMainBinding
import com.robotemi.sdk.Robot
import com.robotemi.sdk.TtsRequest
@@ -29,6 +36,10 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlin.random.Random
import org.json.JSONObject
import java.nio.charset.StandardCharsets
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnGoToLocationStatusChangedListener,
OnDetectionStateChangedListener, OnReposeStatusChangedListener, SharedPreferences.OnSharedPreferenceChangeListener,
@@ -41,6 +52,18 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
private lateinit var prefs: SharedPreferences
private lateinit var navCon: NavController
private lateinit var permissionManager: PermissionManager
private var liveKitManager: LiveKitManager? = null
private val liveKitUrlKey = "livekit_url"
private val liveKitRoomKey = "livekit_room"
private val liveKitTokenKey = "livekit_token"
private val liveKitEnabledKey = "livekit_enabled"
private val liveKitPermissionRequestCode = 2001
private val liveKitUrlDefault = "ws://192.168.2.236:7880"
private val liveKitApiKeyDefault = "devkey"
private val liveKitApiSecretDefault = "secret"
private val liveKitRoomDefault = "temi-room"
private var isLiveKitConnected = false
private var isMqttConnected = false
private var lastArrivalLocation: String? = null
private var lastArrivalAt: Long = 0L
@@ -82,6 +105,13 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
prefs = getSharedPreferences("app_prefs", Context.MODE_PRIVATE)
prefs.registerOnSharedPreferenceChangeListener(this)
liveKitManager = LiveKitManager(applicationContext) { status ->
when (status) {
LiveKitStatus.Connected -> setLiveKitStatus(true)
LiveKitStatus.Disconnected -> setLiveKitStatus(false)
LiveKitStatus.Failed -> setLiveKitStatus(false)
}
}
if (lastArrivalLocation == null) {
lastArrivalLocation = prefs.getString("current_location", null)
}
@@ -104,6 +134,7 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
}
updateMqttConnection()
updateLiveKitStatusSnapshot()
}
override fun onStart() {
@@ -120,6 +151,7 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
} else {
mqttManager?.connect()
}
updateLiveKitConnection()
startBlinking()
}
@@ -132,6 +164,7 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
robot.removeOnReposeStatusChangedListener(this)
robot.removeOnRequestPermissionResultListener(this)
// mqttManager?.disconnect() // Keep MQTT alive in background/settings
liveKitManager?.disconnect()
stopBlinking()
}
@@ -139,6 +172,7 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
super.onDestroy()
prefs.unregisterOnSharedPreferenceChangeListener(this)
mqttManager?.disconnect()
liveKitManager?.release()
LogManager.stopLogcatListener()
mainScope.cancel()
Log.i("MainActivity", "All resources released on destroy.")
@@ -396,6 +430,7 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
if (key == "network_ip") {
Log.i("MainActivity", "IP address changed, re-initializing MQTT connection.")
updateMqttConnection()
updateLiveKitConnection()
}
if (key == "current_location") {
lastArrivalLocation = sharedPreferences?.getString("current_location", null)
@@ -405,6 +440,9 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
val isSpecial = sharedPreferences?.getBoolean("special_task_mode", false) == true
Log.i("MainActivity", "Special mode pref changed: $isSpecial, currentTask: $currentTask")
}
if (key == liveKitUrlKey || key == liveKitRoomKey || key == liveKitTokenKey || key == liveKitEnabledKey) {
updateLiveKitConnection()
}
}
private fun isSpecialModeEnabled(): Boolean {
@@ -415,15 +453,183 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
mqttManager?.shutdown()
val ip = prefs.getString("network_ip", null)
if (!ip.isNullOrEmpty()) {
mqttManager = MqttManager(this, ip, robot, navCon)
mqttManager = MqttManager(this, ip, robot, navCon) { connected ->
setMqttConnectionStatus(connected)
}
mqttManager?.connect()
Log.i("MainActivity", "MQTT Manager updated with new IP: $ip")
} else {
mqttManager = null
setMqttConnectionStatus(false)
Log.w("MainActivity", "MQTT Manager disabled: IP address is not set.")
}
}
private fun updateLiveKitConnection() {
val enabled = prefs.getBoolean(liveKitEnabledKey, true)
val url = resolveLiveKitUrl()
val room = prefs.getString(liveKitRoomKey, liveKitRoomDefault).orEmpty()
val savedToken = prefs.getString(liveKitTokenKey, "").orEmpty()
val token = if (savedToken.isBlank()) {
buildLiveKitToken(
apiKey = liveKitApiKeyDefault,
apiSecret = liveKitApiSecretDefault,
room = room,
identity = buildLiveKitIdentity()
)
} else {
savedToken
}
if (!enabled) {
liveKitManager?.disconnect()
setLiveKitStatus(false)
return
}
if (url.isBlank() || room.isBlank()) {
liveKitManager?.disconnect()
setLiveKitStatus(false)
return
}
if (!hasAudioPermission() || !hasCameraPermission()) {
setLiveKitStatus(false)
requestMediaPermissions()
return
}
setLiveKitStatus(false)
liveKitManager?.connect(url, token, true, true)
}
private fun hasAudioPermission(): Boolean {
return ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED
}
private fun hasCameraPermission(): Boolean {
return ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED
}
private fun requestMediaPermissions() {
ActivityCompat.requestPermissions(
this,
arrayOf(Manifest.permission.RECORD_AUDIO, Manifest.permission.CAMERA),
liveKitPermissionRequestCode
)
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == liveKitPermissionRequestCode) {
val granted = grantResults.isNotEmpty() && grantResults.all { it == PackageManager.PERMISSION_GRANTED }
if (granted) {
updateLiveKitConnection()
} else {
Log.w("MainActivity", "LiveKit media permission denied.")
}
}
}
private fun buildLiveKitIdentity(): String {
val model = Build.MODEL?.trim().orEmpty()
val normalized = model.replace("\\s+".toRegex(), "-").lowercase()
val suffix = if (normalized.isNotEmpty()) normalized else "temi"
return "temi-$suffix"
}
private fun buildLiveKitToken(
apiKey: String,
apiSecret: String,
room: String,
identity: String
): String {
val nowSeconds = System.currentTimeMillis() / 1000
val header = JSONObject()
.put("alg", "HS256")
.put("typ", "JWT")
val grants = JSONObject()
.put("roomJoin", true)
.put("room", room)
.put("canPublish", true)
.put("canSubscribe", true)
.put("canPublishData", true)
val claims = JSONObject()
.put("iss", apiKey)
.put("sub", identity)
.put("nbf", nowSeconds)
.put("exp", nowSeconds + 3600)
.put("video", grants)
val headerB64 = base64Url(header.toString().toByteArray(StandardCharsets.UTF_8))
val claimsB64 = base64Url(claims.toString().toByteArray(StandardCharsets.UTF_8))
val signingInput = "$headerB64.$claimsB64"
val signature = hmacSha256(signingInput, apiSecret)
return "$signingInput.${base64Url(signature)}"
}
private fun hmacSha256(data: String, secret: String): ByteArray {
val mac = Mac.getInstance("HmacSHA256")
val keySpec = SecretKeySpec(secret.toByteArray(StandardCharsets.UTF_8), "HmacSHA256")
mac.init(keySpec)
return mac.doFinal(data.toByteArray(StandardCharsets.UTF_8))
}
private fun base64Url(input: ByteArray): String {
return Base64.encodeToString(input, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
}
private fun updateLiveKitStatusSnapshot() {
val enabled = prefs.getBoolean(liveKitEnabledKey, true)
val url = resolveLiveKitUrl()
val room = prefs.getString(liveKitRoomKey, liveKitRoomDefault).orEmpty()
if (!enabled) {
setLiveKitStatus(false)
return
}
if (url.isBlank() || room.isBlank()) {
setLiveKitStatus(false)
return
}
if (!hasAudioPermission() || !hasCameraPermission()) {
setLiveKitStatus(false)
return
}
setLiveKitStatus(false)
}
private fun setLiveKitStatus(connected: Boolean) {
isLiveKitConnected = connected
updateConnectionIndicator()
}
private fun setMqttConnectionStatus(connected: Boolean) {
isMqttConnected = connected
updateConnectionIndicator()
}
private fun updateConnectionIndicator() {
val colorRes = when {
!isLiveKitConnected && !isMqttConnected -> android.R.color.holo_red_dark
!isLiveKitConnected && isMqttConnected -> android.R.color.holo_blue_light
isLiveKitConnected && !isMqttConnected -> android.R.color.holo_orange_light
else -> android.R.color.holo_green_light
}
val indicatorDrawable = binding.statusIndicator.background as GradientDrawable
indicatorDrawable.setColor(ContextCompat.getColor(this, colorRes))
}
private fun resolveLiveKitUrl(): String {
val savedUrl = prefs.getString(liveKitUrlKey, "").orEmpty().trim()
if (savedUrl.isNotEmpty()) {
return savedUrl
}
val ip = prefs.getString("network_ip", "").orEmpty().trim()
if (ip.isNotEmpty()) {
return "ws://$ip:7880"
}
return liveKitUrlDefault
}
private fun startBlinking() {
stopBlinking()
blinkJob = mainScope.launch {

View File

@@ -2,6 +2,8 @@ package com.example.lzwcai_terminal_temi
import android.content.Context
import android.util.Log
import android.os.Handler
import android.os.Looper
import com.robotemi.sdk.Robot
import com.robotemi.sdk.TtsRequest
import kotlinx.coroutines.*
@@ -15,7 +17,8 @@ class MqttManager(
private val context: Context,
private val serverIp: String,
private val robot: Robot,
private val navController: NavController
private val navController: NavController,
private val statusListener: (Boolean) -> Unit
) {
private var mqttClient: MqttClient? = null
@@ -47,10 +50,12 @@ class MqttManager(
override fun connectComplete(reconnect: Boolean, serverURI: String?) {
Log.i(TAG, "MQTT connection complete. Reconnect: $reconnect")
subscribeTopic("robot/cmd")
updateConnectionStatus(true)
}
override fun connectionLost(cause: Throwable?) {
Log.e(TAG, "Connection lost: ${cause?.message}")
updateConnectionStatus(false)
scheduleReconnect()
}
@@ -74,6 +79,7 @@ class MqttManager(
scope.launch {
if (mqttClient?.isConnected == true) {
Log.d(TAG, "MQTT client is already connected.")
updateConnectionStatus(true)
return@launch
}
Log.i(TAG, "Attempting to connect to MQTT broker at $brokerUri")
@@ -89,6 +95,7 @@ class MqttManager(
mqttClient?.connect(options)
} catch (e: MqttException) {
Log.e(TAG, "Initial connection failed: ${e.message}")
updateConnectionStatus(false)
scheduleReconnect()
}
}
@@ -113,8 +120,10 @@ class MqttManager(
mqttClient?.disconnect()
Log.i(TAG, "Disconnected from MQTT broker.")
}
updateConnectionStatus(false)
} catch (e: MqttException) {
Log.e(TAG, "Error disconnecting from MQTT broker: ${e.message}")
updateConnectionStatus(false)
}
}
}
@@ -131,6 +140,13 @@ class MqttManager(
Log.e(TAG, "Error shutting down MQTT client: ${e.message}")
} finally {
mqttClient = null
updateConnectionStatus(false)
}
}
private fun updateConnectionStatus(connected: Boolean) {
Handler(Looper.getMainLooper()).post {
statusListener(connected)
}
}

View File

@@ -29,6 +29,12 @@ class SettingsActivity : AppCompatActivity() {
private lateinit var locationAdapter: ArrayAdapter<String>
private val currentLocationKey = "current_location"
private lateinit var prefs: SharedPreferences
private val liveKitUrlKey = "livekit_url"
private val liveKitRoomKey = "livekit_room"
private val liveKitTokenKey = "livekit_token"
private val liveKitEnabledKey = "livekit_enabled"
private val liveKitUrlDefault = "ws://192.168.2.236:7880"
private val liveKitRoomDefault = "temi-room"
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -41,9 +47,18 @@ class SettingsActivity : AppCompatActivity() {
prefs = getSharedPreferences("app_prefs", Context.MODE_PRIVATE)
val savedIp = prefs.getString("network_ip", "")
binding.etIpAddress.setText(savedIp)
val savedLiveKitUrl = prefs.getString(liveKitUrlKey, resolveLiveKitUrl())
val savedLiveKitRoom = prefs.getString(liveKitRoomKey, liveKitRoomDefault)
val savedLiveKitToken = prefs.getString(liveKitTokenKey, "")
val isLiveKitEnabled = prefs.getBoolean(liveKitEnabledKey, true)
binding.etLiveKitUrl.setText(savedLiveKitUrl)
binding.etLiveKitRoom.setText(savedLiveKitRoom)
binding.etLiveKitToken.setText(savedLiveKitToken)
binding.switchLiveKitAuto.setOnCheckedChangeListener(null)
binding.switchLiveKitAuto.isChecked = isLiveKitEnabled
// Set Version Name
val versionName = "2603121722"
val versionName = "2603131822"
binding.tvVersion.text = getString(R.string.version_prefix, versionName)
binding.root.setOnClickListener { hideKeyboard() }
@@ -61,6 +76,30 @@ class SettingsActivity : AppCompatActivity() {
}
}
binding.switchLiveKitAuto.setOnCheckedChangeListener { _, isChecked ->
prefs.edit().putBoolean(liveKitEnabledKey, isChecked).apply()
}
binding.btnLiveKitSave.setOnClickListener {
hideKeyboard()
val url = binding.etLiveKitUrl.text?.toString()?.trim().orEmpty()
val room = binding.etLiveKitRoom.text?.toString()?.trim().orEmpty()
val token = binding.etLiveKitToken.text?.toString()?.trim().orEmpty()
val enabled = binding.switchLiveKitAuto.isChecked
prefs.edit()
.putString(liveKitUrlKey, url)
.putString(liveKitRoomKey, room)
.putString(liveKitTokenKey, token)
.putBoolean(liveKitEnabledKey, enabled)
.apply()
if (url.isBlank() || room.isBlank()) {
Toast.makeText(this, getString(R.string.msg_livekit_cleared), Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(this, getString(R.string.msg_livekit_saved), Toast.LENGTH_SHORT).show()
}
finish()
}
binding.btnBack.setOnClickListener {
hideKeyboard()
finish()
@@ -195,4 +234,16 @@ class SettingsActivity : AppCompatActivity() {
imm.hideSoftInputFromWindow(it.windowToken, 0)
}
}
private fun resolveLiveKitUrl(): String {
val savedUrl = prefs.getString(liveKitUrlKey, "").orEmpty().trim()
if (savedUrl.isNotEmpty()) {
return savedUrl
}
val ip = prefs.getString("network_ip", "").orEmpty().trim()
if (ip.isNotEmpty()) {
return "ws://$ip:7880"
}
return liveKitUrlDefault
}
}

View File

@@ -20,6 +20,16 @@
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<View
android:id="@+id/statusIndicator"
android:layout_width="16dp"
android:layout_height="16dp"
android:layout_marginStart="24dp"
android:layout_marginTop="24dp"
android:background="@drawable/status_indicator"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<com.example.lzwcai_terminal_temi.AnimatedEmojiView
android:id="@+id/animatedEmojiView"
android:layout_width="0dp"
@@ -49,4 +59,4 @@
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -114,6 +114,109 @@
</LinearLayout>
</androidx.cardview.widget.CardView>
<androidx.cardview.widget.CardView
style="@style/CardView.App"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="24dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="24dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:text="@string/label_livekit_config"
android:textColor="@color/text_primary"
android:textSize="22sp"
android:textStyle="bold" />
<com.google.android.material.textfield.TextInputLayout
style="@style/Widget.App.TextInputLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/hint_livekit_url">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etLiveKitUrl"
android:layout_width="match_parent"
android:layout_height="72dp"
android:inputType="textUri"
android:textSize="18sp"
android:textColor="@color/text_primary" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
style="@style/Widget.App.TextInputLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:hint="@string/hint_livekit_room">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etLiveKitRoom"
android:layout_width="match_parent"
android:layout_height="72dp"
android:inputType="text"
android:textSize="18sp"
android:textColor="@color/text_primary" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
style="@style/Widget.App.TextInputLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:hint="@string/hint_livekit_token">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etLiveKitToken"
android:layout_width="match_parent"
android:layout_height="72dp"
android:inputType="text"
android:textSize="18sp"
android:textColor="@color/text_primary" />
</com.google.android.material.textfield.TextInputLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/label_livekit_auto"
android:textColor="@color/text_primary"
android:textSize="18sp" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/switchLiveKitAuto"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:scaleX="1.2"
android:scaleY="1.2" />
</LinearLayout>
<Button
android:id="@+id/btnLiveKitSave"
style="@style/Widget.App.Button"
android:layout_width="match_parent"
android:layout_height="72dp"
android:layout_marginTop="16dp"
android:textSize="20sp"
android:text="@string/btn_save_livekit" />
</LinearLayout>
</androidx.cardview.widget.CardView>
<androidx.cardview.widget.CardView
style="@style/CardView.App"
android:layout_width="match_parent"

View File

@@ -20,6 +20,16 @@
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<View
android:id="@+id/statusIndicator"
android:layout_width="16dp"
android:layout_height="16dp"
android:layout_marginStart="24dp"
android:layout_marginTop="24dp"
android:background="@drawable/status_indicator"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<com.example.lzwcai_terminal_temi.AnimatedEmojiView
android:id="@+id/animatedEmojiView"
android:layout_width="0dp"
@@ -49,4 +59,4 @@
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -105,6 +105,109 @@
</LinearLayout>
</androidx.cardview.widget.CardView>
<androidx.cardview.widget.CardView
style="@style/CardView.App"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="24dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:text="@string/label_livekit_config"
android:textColor="@color/text_primary"
android:textSize="22sp"
android:textStyle="bold" />
<com.google.android.material.textfield.TextInputLayout
style="@style/Widget.App.TextInputLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/hint_livekit_url">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etLiveKitUrl"
android:layout_width="match_parent"
android:layout_height="72dp"
android:inputType="textUri"
android:textSize="18sp"
android:textColor="@color/text_primary" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
style="@style/Widget.App.TextInputLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:hint="@string/hint_livekit_room">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etLiveKitRoom"
android:layout_width="match_parent"
android:layout_height="72dp"
android:inputType="text"
android:textSize="18sp"
android:textColor="@color/text_primary" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
style="@style/Widget.App.TextInputLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:hint="@string/hint_livekit_token">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etLiveKitToken"
android:layout_width="match_parent"
android:layout_height="72dp"
android:inputType="text"
android:textSize="18sp"
android:textColor="@color/text_primary" />
</com.google.android.material.textfield.TextInputLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/label_livekit_auto"
android:textColor="@color/text_primary"
android:textSize="18sp" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/switchLiveKitAuto"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:scaleX="1.2"
android:scaleY="1.2" />
</LinearLayout>
<Button
android:id="@+id/btnLiveKitSave"
style="@style/Widget.App.Button"
android:layout_width="match_parent"
android:layout_height="72dp"
android:layout_marginTop="16dp"
android:textSize="20sp"
android:text="@string/btn_save_livekit" />
</LinearLayout>
</androidx.cardview.widget.CardView>
<androidx.cardview.widget.CardView
style="@style/CardView.App"
android:layout_width="match_parent"

View File

@@ -17,4 +17,20 @@
<string name="label_location_config">当前位置</string>
<string name="hint_current_location">请选择当前地点</string>
<string name="location_unknown">未知</string>
<string name="label_livekit_config">LiveKit 配置</string>
<string name="hint_livekit_url">请输入 LiveKit 地址 (wss://your-host)</string>
<string name="hint_livekit_token">请输入 LiveKit Token</string>
<string name="label_livekit_auto">LiveKit 自动连接</string>
<string name="btn_save_livekit">保存 LiveKit 配置</string>
<string name="msg_livekit_saved">LiveKit 配置已保存</string>
<string name="msg_livekit_cleared">LiveKit 配置已清除</string>
<string name="hint_livekit_room">请输入房间名 (例如 temi-room)</string>
<string name="label_livekit_status">LiveKit 状态</string>
<string name="livekit_status_disabled">LiveKit 未启用</string>
<string name="livekit_status_missing">LiveKit 待配置</string>
<string name="livekit_status_connecting">LiveKit 连接中</string>
<string name="livekit_status_connected">LiveKit 已连接</string>
<string name="livekit_status_disconnected">LiveKit 已断开</string>
<string name="livekit_status_failed">LiveKit 连接失败</string>
<string name="livekit_status_permission">需要麦克风/摄像头权限</string>
</resources>

View File

@@ -8,6 +8,7 @@ espressoCore = "3.7.0"
appcompat = "1.7.1"
material = "1.13.0"
emoji2-views = "1.5.0"
livekit = "2.23.5"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -17,9 +18,9 @@ androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-co
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
androidx-emoji2-views = { group = "androidx.emoji2", name = "emoji2-views", version.ref = "emoji2-views" }
livekit-android = { group = "io.livekit", name = "livekit-android", version.ref = "livekit" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }

106
monitor.py Normal file
View File

@@ -0,0 +1,106 @@
import logging
import asyncio
import cv2
import numpy as np
from time import perf_counter
from livekit import rtc, api
# 直接写死你的 API_KEY 和 API_SECRET
API_KEY = "devkey"
API_SECRET = "secret"
URL = "ws://192.168.2.236:7880" # 直接使用你的 LIVEKIT_URL
IDENTITY = "win-client"
ROOM_NAME = "temi-room"
# 生成 token
def generate_token():
token = api.AccessToken(API_KEY, API_SECRET) \
.with_identity(IDENTITY) \
.with_name("Monitor Client1") \
.with_grants(api.VideoGrants(
room_join=True,
room=ROOM_NAME,
)).to_jwt()
return token
async def main():
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
room = rtc.Room()
try:
# 生成连接房间的 token
token = generate_token()
# 连接到 LiveKit 房间
await room.connect(URL, token)
logger.info("Connected to room: %s", room.name)
# 监听参与者连接事件
@room.on("participant_connected")
def on_participant_connected(participant: rtc.RemoteParticipant):
logger.info("Participant connected: %s %s", participant.sid, participant.identity)
async def receive_frames(stream: rtc.VideoStream, track_sid: str):
window_name = f"LiveKit Video Stream ({track_sid})"
cv2.namedWindow(window_name, cv2.WINDOW_AUTOSIZE)
last_time = perf_counter()
frame_count = 0
fps = 0.0
try:
async for event in stream:
frame = event.frame
rgb_frame = frame.convert(rtc.VideoBufferType.RGB24)
arr = np.frombuffer(rgb_frame.data, dtype=np.uint8)
try:
img = arr.reshape((rgb_frame.height, rgb_frame.width, 3))
img_bgr = cv2.cvtColor(img, cv2.COLOR_RGB2BGR)
frame_count += 1
now = perf_counter()
elapsed = now - last_time
if elapsed >= 1.0:
fps = frame_count / elapsed
frame_count = 0
last_time = now
cv2.putText(
img_bgr,
f"FPS: {fps:.1f}",
(10, 30),
cv2.FONT_HERSHEY_SIMPLEX,
1.0,
(0, 255, 0),
2,
cv2.LINE_AA,
)
cv2.imshow(window_name, img_bgr)
if cv2.waitKey(1) & 0xFF == ord('q'):
break
except Exception as e:
logger.error("Error processing frame: %s", e)
finally:
cv2.destroyWindow(window_name)
# 监听 track 订阅事件
@room.on("track_subscribed")
def on_track_subscribed(track: rtc.Track, publication: rtc.RemoteTrackPublication, participant: rtc.RemoteParticipant):
logger.info("Track subscribed: %s", publication.sid)
if track.kind == rtc.TrackKind.KIND_VIDEO:
video_stream = rtc.VideoStream(track)
asyncio.ensure_future(receive_frames(video_stream, publication.sid))
# 保持连接并处理事件
await asyncio.Event().wait()
except Exception as e:
logger.error("Failed to connect or handle events: %s", e)
finally:
# 确保断开连接
await room.disconnect()
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
print("连接已被用户中断")

View File

@@ -16,9 +16,10 @@ dependencyResolutionManagement {
repositories {
google()
mavenCentral()
maven { url = uri("https://jitpack.io") }
}
}
rootProject.name = "lzwcai-terminal-temi"
include(":app")