feat(app): 添加摄像头配置和视频流设置功能
- 在 CameraOption 中新增 supportedResolutions 属性以支持分辨率选择 - 实现 StreamOrientation 和 StreamResolution 数据类用于视频方向和分辨率管理 - 重构 MainActivity 为多页面应用,添加设置页面支持摄像头、分辨率、方向配置 - 集成下拉菜单选择摄像头和视频分辨率功能 - 更新 PosefitStreamingService 支持视频分辨率和方向参数传递 - 移除 AndroidManifest 中 MainActivity 的屏幕方向锁定设置 - 添加详细的视频捕获帧日志记录和可用摄像头格式输出 - 优化视频流启动流程,支持多种分辨率和方向设置
This commit is contained in:
@@ -36,8 +36,7 @@
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:screenOrientation="landscape">
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
|
||||
@@ -2,177 +2,274 @@ 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
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
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.PosefitStreamingService.Companion.KEY_CAMERA_FACING
|
||||
import com.kimgo.posefit.PosefitStreamingService.Companion.KEY_CAMERA_NAME
|
||||
import com.kimgo.posefit.PosefitStreamingService.Companion.KEY_SIGNALING_URL
|
||||
import com.kimgo.posefit.PosefitStreamingService.Companion.KEY_STREAM_ORIENTATION
|
||||
import com.kimgo.posefit.PosefitStreamingService.Companion.KEY_VIDEO_HEIGHT
|
||||
import com.kimgo.posefit.PosefitStreamingService.Companion.KEY_VIDEO_WIDTH
|
||||
import com.kimgo.posefit.PosefitStreamingService.Companion.PREFS_NAME
|
||||
import com.kimgo.posefit.sender.CameraCatalog
|
||||
import com.kimgo.posefit.sender.CameraFacing
|
||||
import com.kimgo.posefit.sender.StreamOrientation
|
||||
import com.kimgo.posefit.sender.StreamResolution
|
||||
import timber.log.Timber
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
private var permissionResultHandler: ((Boolean) -> Unit)? = null
|
||||
|
||||
private val requestPermission =
|
||||
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { grants ->
|
||||
val cameraGranted = grants[Manifest.permission.CAMERA] == true
|
||||
if (cameraGranted) {
|
||||
val url = getSavedSignalingUrl()
|
||||
val cameraFacing = getSavedCameraFacing()
|
||||
val cameraName = getSavedCameraName()
|
||||
startWebRtc(url, cameraFacing, cameraName)
|
||||
startWebRtc(getSavedSettings())
|
||||
} else {
|
||||
Timber.w("Camera permission denied")
|
||||
}
|
||||
permissionResultHandler?.invoke(cameraGranted)
|
||||
permissionResultHandler = null
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
Timber.d("MainActivity onCreate")
|
||||
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
|
||||
applyStreamOrientation(getSavedOrientation())
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
|
||||
setContent {
|
||||
MaterialTheme {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
) {
|
||||
ConfigScreen()
|
||||
PosefitApp()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ConfigScreen() {
|
||||
var url by remember { mutableStateOf(getSavedSignalingUrl()) }
|
||||
var cameraFacing by remember { mutableStateOf(getSavedCameraFacing()) }
|
||||
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 ?: "自动选择后置最广角"
|
||||
}
|
||||
private fun PosefitApp() {
|
||||
var screen by remember { mutableStateOf(AppScreen.HOME) }
|
||||
var isStreaming by remember { mutableStateOf(PosefitStreamingService.isRunning) }
|
||||
var settings by remember { mutableStateOf(getSavedSettings()) }
|
||||
|
||||
Row(
|
||||
when (screen) {
|
||||
AppScreen.HOME -> HomeScreen(
|
||||
isStreaming = isStreaming,
|
||||
settings = settings,
|
||||
onToggleStreaming = {
|
||||
if (isStreaming) {
|
||||
stopWebRtc()
|
||||
isStreaming = false
|
||||
} else {
|
||||
saveSettings(settings)
|
||||
applyStreamOrientation(settings.orientation)
|
||||
permissionResultHandler = { granted ->
|
||||
isStreaming = granted
|
||||
}
|
||||
requestPermission.launch(requiredPermissions())
|
||||
}
|
||||
},
|
||||
onOpenSettings = { screen = AppScreen.SETTINGS },
|
||||
onRotate = {
|
||||
val updated = settings.copy(orientation = settings.orientation.next())
|
||||
settings = updated
|
||||
saveSettings(updated)
|
||||
applyStreamOrientation(updated.orientation)
|
||||
},
|
||||
)
|
||||
|
||||
AppScreen.SETTINGS -> SettingsScreen(
|
||||
initialSettings = settings,
|
||||
isStreaming = isStreaming,
|
||||
onSave = { updated ->
|
||||
settings = updated
|
||||
saveSettings(updated)
|
||||
screen = AppScreen.HOME
|
||||
},
|
||||
onBack = { screen = AppScreen.HOME },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HomeScreen(
|
||||
isStreaming: Boolean,
|
||||
settings: StreamSettings,
|
||||
onToggleStreaming: () -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
onRotate: () -> Unit,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 32.dp, vertical = 20.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(28.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
.padding(24.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(0.9f)
|
||||
.fillMaxHeight(),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.Start
|
||||
Row(
|
||||
modifier = Modifier.align(Alignment.TopEnd),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "PoseFit",
|
||||
style = MaterialTheme.typography.headlineLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = if (isStreaming) "正在横屏 1080x720 推流" else "横屏 1080x720 推流未开始",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = if (isStreaming) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
)
|
||||
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)
|
||||
TextButton(onClick = onRotate) {
|
||||
Text("旋转")
|
||||
}
|
||||
TextButton(
|
||||
onClick = onOpenSettings,
|
||||
enabled = !isStreaming,
|
||||
) {
|
||||
Text(if (isStreaming) "停止推流" else "开始推流")
|
||||
Text("设置")
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1.4f)
|
||||
.fillMaxHeight(),
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 24.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.Start
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
TextField(
|
||||
value = url,
|
||||
onValueChange = { url = it },
|
||||
label = { Text("服务器地址 (ws://...)") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
enabled = !isStreaming
|
||||
Text(
|
||||
text = "PoseFit",
|
||||
style = MaterialTheme.typography.headlineLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
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)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(
|
||||
text = if (isStreaming) {
|
||||
"正在推流:${settings.resolution.label} ${settings.orientation.label}"
|
||||
} else {
|
||||
"未推流:${settings.resolution.label} ${settings.orientation.label}"
|
||||
},
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = if (isStreaming) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Button(
|
||||
onClick = onToggleStreaming,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.72f)
|
||||
.height(56.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
|
||||
}
|
||||
)
|
||||
Text(if (isStreaming) "停止推流" else "开始推流")
|
||||
}
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsScreen(
|
||||
initialSettings: StreamSettings,
|
||||
isStreaming: Boolean,
|
||||
onSave: (StreamSettings) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
var url by remember { mutableStateOf(initialSettings.signalingUrl) }
|
||||
var cameraFacing by remember { mutableStateOf(initialSettings.cameraFacing) }
|
||||
var selectedCameraName by remember { mutableStateOf(initialSettings.cameraName) }
|
||||
var selectedResolution by remember { mutableStateOf(initialSettings.resolution) }
|
||||
var cameraMenuExpanded by remember { mutableStateOf(false) }
|
||||
var resolutionMenuExpanded by remember { mutableStateOf(false) }
|
||||
val cameraOptions = remember { runCatching { CameraCatalog.listOptions(this) }.getOrDefault(emptyList()) }
|
||||
val selectedCameraOption = remember(selectedCameraName, cameraOptions) {
|
||||
resolveSelectedCameraOption(cameraOptions, selectedCameraName)
|
||||
}
|
||||
val availableResolutions = remember(selectedCameraOption) {
|
||||
selectedCameraOption?.supportedResolutions
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?: StreamResolution.PRESETS
|
||||
}
|
||||
val effectiveResolution = remember(availableResolutions, selectedResolution) {
|
||||
chooseResolution(availableResolutions, selectedResolution)
|
||||
}
|
||||
val selectedCameraLabel = remember(selectedCameraName, selectedCameraOption) {
|
||||
if (selectedCameraName.isNullOrBlank()) {
|
||||
"自动选择后置最广角"
|
||||
} else {
|
||||
selectedCameraOption?.label ?: "自动选择后置最广角"
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 28.dp, vertical = 20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = "设置",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
TextButton(onClick = onBack) {
|
||||
Text("返回")
|
||||
}
|
||||
}
|
||||
|
||||
TextField(
|
||||
value = url,
|
||||
onValueChange = { url = it },
|
||||
label = { Text("WS 服务器地址") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
enabled = !isStreaming,
|
||||
)
|
||||
|
||||
SettingGroup(title = "摄像头") {
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
OutlinedButton(
|
||||
onClick = { cameraMenuExpanded = true },
|
||||
enabled = !isStreaming,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(48.dp)
|
||||
.height(48.dp),
|
||||
) {
|
||||
Text(selectedCameraLabel, maxLines = 1)
|
||||
}
|
||||
@@ -185,8 +282,15 @@ class MainActivity : ComponentActivity() {
|
||||
onClick = {
|
||||
cameraFacing = CameraFacing.BACK
|
||||
selectedCameraName = null
|
||||
selectedResolution = chooseResolution(
|
||||
resolveSelectedCameraOption(cameraOptions, null)
|
||||
?.supportedResolutions
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?: StreamResolution.PRESETS,
|
||||
selectedResolution,
|
||||
)
|
||||
cameraMenuExpanded = false
|
||||
}
|
||||
},
|
||||
)
|
||||
cameraOptions.forEach { option ->
|
||||
DropdownMenuItem(
|
||||
@@ -194,85 +298,194 @@ class MainActivity : ComponentActivity() {
|
||||
onClick = {
|
||||
cameraFacing = option.facing
|
||||
selectedCameraName = option.name
|
||||
selectedResolution = chooseResolution(
|
||||
option.supportedResolutions.takeIf { it.isNotEmpty() }
|
||||
?: StreamResolution.PRESETS,
|
||||
selectedResolution,
|
||||
)
|
||||
cameraMenuExpanded = false
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingGroup(title = "推流分辨率") {
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
OutlinedButton(
|
||||
onClick = { resolutionMenuExpanded = true },
|
||||
enabled = !isStreaming,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(48.dp),
|
||||
) {
|
||||
Text(effectiveResolution.label, maxLines = 1)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = resolutionMenuExpanded,
|
||||
onDismissRequest = { resolutionMenuExpanded = false },
|
||||
) {
|
||||
availableResolutions.forEach { resolution ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(resolution.label) },
|
||||
onClick = {
|
||||
selectedResolution = resolution
|
||||
resolutionMenuExpanded = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
onSave(
|
||||
StreamSettings(
|
||||
signalingUrl = url.trim(),
|
||||
cameraFacing = cameraFacing,
|
||||
cameraName = selectedCameraName,
|
||||
resolution = effectiveResolution,
|
||||
orientation = initialSettings.orientation,
|
||||
)
|
||||
)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(52.dp),
|
||||
) {
|
||||
Text("保存设置")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CameraFacingButton(
|
||||
text: String,
|
||||
selected: Boolean,
|
||||
enabled: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit
|
||||
private fun SettingGroup(
|
||||
title: String,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
val colors = if (selected) {
|
||||
ButtonDefaults.buttonColors()
|
||||
} else {
|
||||
ButtonDefaults.outlinedButtonColors()
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
if (selected) {
|
||||
Button(
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
modifier = modifier
|
||||
) {
|
||||
Text(text)
|
||||
}
|
||||
} else {
|
||||
OutlinedButton(
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
modifier = modifier,
|
||||
colors = colors
|
||||
) {
|
||||
Text(text)
|
||||
}
|
||||
}
|
||||
private fun getSavedSettings(): StreamSettings {
|
||||
return StreamSettings(
|
||||
signalingUrl = getSavedSignalingUrl(),
|
||||
cameraFacing = getSavedCameraFacing(),
|
||||
cameraName = getSavedCameraName(),
|
||||
resolution = getSavedResolution(),
|
||||
orientation = getSavedOrientation(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun saveSettings(settings: StreamSettings) {
|
||||
getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putString(KEY_SIGNALING_URL, settings.signalingUrl)
|
||||
.putString(KEY_CAMERA_FACING, settings.cameraFacing.name)
|
||||
.putString(KEY_CAMERA_NAME, settings.cameraName)
|
||||
.putInt(KEY_VIDEO_WIDTH, settings.resolution.width)
|
||||
.putInt(KEY_VIDEO_HEIGHT, settings.resolution.height)
|
||||
.putString(KEY_STREAM_ORIENTATION, settings.orientation.name)
|
||||
.apply()
|
||||
}
|
||||
|
||||
private fun getSavedSignalingUrl(): String {
|
||||
val prefs = getSharedPreferences("posefit_prefs", Context.MODE_PRIVATE)
|
||||
return prefs.getString("signaling_url", "ws://192.168.2.6:8765") ?: "ws://192.168.2.6:8765"
|
||||
}
|
||||
|
||||
private fun saveSignalingUrl(url: String) {
|
||||
val prefs = getSharedPreferences("posefit_prefs", Context.MODE_PRIVATE)
|
||||
prefs.edit().putString("signaling_url", url).apply()
|
||||
val prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
return prefs.getString(KEY_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)
|
||||
val prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
val value = prefs.getString(KEY_CAMERA_FACING, CameraFacing.BACK.name)
|
||||
return runCatching { CameraFacing.valueOf(value ?: CameraFacing.BACK.name) }
|
||||
.getOrDefault(CameraFacing.BACK)
|
||||
}
|
||||
|
||||
private fun saveCameraFacing(cameraFacing: CameraFacing) {
|
||||
val prefs = getSharedPreferences("posefit_prefs", Context.MODE_PRIVATE)
|
||||
prefs.edit().putString("camera_facing", cameraFacing.name).apply()
|
||||
}
|
||||
|
||||
private fun getSavedCameraName(): String? {
|
||||
val prefs = getSharedPreferences("posefit_prefs", Context.MODE_PRIVATE)
|
||||
return prefs.getString("camera_name", null)?.takeIf { it.isNotBlank() }
|
||||
val prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
return prefs.getString(KEY_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 getSavedResolution(): StreamResolution {
|
||||
val prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
return StreamResolution.from(
|
||||
prefs.getInt(KEY_VIDEO_WIDTH, StreamResolution.DEFAULT.width),
|
||||
prefs.getInt(KEY_VIDEO_HEIGHT, StreamResolution.DEFAULT.height),
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
private fun getSavedOrientation(): StreamOrientation {
|
||||
val prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
val value = prefs.getString(KEY_STREAM_ORIENTATION, StreamOrientation.DEFAULT.name)
|
||||
return runCatching { StreamOrientation.valueOf(value ?: StreamOrientation.DEFAULT.name) }
|
||||
.getOrDefault(StreamOrientation.DEFAULT)
|
||||
}
|
||||
|
||||
private fun applyStreamOrientation(orientation: StreamOrientation) {
|
||||
requestedOrientation = orientation.activityOrientation
|
||||
}
|
||||
|
||||
private fun resolveSelectedCameraOption(
|
||||
cameraOptions: List<com.kimgo.posefit.sender.CameraOption>,
|
||||
selectedCameraName: String?,
|
||||
): com.kimgo.posefit.sender.CameraOption? {
|
||||
if (!selectedCameraName.isNullOrBlank()) {
|
||||
return cameraOptions.firstOrNull { it.name == selectedCameraName }
|
||||
}
|
||||
|
||||
return cameraOptions
|
||||
.filter { it.facing == CameraFacing.BACK }
|
||||
.minByOrNull { it.minFocalLength ?: Float.MAX_VALUE }
|
||||
?: cameraOptions.firstOrNull()
|
||||
}
|
||||
|
||||
private fun chooseResolution(
|
||||
availableResolutions: List<StreamResolution>,
|
||||
currentResolution: StreamResolution,
|
||||
): StreamResolution {
|
||||
if (availableResolutions.isEmpty()) {
|
||||
return StreamResolution.DEFAULT
|
||||
}
|
||||
if (currentResolution in availableResolutions) {
|
||||
return currentResolution
|
||||
}
|
||||
if (StreamResolution.DEFAULT in availableResolutions) {
|
||||
return StreamResolution.DEFAULT
|
||||
}
|
||||
return availableResolutions.minByOrNull {
|
||||
kotlin.math.abs((it.width * it.height) - (StreamResolution.DEFAULT.width * StreamResolution.DEFAULT.height))
|
||||
} ?: availableResolutions.first()
|
||||
}
|
||||
|
||||
private fun startWebRtc(settings: StreamSettings) {
|
||||
Timber.i(
|
||||
"startWebRtc: %s, cameraFacing=%s, cameraName=%s, resolution=%s, orientation=%s",
|
||||
settings.signalingUrl,
|
||||
settings.cameraFacing,
|
||||
settings.cameraName,
|
||||
settings.resolution.label,
|
||||
settings.orientation,
|
||||
)
|
||||
applyStreamOrientation(settings.orientation)
|
||||
val intent = PosefitStreamingService.startIntent(
|
||||
context = this,
|
||||
signalingUrl = settings.signalingUrl,
|
||||
cameraFacing = settings.cameraFacing,
|
||||
preferredCameraName = settings.cameraName,
|
||||
resolution = settings.resolution,
|
||||
orientation = settings.orientation,
|
||||
)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
startForegroundService(intent)
|
||||
} else {
|
||||
@@ -298,4 +511,21 @@ class MainActivity : ComponentActivity() {
|
||||
super.onDestroy()
|
||||
Timber.d("MainActivity onDestroy")
|
||||
}
|
||||
|
||||
private enum class AppScreen {
|
||||
HOME,
|
||||
SETTINGS,
|
||||
}
|
||||
|
||||
private data class StreamSettings(
|
||||
val signalingUrl: String,
|
||||
val cameraFacing: CameraFacing,
|
||||
val cameraName: String?,
|
||||
val resolution: StreamResolution,
|
||||
val orientation: StreamOrientation,
|
||||
)
|
||||
|
||||
private companion object {
|
||||
private const val DEFAULT_SIGNALING_URL = "ws://192.168.2.6:8765"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import com.kimgo.posefit.sender.CameraFacing
|
||||
import com.kimgo.posefit.sender.StreamOrientation
|
||||
import com.kimgo.posefit.sender.StreamResolution
|
||||
import com.kimgo.posefit.sender.WebRtcSenderClient
|
||||
import timber.log.Timber
|
||||
|
||||
@@ -29,23 +31,31 @@ class PosefitStreamingService : Service() {
|
||||
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)
|
||||
ACTION_START -> {
|
||||
val settings = StreamSettings(
|
||||
signalingUrl = intent.getStringExtra(EXTRA_SIGNALING_URL) ?: DEFAULT_SIGNALING_URL,
|
||||
cameraFacing = intent.getStringExtra(EXTRA_CAMERA_FACING)
|
||||
?.let { runCatching { CameraFacing.valueOf(it) }.getOrNull() }
|
||||
?: CameraFacing.BACK,
|
||||
preferredCameraName = intent.getStringExtra(EXTRA_CAMERA_NAME),
|
||||
resolution = StreamResolution.from(
|
||||
intent.getIntExtra(EXTRA_VIDEO_WIDTH, StreamResolution.DEFAULT.width),
|
||||
intent.getIntExtra(EXTRA_VIDEO_HEIGHT, StreamResolution.DEFAULT.height),
|
||||
),
|
||||
orientation = intent.getStringExtra(EXTRA_STREAM_ORIENTATION)
|
||||
?.let { runCatching { StreamOrientation.valueOf(it) }.getOrNull() }
|
||||
?: StreamOrientation.DEFAULT,
|
||||
)
|
||||
startForegroundNotification(settings)
|
||||
startStreaming(settings)
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
else -> {
|
||||
val signalingUrl = getSavedSignalingUrl()
|
||||
val cameraFacing = getSavedCameraFacing()
|
||||
val preferredCameraName = getSavedCameraName()
|
||||
startForegroundNotification(signalingUrl, cameraFacing, preferredCameraName)
|
||||
startStreaming(signalingUrl, cameraFacing, preferredCameraName)
|
||||
val settings = getSavedSettings()
|
||||
startForegroundNotification(settings)
|
||||
startStreaming(settings)
|
||||
return START_STICKY
|
||||
}
|
||||
}
|
||||
@@ -58,19 +68,24 @@ class PosefitStreamingService : Service() {
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun startStreaming(signalingUrl: String, cameraFacing: CameraFacing, preferredCameraName: String?) {
|
||||
private fun startStreaming(settings: StreamSettings) {
|
||||
Timber.i(
|
||||
"Streaming service start: %s, cameraFacing=%s, preferredCameraName=%s, orientation=landscape",
|
||||
signalingUrl,
|
||||
cameraFacing,
|
||||
preferredCameraName,
|
||||
"Streaming service start: %s, cameraFacing=%s, preferredCameraName=%s, resolution=%s, orientation=%s",
|
||||
settings.signalingUrl,
|
||||
settings.cameraFacing,
|
||||
settings.preferredCameraName,
|
||||
settings.resolution.label,
|
||||
settings.orientation,
|
||||
)
|
||||
webRtcClient?.release()
|
||||
webRtcClient = WebRtcSenderClient(
|
||||
context = applicationContext,
|
||||
signalingUrl = signalingUrl,
|
||||
cameraFacing = cameraFacing,
|
||||
preferredCameraName = preferredCameraName,
|
||||
signalingUrl = settings.signalingUrl,
|
||||
cameraFacing = settings.cameraFacing,
|
||||
preferredCameraName = settings.preferredCameraName,
|
||||
videoWidth = settings.resolution.width,
|
||||
videoHeight = settings.resolution.height,
|
||||
streamOrientation = settings.orientation,
|
||||
).also { it.start() }
|
||||
isRunning = true
|
||||
}
|
||||
@@ -82,8 +97,8 @@ class PosefitStreamingService : Service() {
|
||||
isRunning = false
|
||||
}
|
||||
|
||||
private fun startForegroundNotification(signalingUrl: String, cameraFacing: CameraFacing, preferredCameraName: String?) {
|
||||
val notification = buildNotification(signalingUrl, cameraFacing, preferredCameraName)
|
||||
private fun startForegroundNotification(settings: StreamSettings) {
|
||||
val notification = buildNotification(settings)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_CAMERA)
|
||||
} else {
|
||||
@@ -91,24 +106,27 @@ class PosefitStreamingService : Service() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildNotification(signalingUrl: String, cameraFacing: CameraFacing, preferredCameraName: String?): Notification {
|
||||
val cameraText = when (cameraFacing) {
|
||||
private fun buildNotification(settings: StreamSettings): Notification {
|
||||
val cameraText = when (settings.cameraFacing) {
|
||||
CameraFacing.FRONT -> "前置摄像头"
|
||||
CameraFacing.BACK -> "后置摄像头"
|
||||
}
|
||||
val cameraModeText = preferredCameraName?.let { "$cameraText $it" } ?: "$cameraText 自动最广角"
|
||||
val cameraModeText = settings.preferredCameraName
|
||||
?.let { "$cameraText $it" }
|
||||
?: "$cameraText 自动最广角"
|
||||
val streamText = "${settings.resolution.label} ${settings.orientation.label}"
|
||||
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
Notification.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle("PoseFit 正在推流")
|
||||
.setContentText("$cameraModeText 横屏720p -> $signalingUrl")
|
||||
.setContentText("$cameraModeText $streamText -> ${settings.signalingUrl}")
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setOngoing(true)
|
||||
.build()
|
||||
} else {
|
||||
Notification.Builder(this)
|
||||
.setContentTitle("PoseFit 正在推流")
|
||||
.setContentText("$cameraModeText 横屏720p -> $signalingUrl")
|
||||
.setContentText("$cameraModeText $streamText -> ${settings.signalingUrl}")
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setOngoing(true)
|
||||
.build()
|
||||
@@ -123,28 +141,38 @@ class PosefitStreamingService : Service() {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"PoseFit 推流",
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
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 getSavedSettings(): StreamSettings {
|
||||
val prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
val cameraFacingValue = prefs.getString(KEY_CAMERA_FACING, CameraFacing.BACK.name)
|
||||
val orientationValue = prefs.getString(KEY_STREAM_ORIENTATION, StreamOrientation.DEFAULT.name)
|
||||
|
||||
return StreamSettings(
|
||||
signalingUrl = prefs.getString(KEY_SIGNALING_URL, DEFAULT_SIGNALING_URL) ?: DEFAULT_SIGNALING_URL,
|
||||
cameraFacing = runCatching { CameraFacing.valueOf(cameraFacingValue ?: CameraFacing.BACK.name) }
|
||||
.getOrDefault(CameraFacing.BACK),
|
||||
preferredCameraName = prefs.getString(KEY_CAMERA_NAME, null)?.takeIf { it.isNotBlank() },
|
||||
resolution = StreamResolution.from(
|
||||
prefs.getInt(KEY_VIDEO_WIDTH, StreamResolution.DEFAULT.width),
|
||||
prefs.getInt(KEY_VIDEO_HEIGHT, StreamResolution.DEFAULT.height),
|
||||
),
|
||||
orientation = runCatching { StreamOrientation.valueOf(orientationValue ?: StreamOrientation.DEFAULT.name) }
|
||||
.getOrDefault(StreamOrientation.DEFAULT),
|
||||
)
|
||||
}
|
||||
|
||||
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() }
|
||||
}
|
||||
private data class StreamSettings(
|
||||
val signalingUrl: String,
|
||||
val cameraFacing: CameraFacing,
|
||||
val preferredCameraName: String?,
|
||||
val resolution: StreamResolution,
|
||||
val orientation: StreamOrientation,
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val CHANNEL_ID = "posefit_streaming"
|
||||
@@ -154,8 +182,19 @@ class PosefitStreamingService : Service() {
|
||||
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 EXTRA_VIDEO_WIDTH = "extra_video_width"
|
||||
private const val EXTRA_VIDEO_HEIGHT = "extra_video_height"
|
||||
private const val EXTRA_STREAM_ORIENTATION = "extra_stream_orientation"
|
||||
private const val DEFAULT_SIGNALING_URL = "ws://192.168.2.6:8765"
|
||||
|
||||
const val PREFS_NAME = "posefit_prefs"
|
||||
const val KEY_SIGNALING_URL = "signaling_url"
|
||||
const val KEY_CAMERA_FACING = "camera_facing"
|
||||
const val KEY_CAMERA_NAME = "camera_name"
|
||||
const val KEY_VIDEO_WIDTH = "video_width"
|
||||
const val KEY_VIDEO_HEIGHT = "video_height"
|
||||
const val KEY_STREAM_ORIENTATION = "stream_orientation"
|
||||
|
||||
@Volatile
|
||||
var isRunning: Boolean = false
|
||||
private set
|
||||
@@ -164,13 +203,18 @@ class PosefitStreamingService : Service() {
|
||||
context: Context,
|
||||
signalingUrl: String,
|
||||
cameraFacing: CameraFacing,
|
||||
preferredCameraName: String?
|
||||
preferredCameraName: String?,
|
||||
resolution: StreamResolution,
|
||||
orientation: StreamOrientation,
|
||||
): 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)
|
||||
putExtra(EXTRA_VIDEO_WIDTH, resolution.width)
|
||||
putExtra(EXTRA_VIDEO_HEIGHT, resolution.height)
|
||||
putExtra(EXTRA_STREAM_ORIENTATION, orientation.name)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,11 +22,17 @@ object CameraCatalog {
|
||||
?.toList()
|
||||
.orEmpty()
|
||||
}.getOrDefault(emptyList())
|
||||
val supportedResolutions = enumerator.getSupportedFormats(cameraName)
|
||||
?.map { StreamResolution(it.width, it.height) }
|
||||
?.distinct()
|
||||
?.sortedWith(compareBy<StreamResolution> { it.width * it.height }.thenBy { it.width })
|
||||
.orEmpty()
|
||||
|
||||
CameraOption(
|
||||
name = cameraName,
|
||||
facing = facing,
|
||||
focalLengths = focalLengths
|
||||
focalLengths = focalLengths,
|
||||
supportedResolutions = supportedResolutions,
|
||||
)
|
||||
}.sortedWith(
|
||||
compareBy<CameraOption> { it.facing != CameraFacing.BACK }
|
||||
|
||||
@@ -3,7 +3,8 @@ package com.kimgo.posefit.sender
|
||||
data class CameraOption(
|
||||
val name: String,
|
||||
val facing: CameraFacing,
|
||||
val focalLengths: List<Float>
|
||||
val focalLengths: List<Float>,
|
||||
val supportedResolutions: List<StreamResolution>,
|
||||
) {
|
||||
val minFocalLength: Float?
|
||||
get() = focalLengths.minOrNull()
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.kimgo.posefit.sender
|
||||
|
||||
import android.content.pm.ActivityInfo
|
||||
|
||||
enum class StreamOrientation(
|
||||
val label: String,
|
||||
val activityOrientation: Int,
|
||||
) {
|
||||
LANDSCAPE("横屏", ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE),
|
||||
REVERSE_LANDSCAPE("反向横屏", ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE),
|
||||
PORTRAIT("竖屏", ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);
|
||||
|
||||
fun next(): StreamOrientation {
|
||||
return when (this) {
|
||||
LANDSCAPE -> REVERSE_LANDSCAPE
|
||||
REVERSE_LANDSCAPE -> PORTRAIT
|
||||
PORTRAIT -> LANDSCAPE
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
val DEFAULT = LANDSCAPE
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.kimgo.posefit.sender
|
||||
|
||||
data class StreamResolution(
|
||||
val width: Int,
|
||||
val height: Int,
|
||||
) {
|
||||
val label: String
|
||||
get() = "${width}x$height"
|
||||
|
||||
companion object {
|
||||
val DEFAULT = StreamResolution(1280, 720)
|
||||
|
||||
val PRESETS = listOf(
|
||||
StreamResolution(640, 480),
|
||||
StreamResolution(960, 720),
|
||||
StreamResolution(1280, 720),
|
||||
StreamResolution(1280, 960),
|
||||
StreamResolution(1920, 1080),
|
||||
)
|
||||
|
||||
fun from(width: Int, height: Int): StreamResolution {
|
||||
return PRESETS.firstOrNull { it.width == width && it.height == height }
|
||||
?: StreamResolution(width, height)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,12 +10,13 @@ class WebRtcSenderClient(
|
||||
private val context: Context,
|
||||
private val signalingUrl: String,
|
||||
private val cameraFacing: CameraFacing = CameraFacing.BACK,
|
||||
private val preferredCameraName: String? = null
|
||||
private val preferredCameraName: String? = null,
|
||||
private val videoWidth: Int = StreamResolution.DEFAULT.width,
|
||||
private val videoHeight: Int = StreamResolution.DEFAULT.height,
|
||||
private val streamOrientation: StreamOrientation = StreamOrientation.DEFAULT,
|
||||
) {
|
||||
|
||||
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
|
||||
@@ -33,13 +34,18 @@ class WebRtcSenderClient(
|
||||
private var videoTrack: VideoTrack? = null
|
||||
private var videoSender: RtpSender? = null
|
||||
private var surfaceTextureHelper: SurfaceTextureHelper? = null
|
||||
private var capturedFrameCount = 0L
|
||||
private var lastFrameSignature: String? = null
|
||||
|
||||
fun start() {
|
||||
Timber.i(
|
||||
"WebRTC starting, signalingUrl=%s, cameraFacing=%s, preferredCameraName=%s",
|
||||
"WebRTC starting, signalingUrl=%s, cameraFacing=%s, preferredCameraName=%s, resolution=%dx%d, orientation=%s",
|
||||
signalingUrl,
|
||||
cameraFacing,
|
||||
preferredCameraName
|
||||
preferredCameraName,
|
||||
videoWidth,
|
||||
videoHeight,
|
||||
streamOrientation,
|
||||
)
|
||||
initPeerConnectionFactory()
|
||||
connectSignaling()
|
||||
@@ -155,14 +161,25 @@ class WebRtcSenderClient(
|
||||
)
|
||||
|
||||
videoSource = factory.createVideoSource(false)
|
||||
capturedFrameCount = 0L
|
||||
lastFrameSignature = null
|
||||
val capturerObserver = videoSource?.capturerObserver
|
||||
?.let { createLoggingCapturerObserver(it) }
|
||||
|
||||
videoCapturer?.initialize(
|
||||
surfaceTextureHelper,
|
||||
context,
|
||||
videoSource?.capturerObserver
|
||||
capturerObserver
|
||||
)
|
||||
|
||||
videoCapturer?.startCapture(VIDEO_WIDTH, VIDEO_HEIGHT, VIDEO_FPS)
|
||||
Timber.i(
|
||||
"Starting camera capture: camera=%s, requested=%dx%d@%dfps",
|
||||
cameraName,
|
||||
videoWidth,
|
||||
videoHeight,
|
||||
VIDEO_FPS,
|
||||
)
|
||||
videoCapturer?.startCapture(videoWidth, videoHeight, VIDEO_FPS)
|
||||
|
||||
videoTrack = factory.createVideoTrack("video_track", videoSource)
|
||||
|
||||
@@ -172,7 +189,7 @@ class WebRtcSenderClient(
|
||||
)
|
||||
videoSender?.let { configureVideoSender(it) }
|
||||
|
||||
Timber.d("Camera started: %s, resolution=%dx%d@%dfps", cameraName, VIDEO_WIDTH, VIDEO_HEIGHT, VIDEO_FPS)
|
||||
Timber.d("Camera started: %s, resolution=%dx%d@%dfps", cameraName, videoWidth, videoHeight, VIDEO_FPS)
|
||||
}
|
||||
|
||||
private fun configureVideoSender(sender: RtpSender) {
|
||||
@@ -194,8 +211,8 @@ class WebRtcSenderClient(
|
||||
Timber.d(
|
||||
"Video sender configured: applied=%s, resolution=%dx%d@%dfps, bitrate=%d-%d",
|
||||
applied,
|
||||
VIDEO_WIDTH,
|
||||
VIDEO_HEIGHT,
|
||||
videoWidth,
|
||||
videoHeight,
|
||||
VIDEO_FPS,
|
||||
VIDEO_MIN_BITRATE_BPS,
|
||||
VIDEO_MAX_BITRATE_BPS,
|
||||
@@ -237,14 +254,73 @@ class WebRtcSenderClient(
|
||||
|
||||
private fun logAvailableCameras(enumerator: Camera2Enumerator) {
|
||||
val knownOptions = CameraCatalog.listOptions(context).associateBy { it.name }
|
||||
Timber.i("Available WebRTC cameras: count=%d", enumerator.deviceNames.size)
|
||||
enumerator.deviceNames.forEach { cameraName ->
|
||||
val option = knownOptions[cameraName]
|
||||
val formats = enumerator.getSupportedFormats(cameraName) ?: emptyList()
|
||||
Timber.i(
|
||||
"Camera available: name=%s, facing=%s, focalLengths=%s",
|
||||
"Camera available: name=%s, facing=%s, focalLengths=%s, supportedFormats=%d",
|
||||
cameraName,
|
||||
option?.facing ?: "unknown",
|
||||
option?.focalLengths,
|
||||
formats.size,
|
||||
)
|
||||
|
||||
formats
|
||||
.sortedWith(
|
||||
compareBy<CameraEnumerationAndroid.CaptureFormat> { it.width * it.height }
|
||||
.thenBy { it.framerate.max }
|
||||
)
|
||||
.forEach { format ->
|
||||
Timber.i(
|
||||
"Camera format: name=%s, size=%dx%d, fps=%.1f-%.1f",
|
||||
cameraName,
|
||||
format.width,
|
||||
format.height,
|
||||
format.framerate.min / 1000.0,
|
||||
format.framerate.max / 1000.0,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createLoggingCapturerObserver(delegate: CapturerObserver): CapturerObserver {
|
||||
return object : CapturerObserver {
|
||||
override fun onCapturerStarted(success: Boolean) {
|
||||
Timber.i("Camera capturer started: success=%s", success)
|
||||
delegate.onCapturerStarted(success)
|
||||
}
|
||||
|
||||
override fun onCapturerStopped() {
|
||||
Timber.i("Camera capturer stopped")
|
||||
delegate.onCapturerStopped()
|
||||
}
|
||||
|
||||
override fun onFrameCaptured(frame: VideoFrame) {
|
||||
capturedFrameCount += 1
|
||||
|
||||
val buffer = frame.buffer
|
||||
val signature = "${buffer.width}x${buffer.height}, rotation=${frame.rotation}"
|
||||
if (
|
||||
signature != lastFrameSignature ||
|
||||
capturedFrameCount == 1L ||
|
||||
capturedFrameCount % 100L == 0L
|
||||
) {
|
||||
Timber.i(
|
||||
"Camera frame captured: frame=%d, buffer=%dx%d, rotated=%dx%d, rotation=%d, timestampNs=%d",
|
||||
capturedFrameCount,
|
||||
buffer.width,
|
||||
buffer.height,
|
||||
frame.rotatedWidth,
|
||||
frame.rotatedHeight,
|
||||
frame.rotation,
|
||||
frame.timestampNs,
|
||||
)
|
||||
lastFrameSignature = signature
|
||||
}
|
||||
|
||||
delegate.onFrameCaptured(frame)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user