diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 69fb7a7..1028a86 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -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)
-}
\ No newline at end of file
+}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 93e831d..0e1ccc7 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -5,6 +5,9 @@
+
+
+
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)
+ }
+}
diff --git a/app/src/main/java/com/example/lzwcai_terminal_temi/MainActivity.kt b/app/src/main/java/com/example/lzwcai_terminal_temi/MainActivity.kt
index 78c3b76..a421569 100644
--- a/app/src/main/java/com/example/lzwcai_terminal_temi/MainActivity.kt
+++ b/app/src/main/java/com/example/lzwcai_terminal_temi/MainActivity.kt
@@ -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,
+ 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 {
diff --git a/app/src/main/java/com/example/lzwcai_terminal_temi/MqttManager.kt b/app/src/main/java/com/example/lzwcai_terminal_temi/MqttManager.kt
index 6ffe1de..f5e5b77 100644
--- a/app/src/main/java/com/example/lzwcai_terminal_temi/MqttManager.kt
+++ b/app/src/main/java/com/example/lzwcai_terminal_temi/MqttManager.kt
@@ -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)
}
}
diff --git a/app/src/main/java/com/example/lzwcai_terminal_temi/SettingsActivity.kt b/app/src/main/java/com/example/lzwcai_terminal_temi/SettingsActivity.kt
index f7ec1d4..1ed54d6 100644
--- a/app/src/main/java/com/example/lzwcai_terminal_temi/SettingsActivity.kt
+++ b/app/src/main/java/com/example/lzwcai_terminal_temi/SettingsActivity.kt
@@ -29,6 +29,12 @@ class SettingsActivity : AppCompatActivity() {
private lateinit var locationAdapter: ArrayAdapter
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
+ }
}
diff --git a/app/src/main/res/layout-land/activity_main.xml b/app/src/main/res/layout-land/activity_main.xml
index 0ac98d7..ec86bac 100644
--- a/app/src/main/res/layout-land/activity_main.xml
+++ b/app/src/main/res/layout-land/activity_main.xml
@@ -20,6 +20,16 @@
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
+
+
-
\ No newline at end of file
+
diff --git a/app/src/main/res/layout-land/activity_settings.xml b/app/src/main/res/layout-land/activity_settings.xml
index 95fcf07..4ca7bf2 100644
--- a/app/src/main/res/layout-land/activity_settings.xml
+++ b/app/src/main/res/layout-land/activity_settings.xml
@@ -114,6 +114,109 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
\ No newline at end of file
+
diff --git a/app/src/main/res/layout/activity_settings.xml b/app/src/main/res/layout/activity_settings.xml
index 5327c09..4664f32 100644
--- a/app/src/main/res/layout/activity_settings.xml
+++ b/app/src/main/res/layout/activity_settings.xml
@@ -105,6 +105,109 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
当前位置
请选择当前地点
未知
+ LiveKit 配置
+ 请输入 LiveKit 地址 (wss://your-host)
+ 请输入 LiveKit Token
+ LiveKit 自动连接
+ 保存 LiveKit 配置
+ LiveKit 配置已保存
+ LiveKit 配置已清除
+ 请输入房间名 (例如 temi-room)
+ LiveKit 状态
+ LiveKit 未启用
+ LiveKit 待配置
+ LiveKit 连接中
+ LiveKit 已连接
+ LiveKit 已断开
+ LiveKit 连接失败
+ 需要麦克风/摄像头权限
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index b120e5b..1242f05 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -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" }
-
diff --git a/monitor.py b/monitor.py
new file mode 100644
index 0000000..7a29c7c
--- /dev/null
+++ b/monitor.py
@@ -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("连接已被用户中断")
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 95bfe0f..79ca02d 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -16,9 +16,10 @@ dependencyResolutionManagement {
repositories {
google()
mavenCentral()
+ maven { url = uri("https://jitpack.io") }
}
}
rootProject.name = "lzwcai-terminal-temi"
include(":app")
-
\ No newline at end of file
+