Improve streaming camera controls
This commit is contained in:
@@ -4,6 +4,9 @@
|
||||
|
||||
<!-- 摄像头权限 -->
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CAMERA" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<!-- 网络权限:WebRTC / WebSocket / 信令服务器需要 -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
@@ -33,13 +36,19 @@
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true">
|
||||
android:exported="true"
|
||||
android:screenOrientation="landscape">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name=".PosefitStreamingService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="camera" />
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
</manifest>
|
||||
|
||||
@@ -2,7 +2,11 @@ package com.kimgo.posefit
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
@@ -11,21 +15,22 @@ import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.kimgo.posefit.sender.CameraCatalog
|
||||
import com.kimgo.posefit.sender.CameraFacing
|
||||
import com.kimgo.posefit.sender.WebRtcSenderClient
|
||||
import timber.log.Timber
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
private var webRtcClient: WebRtcSenderClient? = null
|
||||
|
||||
private val requestPermission =
|
||||
registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||
if (granted) {
|
||||
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { grants ->
|
||||
val cameraGranted = grants[Manifest.permission.CAMERA] == true
|
||||
if (cameraGranted) {
|
||||
val url = getSavedSignalingUrl()
|
||||
val cameraFacing = getSavedCameraFacing()
|
||||
startWebRtc(url, cameraFacing)
|
||||
val cameraName = getSavedCameraName()
|
||||
startWebRtc(url, cameraFacing, cameraName)
|
||||
} else {
|
||||
Timber.w("Camera permission denied")
|
||||
}
|
||||
@@ -34,6 +39,8 @@ class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
Timber.d("MainActivity onCreate")
|
||||
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
|
||||
setContent {
|
||||
MaterialTheme {
|
||||
@@ -51,70 +58,148 @@ class MainActivity : ComponentActivity() {
|
||||
fun ConfigScreen() {
|
||||
var url by remember { mutableStateOf(getSavedSignalingUrl()) }
|
||||
var cameraFacing by remember { mutableStateOf(getSavedCameraFacing()) }
|
||||
var isStreaming by remember { mutableStateOf(false) }
|
||||
var selectedCameraName by remember { mutableStateOf(getSavedCameraName()) }
|
||||
var cameraMenuExpanded by remember { mutableStateOf(false) }
|
||||
val cameraOptions = remember { CameraCatalog.listOptions(this) }
|
||||
val selectedCameraLabel = remember(selectedCameraName, cameraOptions) {
|
||||
cameraOptions.firstOrNull { it.name == selectedCameraName }?.label ?: "自动选择后置最广角"
|
||||
}
|
||||
var isStreaming by remember { mutableStateOf(PosefitStreamingService.isRunning) }
|
||||
|
||||
Column(
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
.padding(horizontal = 32.dp, vertical = 20.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(28.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "PoseFit 配置",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
TextField(
|
||||
value = url,
|
||||
onValueChange = { url = it },
|
||||
label = { Text("服务器地址 (ws://...)") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = !isStreaming
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = "选择摄像头",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(0.9f)
|
||||
.fillMaxHeight(),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.Start
|
||||
) {
|
||||
CameraFacingButton(
|
||||
text = "前置摄像头",
|
||||
selected = cameraFacing == CameraFacing.FRONT,
|
||||
enabled = !isStreaming,
|
||||
modifier = Modifier.weight(1f),
|
||||
onClick = { cameraFacing = CameraFacing.FRONT }
|
||||
Text(
|
||||
text = "PoseFit",
|
||||
style = MaterialTheme.typography.headlineLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
CameraFacingButton(
|
||||
text = "后置摄像头",
|
||||
selected = cameraFacing == CameraFacing.BACK,
|
||||
enabled = !isStreaming,
|
||||
modifier = Modifier.weight(1f),
|
||||
onClick = { cameraFacing = CameraFacing.BACK }
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Button(
|
||||
onClick = {
|
||||
if (isStreaming) {
|
||||
stopWebRtc()
|
||||
isStreaming = false
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = if (isStreaming) "正在横屏 1080x720 推流" else "横屏 1080x720 推流未开始",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = if (isStreaming) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
saveSignalingUrl(url)
|
||||
saveCameraFacing(cameraFacing)
|
||||
requestPermission.launch(Manifest.permission.CAMERA)
|
||||
isStreaming = true
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Button(
|
||||
onClick = {
|
||||
if (isStreaming) {
|
||||
stopWebRtc()
|
||||
isStreaming = false
|
||||
} else {
|
||||
saveSignalingUrl(url)
|
||||
saveCameraFacing(cameraFacing)
|
||||
saveCameraName(selectedCameraName)
|
||||
requestPermission.launch(requiredPermissions())
|
||||
isStreaming = true
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(56.dp)
|
||||
) {
|
||||
Text(if (isStreaming) "停止推流" else "开始推流")
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1.4f)
|
||||
.fillMaxHeight(),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.Start
|
||||
) {
|
||||
Text(if (isStreaming) "停止推流" else "开始推流")
|
||||
TextField(
|
||||
value = url,
|
||||
onValueChange = { url = it },
|
||||
label = { Text("服务器地址 (ws://...)") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
enabled = !isStreaming
|
||||
)
|
||||
Spacer(modifier = Modifier.height(14.dp))
|
||||
Text(text = "选择摄像头", style = MaterialTheme.typography.titleSmall)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
CameraFacingButton(
|
||||
text = "前置",
|
||||
selected = cameraFacing == CameraFacing.FRONT && selectedCameraName == null,
|
||||
enabled = !isStreaming,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(48.dp),
|
||||
onClick = {
|
||||
cameraFacing = CameraFacing.FRONT
|
||||
selectedCameraName = null
|
||||
}
|
||||
)
|
||||
CameraFacingButton(
|
||||
text = "后置最广",
|
||||
selected = cameraFacing == CameraFacing.BACK && selectedCameraName == null,
|
||||
enabled = !isStreaming,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(48.dp),
|
||||
onClick = {
|
||||
cameraFacing = CameraFacing.BACK
|
||||
selectedCameraName = null
|
||||
}
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
OutlinedButton(
|
||||
onClick = { cameraMenuExpanded = true },
|
||||
enabled = !isStreaming,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(48.dp)
|
||||
) {
|
||||
Text(selectedCameraLabel, maxLines = 1)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = cameraMenuExpanded,
|
||||
onDismissRequest = { cameraMenuExpanded = false },
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("自动选择后置最广角") },
|
||||
onClick = {
|
||||
cameraFacing = CameraFacing.BACK
|
||||
selectedCameraName = null
|
||||
cameraMenuExpanded = false
|
||||
}
|
||||
)
|
||||
cameraOptions.forEach { option ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(option.label) },
|
||||
onClick = {
|
||||
cameraFacing = option.facing
|
||||
selectedCameraName = option.name
|
||||
cameraMenuExpanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -155,7 +240,7 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
private fun getSavedSignalingUrl(): String {
|
||||
val prefs = getSharedPreferences("posefit_prefs", Context.MODE_PRIVATE)
|
||||
return prefs.getString("signaling_url", "ws://192.168.2.10:8765") ?: "ws://192.168.2.10:8765"
|
||||
return prefs.getString("signaling_url", "ws://192.168.2.6:8765") ?: "ws://192.168.2.6:8765"
|
||||
}
|
||||
|
||||
private fun saveSignalingUrl(url: String) {
|
||||
@@ -165,9 +250,9 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
private fun getSavedCameraFacing(): CameraFacing {
|
||||
val prefs = getSharedPreferences("posefit_prefs", Context.MODE_PRIVATE)
|
||||
val value = prefs.getString("camera_facing", CameraFacing.FRONT.name)
|
||||
return runCatching { CameraFacing.valueOf(value ?: CameraFacing.FRONT.name) }
|
||||
.getOrDefault(CameraFacing.FRONT)
|
||||
val value = prefs.getString("camera_facing", CameraFacing.BACK.name)
|
||||
return runCatching { CameraFacing.valueOf(value ?: CameraFacing.BACK.name) }
|
||||
.getOrDefault(CameraFacing.BACK)
|
||||
}
|
||||
|
||||
private fun saveCameraFacing(cameraFacing: CameraFacing) {
|
||||
@@ -175,26 +260,42 @@ class MainActivity : ComponentActivity() {
|
||||
prefs.edit().putString("camera_facing", cameraFacing.name).apply()
|
||||
}
|
||||
|
||||
private fun startWebRtc(url: String, cameraFacing: CameraFacing) {
|
||||
Timber.i("startWebRtc: %s, cameraFacing=%s", url, cameraFacing)
|
||||
webRtcClient?.release()
|
||||
webRtcClient = WebRtcSenderClient(
|
||||
context = this,
|
||||
signalingUrl = url,
|
||||
cameraFacing = cameraFacing
|
||||
)
|
||||
webRtcClient?.start()
|
||||
private fun getSavedCameraName(): String? {
|
||||
val prefs = getSharedPreferences("posefit_prefs", Context.MODE_PRIVATE)
|
||||
return prefs.getString("camera_name", null)?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
private fun saveCameraName(cameraName: String?) {
|
||||
val prefs = getSharedPreferences("posefit_prefs", Context.MODE_PRIVATE)
|
||||
prefs.edit().putString("camera_name", cameraName).apply()
|
||||
}
|
||||
|
||||
private fun startWebRtc(url: String, cameraFacing: CameraFacing, cameraName: String?) {
|
||||
Timber.i("startWebRtc: %s, cameraFacing=%s, cameraName=%s", url, cameraFacing, cameraName)
|
||||
val intent = PosefitStreamingService.startIntent(this, url, cameraFacing, cameraName)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
startForegroundService(intent)
|
||||
} else {
|
||||
startService(intent)
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopWebRtc() {
|
||||
Timber.i("stopWebRtc")
|
||||
webRtcClient?.release()
|
||||
webRtcClient = null
|
||||
startService(PosefitStreamingService.stopIntent(this))
|
||||
}
|
||||
|
||||
private fun requiredPermissions(): Array<String> {
|
||||
return buildList {
|
||||
add(Manifest.permission.CAMERA)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
add(Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
}.toTypedArray()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
stopWebRtc()
|
||||
Timber.d("MainActivity onDestroy")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
package com.kimgo.posefit
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import com.kimgo.posefit.sender.CameraFacing
|
||||
import com.kimgo.posefit.sender.WebRtcSenderClient
|
||||
import timber.log.Timber
|
||||
|
||||
class PosefitStreamingService : Service() {
|
||||
|
||||
private var webRtcClient: WebRtcSenderClient? = null
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
createNotificationChannel()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
when (intent?.action) {
|
||||
ACTION_STOP -> {
|
||||
stopStreaming()
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
ACTION_START -> {
|
||||
val signalingUrl = intent.getStringExtra(EXTRA_SIGNALING_URL) ?: DEFAULT_SIGNALING_URL
|
||||
val cameraFacing = intent.getStringExtra(EXTRA_CAMERA_FACING)
|
||||
?.let { runCatching { CameraFacing.valueOf(it) }.getOrNull() }
|
||||
?: CameraFacing.BACK
|
||||
val preferredCameraName = intent.getStringExtra(EXTRA_CAMERA_NAME)
|
||||
|
||||
startForegroundNotification(signalingUrl, cameraFacing, preferredCameraName)
|
||||
startStreaming(signalingUrl, cameraFacing, preferredCameraName)
|
||||
return START_STICKY
|
||||
}
|
||||
else -> {
|
||||
val signalingUrl = getSavedSignalingUrl()
|
||||
val cameraFacing = getSavedCameraFacing()
|
||||
val preferredCameraName = getSavedCameraName()
|
||||
startForegroundNotification(signalingUrl, cameraFacing, preferredCameraName)
|
||||
startStreaming(signalingUrl, cameraFacing, preferredCameraName)
|
||||
return START_STICKY
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onDestroy() {
|
||||
stopStreaming()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun startStreaming(signalingUrl: String, cameraFacing: CameraFacing, preferredCameraName: String?) {
|
||||
Timber.i(
|
||||
"Streaming service start: %s, cameraFacing=%s, preferredCameraName=%s, orientation=landscape",
|
||||
signalingUrl,
|
||||
cameraFacing,
|
||||
preferredCameraName,
|
||||
)
|
||||
webRtcClient?.release()
|
||||
webRtcClient = WebRtcSenderClient(
|
||||
context = applicationContext,
|
||||
signalingUrl = signalingUrl,
|
||||
cameraFacing = cameraFacing,
|
||||
preferredCameraName = preferredCameraName,
|
||||
).also { it.start() }
|
||||
isRunning = true
|
||||
}
|
||||
|
||||
private fun stopStreaming() {
|
||||
Timber.i("Streaming service stop")
|
||||
webRtcClient?.release()
|
||||
webRtcClient = null
|
||||
isRunning = false
|
||||
}
|
||||
|
||||
private fun startForegroundNotification(signalingUrl: String, cameraFacing: CameraFacing, preferredCameraName: String?) {
|
||||
val notification = buildNotification(signalingUrl, cameraFacing, preferredCameraName)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_CAMERA)
|
||||
} else {
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildNotification(signalingUrl: String, cameraFacing: CameraFacing, preferredCameraName: String?): Notification {
|
||||
val cameraText = when (cameraFacing) {
|
||||
CameraFacing.FRONT -> "前置摄像头"
|
||||
CameraFacing.BACK -> "后置摄像头"
|
||||
}
|
||||
val cameraModeText = preferredCameraName?.let { "$cameraText $it" } ?: "$cameraText 自动最广角"
|
||||
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
Notification.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle("PoseFit 正在推流")
|
||||
.setContentText("$cameraModeText 横屏720p -> $signalingUrl")
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setOngoing(true)
|
||||
.build()
|
||||
} else {
|
||||
Notification.Builder(this)
|
||||
.setContentTitle("PoseFit 正在推流")
|
||||
.setContentText("$cameraModeText 横屏720p -> $signalingUrl")
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setOngoing(true)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||
return
|
||||
}
|
||||
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"PoseFit 推流",
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
)
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
manager.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
private fun getSavedSignalingUrl(): String {
|
||||
val prefs = getSharedPreferences("posefit_prefs", Context.MODE_PRIVATE)
|
||||
return prefs.getString("signaling_url", DEFAULT_SIGNALING_URL) ?: DEFAULT_SIGNALING_URL
|
||||
}
|
||||
|
||||
private fun getSavedCameraFacing(): CameraFacing {
|
||||
val prefs = getSharedPreferences("posefit_prefs", Context.MODE_PRIVATE)
|
||||
val value = prefs.getString("camera_facing", CameraFacing.BACK.name)
|
||||
return runCatching { CameraFacing.valueOf(value ?: CameraFacing.BACK.name) }
|
||||
.getOrDefault(CameraFacing.BACK)
|
||||
}
|
||||
|
||||
private fun getSavedCameraName(): String? {
|
||||
val prefs = getSharedPreferences("posefit_prefs", Context.MODE_PRIVATE)
|
||||
return prefs.getString("camera_name", null)?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val CHANNEL_ID = "posefit_streaming"
|
||||
private const val NOTIFICATION_ID = 1001
|
||||
private const val ACTION_START = "com.kimgo.posefit.action.START_STREAMING"
|
||||
private const val ACTION_STOP = "com.kimgo.posefit.action.STOP_STREAMING"
|
||||
private const val EXTRA_SIGNALING_URL = "extra_signaling_url"
|
||||
private const val EXTRA_CAMERA_FACING = "extra_camera_facing"
|
||||
private const val EXTRA_CAMERA_NAME = "extra_camera_name"
|
||||
private const val DEFAULT_SIGNALING_URL = "ws://192.168.2.6:8765"
|
||||
|
||||
@Volatile
|
||||
var isRunning: Boolean = false
|
||||
private set
|
||||
|
||||
fun startIntent(
|
||||
context: Context,
|
||||
signalingUrl: String,
|
||||
cameraFacing: CameraFacing,
|
||||
preferredCameraName: String?
|
||||
): Intent {
|
||||
return Intent(context, PosefitStreamingService::class.java).apply {
|
||||
action = ACTION_START
|
||||
putExtra(EXTRA_SIGNALING_URL, signalingUrl)
|
||||
putExtra(EXTRA_CAMERA_FACING, cameraFacing.name)
|
||||
putExtra(EXTRA_CAMERA_NAME, preferredCameraName)
|
||||
}
|
||||
}
|
||||
|
||||
fun stopIntent(context: Context): Intent {
|
||||
return Intent(context, PosefitStreamingService::class.java).apply {
|
||||
action = ACTION_STOP
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.kimgo.posefit.sender
|
||||
|
||||
import android.content.Context
|
||||
import android.hardware.camera2.CameraCharacteristics
|
||||
import android.hardware.camera2.CameraManager
|
||||
import org.webrtc.Camera2Enumerator
|
||||
|
||||
object CameraCatalog {
|
||||
fun listOptions(context: Context): List<CameraOption> {
|
||||
val enumerator = Camera2Enumerator(context)
|
||||
val cameraManager = context.getSystemService(Context.CAMERA_SERVICE) as CameraManager
|
||||
|
||||
return enumerator.deviceNames.mapNotNull { cameraName ->
|
||||
val facing = when {
|
||||
enumerator.isFrontFacing(cameraName) -> CameraFacing.FRONT
|
||||
enumerator.isBackFacing(cameraName) -> CameraFacing.BACK
|
||||
else -> return@mapNotNull null
|
||||
}
|
||||
val focalLengths = runCatching {
|
||||
cameraManager.getCameraCharacteristics(cameraName)
|
||||
.get(CameraCharacteristics.LENS_INFO_AVAILABLE_FOCAL_LENGTHS)
|
||||
?.toList()
|
||||
.orEmpty()
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
CameraOption(
|
||||
name = cameraName,
|
||||
facing = facing,
|
||||
focalLengths = focalLengths
|
||||
)
|
||||
}.sortedWith(
|
||||
compareBy<CameraOption> { it.facing != CameraFacing.BACK }
|
||||
.thenBy { it.minFocalLength ?: Float.MAX_VALUE }
|
||||
.thenBy { it.name }
|
||||
)
|
||||
}
|
||||
|
||||
fun widestBackCameraName(context: Context): String? {
|
||||
return listOptions(context)
|
||||
.filter { it.facing == CameraFacing.BACK }
|
||||
.minByOrNull { it.minFocalLength ?: Float.MAX_VALUE }
|
||||
?.name
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.kimgo.posefit.sender
|
||||
|
||||
data class CameraOption(
|
||||
val name: String,
|
||||
val facing: CameraFacing,
|
||||
val focalLengths: List<Float>
|
||||
) {
|
||||
val minFocalLength: Float?
|
||||
get() = focalLengths.minOrNull()
|
||||
|
||||
val label: String
|
||||
get() {
|
||||
val facingText = when (facing) {
|
||||
CameraFacing.FRONT -> "前置"
|
||||
CameraFacing.BACK -> "后置"
|
||||
}
|
||||
val focalText = minFocalLength?.let { "%.2fmm".format(it) } ?: "未知焦距"
|
||||
return "$facingText $focalText ($name)"
|
||||
}
|
||||
}
|
||||
@@ -9,9 +9,18 @@ import timber.log.Timber
|
||||
class WebRtcSenderClient(
|
||||
private val context: Context,
|
||||
private val signalingUrl: String,
|
||||
private val cameraFacing: CameraFacing = CameraFacing.FRONT
|
||||
private val cameraFacing: CameraFacing = CameraFacing.BACK,
|
||||
private val preferredCameraName: String? = null
|
||||
) {
|
||||
|
||||
private companion object {
|
||||
const val VIDEO_WIDTH = 1080
|
||||
const val VIDEO_HEIGHT = 720
|
||||
const val VIDEO_FPS = 30
|
||||
const val VIDEO_MIN_BITRATE_BPS = 3_000_000
|
||||
const val VIDEO_MAX_BITRATE_BPS = 6_000_000
|
||||
}
|
||||
|
||||
private val eglBase = EglBase.create()
|
||||
private val okHttpClient = OkHttpClient()
|
||||
|
||||
@@ -22,10 +31,16 @@ class WebRtcSenderClient(
|
||||
private var videoCapturer: CameraVideoCapturer? = null
|
||||
private var videoSource: VideoSource? = null
|
||||
private var videoTrack: VideoTrack? = null
|
||||
private var videoSender: RtpSender? = null
|
||||
private var surfaceTextureHelper: SurfaceTextureHelper? = null
|
||||
|
||||
fun start() {
|
||||
Timber.i("WebRTC starting, signalingUrl=%s, cameraFacing=%s", signalingUrl, cameraFacing)
|
||||
Timber.i(
|
||||
"WebRTC starting, signalingUrl=%s, cameraFacing=%s, preferredCameraName=%s",
|
||||
signalingUrl,
|
||||
cameraFacing,
|
||||
preferredCameraName
|
||||
)
|
||||
initPeerConnectionFactory()
|
||||
connectSignaling()
|
||||
}
|
||||
@@ -147,24 +162,57 @@ class WebRtcSenderClient(
|
||||
videoSource?.capturerObserver
|
||||
)
|
||||
|
||||
videoCapturer?.startCapture(1280, 720, 30)
|
||||
videoCapturer?.startCapture(VIDEO_WIDTH, VIDEO_HEIGHT, VIDEO_FPS)
|
||||
|
||||
videoTrack = factory.createVideoTrack("video_track", videoSource)
|
||||
|
||||
peerConnection?.addTrack(
|
||||
videoSender = peerConnection?.addTrack(
|
||||
videoTrack,
|
||||
listOf("posefit_stream")
|
||||
)
|
||||
videoSender?.let { configureVideoSender(it) }
|
||||
|
||||
Timber.d("Camera started: %s, resolution=1280x720@30fps", cameraName)
|
||||
Timber.d("Camera started: %s, resolution=%dx%d@%dfps", cameraName, VIDEO_WIDTH, VIDEO_HEIGHT, VIDEO_FPS)
|
||||
}
|
||||
|
||||
private fun configureVideoSender(sender: RtpSender) {
|
||||
val parameters = sender.parameters ?: run {
|
||||
Timber.w("Video sender parameters unavailable")
|
||||
return
|
||||
}
|
||||
|
||||
parameters.degradationPreference = RtpParameters.DegradationPreference.MAINTAIN_RESOLUTION
|
||||
parameters.encodings.forEach { encoding ->
|
||||
encoding.active = true
|
||||
encoding.scaleResolutionDownBy = 1.0
|
||||
encoding.maxFramerate = VIDEO_FPS
|
||||
encoding.minBitrateBps = VIDEO_MIN_BITRATE_BPS
|
||||
encoding.maxBitrateBps = VIDEO_MAX_BITRATE_BPS
|
||||
}
|
||||
|
||||
val applied = sender.setParameters(parameters)
|
||||
Timber.d(
|
||||
"Video sender configured: applied=%s, resolution=%dx%d@%dfps, bitrate=%d-%d",
|
||||
applied,
|
||||
VIDEO_WIDTH,
|
||||
VIDEO_HEIGHT,
|
||||
VIDEO_FPS,
|
||||
VIDEO_MIN_BITRATE_BPS,
|
||||
VIDEO_MAX_BITRATE_BPS,
|
||||
)
|
||||
}
|
||||
|
||||
private fun selectCamera(enumerator: Camera2Enumerator): String {
|
||||
val preferredCamera = enumerator.deviceNames.firstOrNull { cameraName ->
|
||||
when (cameraFacing) {
|
||||
CameraFacing.FRONT -> enumerator.isFrontFacing(cameraName)
|
||||
CameraFacing.BACK -> enumerator.isBackFacing(cameraName)
|
||||
}
|
||||
logAvailableCameras(enumerator)
|
||||
|
||||
if (!preferredCameraName.isNullOrBlank() && preferredCameraName in enumerator.deviceNames) {
|
||||
Timber.i("Selected explicit camera: %s", preferredCameraName)
|
||||
return preferredCameraName
|
||||
}
|
||||
|
||||
val preferredCamera = when (cameraFacing) {
|
||||
CameraFacing.FRONT -> enumerator.deviceNames.firstOrNull { enumerator.isFrontFacing(it) }
|
||||
CameraFacing.BACK -> selectWidestBackCamera(enumerator)
|
||||
}
|
||||
|
||||
if (preferredCamera != null) {
|
||||
@@ -175,6 +223,31 @@ class WebRtcSenderClient(
|
||||
return enumerator.deviceNames.first()
|
||||
}
|
||||
|
||||
private fun selectWidestBackCamera(enumerator: Camera2Enumerator): String? {
|
||||
val backCameras = enumerator.deviceNames.filter { enumerator.isBackFacing(it) }
|
||||
if (backCameras.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
|
||||
val widestCamera = CameraCatalog.widestBackCameraName(context)
|
||||
|
||||
Timber.i("Selected widest back camera: %s", widestCamera)
|
||||
return widestCamera ?: backCameras.first()
|
||||
}
|
||||
|
||||
private fun logAvailableCameras(enumerator: Camera2Enumerator) {
|
||||
val knownOptions = CameraCatalog.listOptions(context).associateBy { it.name }
|
||||
enumerator.deviceNames.forEach { cameraName ->
|
||||
val option = knownOptions[cameraName]
|
||||
Timber.i(
|
||||
"Camera available: name=%s, facing=%s, focalLengths=%s",
|
||||
cameraName,
|
||||
option?.facing ?: "unknown",
|
||||
option?.focalLengths,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createOffer() {
|
||||
val constraints = MediaConstraints().apply {
|
||||
mandatory.add(
|
||||
@@ -188,11 +261,16 @@ class WebRtcSenderClient(
|
||||
peerConnection?.createOffer(object : SdpObserver {
|
||||
|
||||
override fun onCreateSuccess(desc: SessionDescription) {
|
||||
val fixedVideoDesc = SessionDescription(
|
||||
desc.type,
|
||||
forceVideoBandwidth(desc.description)
|
||||
)
|
||||
|
||||
peerConnection?.setLocalDescription(object : SdpObserver {
|
||||
override fun onSetSuccess() {
|
||||
val json = JSONObject().apply {
|
||||
put("type", desc.type.canonicalForm())
|
||||
put("sdp", desc.description)
|
||||
put("type", fixedVideoDesc.type.canonicalForm())
|
||||
put("sdp", fixedVideoDesc.description)
|
||||
}
|
||||
|
||||
webSocket.send(json.toString())
|
||||
@@ -207,7 +285,7 @@ class WebRtcSenderClient(
|
||||
override fun onCreateFailure(error: String) {
|
||||
Timber.e("createOffer failed: %s", error)
|
||||
}
|
||||
}, desc)
|
||||
}, fixedVideoDesc)
|
||||
}
|
||||
|
||||
override fun onSetSuccess() {}
|
||||
@@ -221,6 +299,35 @@ class WebRtcSenderClient(
|
||||
}, constraints)
|
||||
}
|
||||
|
||||
private fun forceVideoBandwidth(sdp: String): String {
|
||||
val lines = sdp.split("\r\n").toMutableList()
|
||||
val videoStart = lines.indexOfFirst { it.startsWith("m=video") }
|
||||
if (videoStart == -1) {
|
||||
return sdp
|
||||
}
|
||||
|
||||
val videoEnd = lines.drop(videoStart + 1)
|
||||
.indexOfFirst { it.startsWith("m=") }
|
||||
.let { if (it == -1) lines.size else videoStart + 1 + it }
|
||||
|
||||
val bandwidthLine = "b=AS:${VIDEO_MAX_BITRATE_BPS / 1000}"
|
||||
val existingBandwidth = (videoStart until videoEnd).firstOrNull { index ->
|
||||
lines[index].startsWith("b=AS:") || lines[index].startsWith("b=TIAS:")
|
||||
}
|
||||
|
||||
if (existingBandwidth != null) {
|
||||
lines[existingBandwidth] = bandwidthLine
|
||||
} else {
|
||||
val insertAt = (videoStart + 1 until videoEnd).firstOrNull { index ->
|
||||
lines[index].startsWith("a=")
|
||||
} ?: videoEnd
|
||||
lines.add(insertAt, bandwidthLine)
|
||||
}
|
||||
|
||||
Timber.d("Forced video SDP bandwidth: %s", bandwidthLine)
|
||||
return lines.joinToString("\r\n")
|
||||
}
|
||||
|
||||
private fun handleSignalingMessage(text: String) {
|
||||
val json = JSONObject(text)
|
||||
|
||||
@@ -261,12 +368,17 @@ class WebRtcSenderClient(
|
||||
videoCapturer?.dispose()
|
||||
videoSource?.dispose()
|
||||
videoTrack?.dispose()
|
||||
videoSender = null
|
||||
surfaceTextureHelper?.dispose()
|
||||
peerConnection?.close()
|
||||
peerConnection?.dispose()
|
||||
factory.dispose()
|
||||
if (::factory.isInitialized) {
|
||||
factory.dispose()
|
||||
}
|
||||
eglBase.release()
|
||||
webSocket.close(1000, "close")
|
||||
if (::webSocket.isInitialized) {
|
||||
webSocket.close(1000, "close")
|
||||
}
|
||||
okHttpClient.dispatcher.executorService.shutdown()
|
||||
|
||||
Timber.d("WebRTC released")
|
||||
|
||||
Reference in New Issue
Block a user