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.androidx.appcompat)
implementation(libs.material) implementation(libs.material)
implementation(libs.androidx.emoji2.views) 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.android.service:1.1.1")
implementation("org.eclipse.paho:org.eclipse.paho.client.mqttv3:1.2.5") implementation("org.eclipse.paho:org.eclipse.paho.client.mqttv3:1.2.5")
testImplementation(libs.junit) testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.espresso.core)
} }

View File

@@ -5,6 +5,9 @@
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.WAKE_LOCK" /> <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 <application
android:allowBackup="true" 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 package com.example.lzwcai_terminal_temi
import android.Manifest
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.SharedPreferences import android.content.SharedPreferences
import android.content.pm.PackageManager
import android.os.Bundle import android.os.Bundle
import android.os.Build
import android.util.Log import android.util.Log
import android.view.WindowManager import android.view.WindowManager
import android.util.Base64
import android.graphics.drawable.GradientDrawable
import androidx.appcompat.app.AppCompatActivity 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.example.lzwcai_terminal_temi.databinding.ActivityMainBinding
import com.robotemi.sdk.Robot import com.robotemi.sdk.Robot
import com.robotemi.sdk.TtsRequest import com.robotemi.sdk.TtsRequest
@@ -29,6 +36,10 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlin.random.Random 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, class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnGoToLocationStatusChangedListener,
OnDetectionStateChangedListener, OnReposeStatusChangedListener, SharedPreferences.OnSharedPreferenceChangeListener, OnDetectionStateChangedListener, OnReposeStatusChangedListener, SharedPreferences.OnSharedPreferenceChangeListener,
@@ -41,6 +52,18 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
private lateinit var prefs: SharedPreferences private lateinit var prefs: SharedPreferences
private lateinit var navCon: NavController private lateinit var navCon: NavController
private lateinit var permissionManager: PermissionManager 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 lastArrivalLocation: String? = null
private var lastArrivalAt: Long = 0L private var lastArrivalAt: Long = 0L
@@ -82,6 +105,13 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
prefs = getSharedPreferences("app_prefs", Context.MODE_PRIVATE) prefs = getSharedPreferences("app_prefs", Context.MODE_PRIVATE)
prefs.registerOnSharedPreferenceChangeListener(this) prefs.registerOnSharedPreferenceChangeListener(this)
liveKitManager = LiveKitManager(applicationContext) { status ->
when (status) {
LiveKitStatus.Connected -> setLiveKitStatus(true)
LiveKitStatus.Disconnected -> setLiveKitStatus(false)
LiveKitStatus.Failed -> setLiveKitStatus(false)
}
}
if (lastArrivalLocation == null) { if (lastArrivalLocation == null) {
lastArrivalLocation = prefs.getString("current_location", null) lastArrivalLocation = prefs.getString("current_location", null)
} }
@@ -104,6 +134,7 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
} }
updateMqttConnection() updateMqttConnection()
updateLiveKitStatusSnapshot()
} }
override fun onStart() { override fun onStart() {
@@ -120,6 +151,7 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
} else { } else {
mqttManager?.connect() mqttManager?.connect()
} }
updateLiveKitConnection()
startBlinking() startBlinking()
} }
@@ -132,6 +164,7 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
robot.removeOnReposeStatusChangedListener(this) robot.removeOnReposeStatusChangedListener(this)
robot.removeOnRequestPermissionResultListener(this) robot.removeOnRequestPermissionResultListener(this)
// mqttManager?.disconnect() // Keep MQTT alive in background/settings // mqttManager?.disconnect() // Keep MQTT alive in background/settings
liveKitManager?.disconnect()
stopBlinking() stopBlinking()
} }
@@ -139,6 +172,7 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
super.onDestroy() super.onDestroy()
prefs.unregisterOnSharedPreferenceChangeListener(this) prefs.unregisterOnSharedPreferenceChangeListener(this)
mqttManager?.disconnect() mqttManager?.disconnect()
liveKitManager?.release()
LogManager.stopLogcatListener() LogManager.stopLogcatListener()
mainScope.cancel() mainScope.cancel()
Log.i("MainActivity", "All resources released on destroy.") Log.i("MainActivity", "All resources released on destroy.")
@@ -396,6 +430,7 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
if (key == "network_ip") { if (key == "network_ip") {
Log.i("MainActivity", "IP address changed, re-initializing MQTT connection.") Log.i("MainActivity", "IP address changed, re-initializing MQTT connection.")
updateMqttConnection() updateMqttConnection()
updateLiveKitConnection()
} }
if (key == "current_location") { if (key == "current_location") {
lastArrivalLocation = sharedPreferences?.getString("current_location", null) 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 val isSpecial = sharedPreferences?.getBoolean("special_task_mode", false) == true
Log.i("MainActivity", "Special mode pref changed: $isSpecial, currentTask: $currentTask") Log.i("MainActivity", "Special mode pref changed: $isSpecial, currentTask: $currentTask")
} }
if (key == liveKitUrlKey || key == liveKitRoomKey || key == liveKitTokenKey || key == liveKitEnabledKey) {
updateLiveKitConnection()
}
} }
private fun isSpecialModeEnabled(): Boolean { private fun isSpecialModeEnabled(): Boolean {
@@ -415,15 +453,183 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
mqttManager?.shutdown() mqttManager?.shutdown()
val ip = prefs.getString("network_ip", null) val ip = prefs.getString("network_ip", null)
if (!ip.isNullOrEmpty()) { if (!ip.isNullOrEmpty()) {
mqttManager = MqttManager(this, ip, robot, navCon) mqttManager = MqttManager(this, ip, robot, navCon) { connected ->
setMqttConnectionStatus(connected)
}
mqttManager?.connect() mqttManager?.connect()
Log.i("MainActivity", "MQTT Manager updated with new IP: $ip") Log.i("MainActivity", "MQTT Manager updated with new IP: $ip")
} else { } else {
mqttManager = null mqttManager = null
setMqttConnectionStatus(false)
Log.w("MainActivity", "MQTT Manager disabled: IP address is not set.") 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() { private fun startBlinking() {
stopBlinking() stopBlinking()
blinkJob = mainScope.launch { blinkJob = mainScope.launch {

View File

@@ -2,6 +2,8 @@ package com.example.lzwcai_terminal_temi
import android.content.Context import android.content.Context
import android.util.Log import android.util.Log
import android.os.Handler
import android.os.Looper
import com.robotemi.sdk.Robot import com.robotemi.sdk.Robot
import com.robotemi.sdk.TtsRequest import com.robotemi.sdk.TtsRequest
import kotlinx.coroutines.* import kotlinx.coroutines.*
@@ -15,7 +17,8 @@ class MqttManager(
private val context: Context, private val context: Context,
private val serverIp: String, private val serverIp: String,
private val robot: Robot, private val robot: Robot,
private val navController: NavController private val navController: NavController,
private val statusListener: (Boolean) -> Unit
) { ) {
private var mqttClient: MqttClient? = null private var mqttClient: MqttClient? = null
@@ -47,10 +50,12 @@ class MqttManager(
override fun connectComplete(reconnect: Boolean, serverURI: String?) { override fun connectComplete(reconnect: Boolean, serverURI: String?) {
Log.i(TAG, "MQTT connection complete. Reconnect: $reconnect") Log.i(TAG, "MQTT connection complete. Reconnect: $reconnect")
subscribeTopic("robot/cmd") subscribeTopic("robot/cmd")
updateConnectionStatus(true)
} }
override fun connectionLost(cause: Throwable?) { override fun connectionLost(cause: Throwable?) {
Log.e(TAG, "Connection lost: ${cause?.message}") Log.e(TAG, "Connection lost: ${cause?.message}")
updateConnectionStatus(false)
scheduleReconnect() scheduleReconnect()
} }
@@ -74,6 +79,7 @@ class MqttManager(
scope.launch { scope.launch {
if (mqttClient?.isConnected == true) { if (mqttClient?.isConnected == true) {
Log.d(TAG, "MQTT client is already connected.") Log.d(TAG, "MQTT client is already connected.")
updateConnectionStatus(true)
return@launch return@launch
} }
Log.i(TAG, "Attempting to connect to MQTT broker at $brokerUri") Log.i(TAG, "Attempting to connect to MQTT broker at $brokerUri")
@@ -89,6 +95,7 @@ class MqttManager(
mqttClient?.connect(options) mqttClient?.connect(options)
} catch (e: MqttException) { } catch (e: MqttException) {
Log.e(TAG, "Initial connection failed: ${e.message}") Log.e(TAG, "Initial connection failed: ${e.message}")
updateConnectionStatus(false)
scheduleReconnect() scheduleReconnect()
} }
} }
@@ -113,8 +120,10 @@ class MqttManager(
mqttClient?.disconnect() mqttClient?.disconnect()
Log.i(TAG, "Disconnected from MQTT broker.") Log.i(TAG, "Disconnected from MQTT broker.")
} }
updateConnectionStatus(false)
} catch (e: MqttException) { } catch (e: MqttException) {
Log.e(TAG, "Error disconnecting from MQTT broker: ${e.message}") 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}") Log.e(TAG, "Error shutting down MQTT client: ${e.message}")
} finally { } finally {
mqttClient = null 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 lateinit var locationAdapter: ArrayAdapter<String>
private val currentLocationKey = "current_location" private val currentLocationKey = "current_location"
private lateinit var prefs: SharedPreferences 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?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@@ -41,9 +47,18 @@ class SettingsActivity : AppCompatActivity() {
prefs = getSharedPreferences("app_prefs", Context.MODE_PRIVATE) prefs = getSharedPreferences("app_prefs", Context.MODE_PRIVATE)
val savedIp = prefs.getString("network_ip", "") val savedIp = prefs.getString("network_ip", "")
binding.etIpAddress.setText(savedIp) 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 // Set Version Name
val versionName = "2603121722" val versionName = "2603131822"
binding.tvVersion.text = getString(R.string.version_prefix, versionName) binding.tvVersion.text = getString(R.string.version_prefix, versionName)
binding.root.setOnClickListener { hideKeyboard() } 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 { binding.btnBack.setOnClickListener {
hideKeyboard() hideKeyboard()
finish() finish()
@@ -195,4 +234,16 @@ class SettingsActivity : AppCompatActivity() {
imm.hideSoftInputFromWindow(it.windowToken, 0) 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_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="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 <com.example.lzwcai_terminal_temi.AnimatedEmojiView
android:id="@+id/animatedEmojiView" android:id="@+id/animatedEmojiView"
android:layout_width="0dp" android:layout_width="0dp"
@@ -49,4 +59,4 @@
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" /> app:layout_constraintStart_toStartOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -114,6 +114,109 @@
</LinearLayout> </LinearLayout>
</androidx.cardview.widget.CardView> </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 <androidx.cardview.widget.CardView
style="@style/CardView.App" style="@style/CardView.App"
android:layout_width="match_parent" android:layout_width="match_parent"

View File

@@ -20,6 +20,16 @@
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="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 <com.example.lzwcai_terminal_temi.AnimatedEmojiView
android:id="@+id/animatedEmojiView" android:id="@+id/animatedEmojiView"
android:layout_width="0dp" android:layout_width="0dp"
@@ -49,4 +59,4 @@
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" /> app:layout_constraintStart_toStartOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -105,6 +105,109 @@
</LinearLayout> </LinearLayout>
</androidx.cardview.widget.CardView> </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 <androidx.cardview.widget.CardView
style="@style/CardView.App" style="@style/CardView.App"
android:layout_width="match_parent" android:layout_width="match_parent"

View File

@@ -17,4 +17,20 @@
<string name="label_location_config">当前位置</string> <string name="label_location_config">当前位置</string>
<string name="hint_current_location">请选择当前地点</string> <string name="hint_current_location">请选择当前地点</string>
<string name="location_unknown">未知</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> </resources>

View File

@@ -8,6 +8,7 @@ espressoCore = "3.7.0"
appcompat = "1.7.1" appcompat = "1.7.1"
material = "1.13.0" material = "1.13.0"
emoji2-views = "1.5.0" emoji2-views = "1.5.0"
livekit = "2.23.5"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } 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" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
material = { group = "com.google.android.material", name = "material", version.ref = "material" } material = { group = "com.google.android.material", name = "material", version.ref = "material" }
androidx-emoji2-views = { group = "androidx.emoji2", name = "emoji2-views", version.ref = "emoji2-views" } 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] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } 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 { repositories {
google() google()
mavenCentral() mavenCentral()
maven { url = uri("https://jitpack.io") }
} }
} }
rootProject.name = "lzwcai-terminal-temi" rootProject.name = "lzwcai-terminal-temi"
include(":app") include(":app")