feat(app): 添加摄像头配置和视频流设置功能

- 在 CameraOption 中新增 supportedResolutions 属性以支持分辨率选择
- 实现 StreamOrientation 和 StreamResolution 数据类用于视频方向和分辨率管理
- 重构 MainActivity 为多页面应用,添加设置页面支持摄像头、分辨率、方向配置
- 集成下拉菜单选择摄像头和视频分辨率功能
- 更新 PosefitStreamingService 支持视频分辨率和方向参数传递
- 移除 AndroidManifest 中 MainActivity 的屏幕方向锁定设置
- 添加详细的视频捕获帧日志记录和可用摄像头格式输出
- 优化视频流启动流程,支持多种分辨率和方向设置
This commit is contained in:
2026-06-10 11:24:47 +08:00
parent 1d9c5f9f20
commit 8c8239502e
8 changed files with 624 additions and 218 deletions
+1 -2
View File
@@ -36,8 +36,7 @@
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true">
android:screenOrientation="landscape">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
@@ -2,177 +2,274 @@ package com.kimgo.posefit
import android.Manifest import android.Manifest
import android.content.Context import android.content.Context
import android.content.Intent
import android.content.pm.ActivityInfo
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.view.WindowManager import android.view.WindowManager
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.* import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.* 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp 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.CameraCatalog
import com.kimgo.posefit.sender.CameraFacing import com.kimgo.posefit.sender.CameraFacing
import com.kimgo.posefit.sender.StreamOrientation
import com.kimgo.posefit.sender.StreamResolution
import timber.log.Timber import timber.log.Timber
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private var permissionResultHandler: ((Boolean) -> Unit)? = null
private val requestPermission = private val requestPermission =
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { grants -> registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { grants ->
val cameraGranted = grants[Manifest.permission.CAMERA] == true val cameraGranted = grants[Manifest.permission.CAMERA] == true
if (cameraGranted) { if (cameraGranted) {
val url = getSavedSignalingUrl() startWebRtc(getSavedSettings())
val cameraFacing = getSavedCameraFacing()
val cameraName = getSavedCameraName()
startWebRtc(url, cameraFacing, cameraName)
} else { } else {
Timber.w("Camera permission denied") Timber.w("Camera permission denied")
} }
permissionResultHandler?.invoke(cameraGranted)
permissionResultHandler = null
} }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
Timber.d("MainActivity onCreate") Timber.d("MainActivity onCreate")
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE applyStreamOrientation(getSavedOrientation())
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
setContent { setContent {
MaterialTheme { MaterialTheme {
Surface( Surface(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background color = MaterialTheme.colorScheme.background,
) { ) {
ConfigScreen() PosefitApp()
} }
} }
} }
} }
@Composable @Composable
fun ConfigScreen() { private fun PosefitApp() {
var url by remember { mutableStateOf(getSavedSignalingUrl()) } var screen by remember { mutableStateOf(AppScreen.HOME) }
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 ?: "自动选择后置最广角"
}
var isStreaming by remember { mutableStateOf(PosefitStreamingService.isRunning) } 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 modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(horizontal = 32.dp, vertical = 20.dp), .padding(24.dp),
horizontalArrangement = Arrangement.spacedBy(28.dp),
verticalAlignment = Alignment.CenterVertically
) { ) {
Row(
modifier = Modifier.align(Alignment.TopEnd),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
TextButton(onClick = onRotate) {
Text("旋转")
}
TextButton(
onClick = onOpenSettings,
enabled = !isStreaming,
) {
Text("设置")
}
}
Column( Column(
modifier = Modifier modifier = Modifier
.weight(0.9f) .fillMaxSize()
.fillMaxHeight(), .padding(horizontal = 24.dp),
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.Start horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Text( Text(
text = "PoseFit", text = "PoseFit",
style = MaterialTheme.typography.headlineLarge, style = MaterialTheme.typography.headlineLarge,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.primary,
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(12.dp))
Text( Text(
text = if (isStreaming) "正在横屏 1080x720 推流" else "横屏 1080x720 推流未开始", text = if (isStreaming) {
"正在推流:${settings.resolution.label} ${settings.orientation.label}"
} else {
"未推流:${settings.resolution.label} ${settings.orientation.label}"
},
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = if (isStreaming) { color = if (isStreaming) {
MaterialTheme.colorScheme.primary MaterialTheme.colorScheme.primary
} else { } else {
MaterialTheme.colorScheme.onSurfaceVariant MaterialTheme.colorScheme.onSurfaceVariant
} },
) )
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
Button( Button(
onClick = { onClick = onToggleStreaming,
if (isStreaming) {
stopWebRtc()
isStreaming = false
} else {
saveSignalingUrl(url)
saveCameraFacing(cameraFacing)
saveCameraName(selectedCameraName)
requestPermission.launch(requiredPermissions())
isStreaming = true
}
},
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth(0.72f)
.height(56.dp) .height(56.dp),
) { ) {
Text(if (isStreaming) "停止推流" else "开始推流") Text(if (isStreaming) "停止推流" else "开始推流")
} }
} }
}
}
@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( Column(
modifier = Modifier modifier = Modifier
.weight(1.4f) .fillMaxSize()
.fillMaxHeight(), .verticalScroll(rememberScrollState())
verticalArrangement = Arrangement.Center, .padding(horizontal = 28.dp, vertical = 20.dp),
horizontalAlignment = Alignment.Start 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( TextField(
value = url, value = url,
onValueChange = { url = it }, onValueChange = { url = it },
label = { Text("服务器地址 (ws://...)") }, label = { Text("WS 服务器地址") },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
singleLine = true, 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, enabled = !isStreaming,
modifier = Modifier
.weight(1f)
.height(48.dp),
onClick = {
cameraFacing = CameraFacing.FRONT
selectedCameraName = null
}
) )
CameraFacingButton(
text = "后置最广", SettingGroup(title = "摄像头") {
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()) { Box(modifier = Modifier.fillMaxWidth()) {
OutlinedButton( OutlinedButton(
onClick = { cameraMenuExpanded = true }, onClick = { cameraMenuExpanded = true },
enabled = !isStreaming, enabled = !isStreaming,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(48.dp) .height(48.dp),
) { ) {
Text(selectedCameraLabel, maxLines = 1) Text(selectedCameraLabel, maxLines = 1)
} }
@@ -185,8 +282,15 @@ class MainActivity : ComponentActivity() {
onClick = { onClick = {
cameraFacing = CameraFacing.BACK cameraFacing = CameraFacing.BACK
selectedCameraName = null selectedCameraName = null
selectedResolution = chooseResolution(
resolveSelectedCameraOption(cameraOptions, null)
?.supportedResolutions
?.takeIf { it.isNotEmpty() }
?: StreamResolution.PRESETS,
selectedResolution,
)
cameraMenuExpanded = false cameraMenuExpanded = false
} },
) )
cameraOptions.forEach { option -> cameraOptions.forEach { option ->
DropdownMenuItem( DropdownMenuItem(
@@ -194,85 +298,194 @@ class MainActivity : ComponentActivity() {
onClick = { onClick = {
cameraFacing = option.facing cameraFacing = option.facing
selectedCameraName = option.name selectedCameraName = option.name
selectedResolution = chooseResolution(
option.supportedResolutions.takeIf { it.isNotEmpty() }
?: StreamResolution.PRESETS,
selectedResolution,
)
cameraMenuExpanded = false 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 @Composable
private fun CameraFacingButton( private fun SettingGroup(
text: String, title: String,
selected: Boolean, content: @Composable ColumnScope.() -> Unit,
enabled: Boolean,
modifier: Modifier = Modifier,
onClick: () -> Unit
) { ) {
val colors = if (selected) { Column(
ButtonDefaults.buttonColors() modifier = Modifier.fillMaxWidth(),
} else { verticalArrangement = Arrangement.spacedBy(8.dp),
ButtonDefaults.outlinedButtonColors() ) {
Text(
text = title,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
content()
}
} }
if (selected) { private fun getSavedSettings(): StreamSettings {
Button( return StreamSettings(
onClick = onClick, signalingUrl = getSavedSignalingUrl(),
enabled = enabled, cameraFacing = getSavedCameraFacing(),
modifier = modifier cameraName = getSavedCameraName(),
) { resolution = getSavedResolution(),
Text(text) orientation = getSavedOrientation(),
} )
} else {
OutlinedButton(
onClick = onClick,
enabled = enabled,
modifier = modifier,
colors = colors
) {
Text(text)
}
} }
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 { private fun getSavedSignalingUrl(): String {
val prefs = getSharedPreferences("posefit_prefs", Context.MODE_PRIVATE) val prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
return prefs.getString("signaling_url", "ws://192.168.2.6:8765") ?: "ws://192.168.2.6:8765" return prefs.getString(KEY_SIGNALING_URL, DEFAULT_SIGNALING_URL) ?: DEFAULT_SIGNALING_URL
}
private fun saveSignalingUrl(url: String) {
val prefs = getSharedPreferences("posefit_prefs", Context.MODE_PRIVATE)
prefs.edit().putString("signaling_url", url).apply()
} }
private fun getSavedCameraFacing(): CameraFacing { private fun getSavedCameraFacing(): CameraFacing {
val prefs = getSharedPreferences("posefit_prefs", Context.MODE_PRIVATE) val prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
val value = prefs.getString("camera_facing", CameraFacing.BACK.name) val value = prefs.getString(KEY_CAMERA_FACING, CameraFacing.BACK.name)
return runCatching { CameraFacing.valueOf(value ?: CameraFacing.BACK.name) } return runCatching { CameraFacing.valueOf(value ?: CameraFacing.BACK.name) }
.getOrDefault(CameraFacing.BACK) .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? { private fun getSavedCameraName(): String? {
val prefs = getSharedPreferences("posefit_prefs", Context.MODE_PRIVATE) val prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
return prefs.getString("camera_name", null)?.takeIf { it.isNotBlank() } return prefs.getString(KEY_CAMERA_NAME, null)?.takeIf { it.isNotBlank() }
} }
private fun saveCameraName(cameraName: String?) { private fun getSavedResolution(): StreamResolution {
val prefs = getSharedPreferences("posefit_prefs", Context.MODE_PRIVATE) val prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit().putString("camera_name", cameraName).apply() 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?) { private fun getSavedOrientation(): StreamOrientation {
Timber.i("startWebRtc: %s, cameraFacing=%s, cameraName=%s", url, cameraFacing, cameraName) val prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
val intent = PosefitStreamingService.startIntent(this, url, cameraFacing, cameraName) 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) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(intent) startForegroundService(intent)
} else { } else {
@@ -298,4 +511,21 @@ class MainActivity : ComponentActivity() {
super.onDestroy() super.onDestroy()
Timber.d("MainActivity 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.Build
import android.os.IBinder import android.os.IBinder
import com.kimgo.posefit.sender.CameraFacing 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 com.kimgo.posefit.sender.WebRtcSenderClient
import timber.log.Timber import timber.log.Timber
@@ -29,23 +31,31 @@ class PosefitStreamingService : Service() {
stopSelf() stopSelf()
return START_NOT_STICKY 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) ACTION_START -> {
startStreaming(signalingUrl, cameraFacing, preferredCameraName) 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 return START_STICKY
} }
else -> { else -> {
val signalingUrl = getSavedSignalingUrl() val settings = getSavedSettings()
val cameraFacing = getSavedCameraFacing() startForegroundNotification(settings)
val preferredCameraName = getSavedCameraName() startStreaming(settings)
startForegroundNotification(signalingUrl, cameraFacing, preferredCameraName)
startStreaming(signalingUrl, cameraFacing, preferredCameraName)
return START_STICKY return START_STICKY
} }
} }
@@ -58,19 +68,24 @@ class PosefitStreamingService : Service() {
super.onDestroy() super.onDestroy()
} }
private fun startStreaming(signalingUrl: String, cameraFacing: CameraFacing, preferredCameraName: String?) { private fun startStreaming(settings: StreamSettings) {
Timber.i( Timber.i(
"Streaming service start: %s, cameraFacing=%s, preferredCameraName=%s, orientation=landscape", "Streaming service start: %s, cameraFacing=%s, preferredCameraName=%s, resolution=%s, orientation=%s",
signalingUrl, settings.signalingUrl,
cameraFacing, settings.cameraFacing,
preferredCameraName, settings.preferredCameraName,
settings.resolution.label,
settings.orientation,
) )
webRtcClient?.release() webRtcClient?.release()
webRtcClient = WebRtcSenderClient( webRtcClient = WebRtcSenderClient(
context = applicationContext, context = applicationContext,
signalingUrl = signalingUrl, signalingUrl = settings.signalingUrl,
cameraFacing = cameraFacing, cameraFacing = settings.cameraFacing,
preferredCameraName = preferredCameraName, preferredCameraName = settings.preferredCameraName,
videoWidth = settings.resolution.width,
videoHeight = settings.resolution.height,
streamOrientation = settings.orientation,
).also { it.start() } ).also { it.start() }
isRunning = true isRunning = true
} }
@@ -82,8 +97,8 @@ class PosefitStreamingService : Service() {
isRunning = false isRunning = false
} }
private fun startForegroundNotification(signalingUrl: String, cameraFacing: CameraFacing, preferredCameraName: String?) { private fun startForegroundNotification(settings: StreamSettings) {
val notification = buildNotification(signalingUrl, cameraFacing, preferredCameraName) val notification = buildNotification(settings)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_CAMERA) startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_CAMERA)
} else { } else {
@@ -91,24 +106,27 @@ class PosefitStreamingService : Service() {
} }
} }
private fun buildNotification(signalingUrl: String, cameraFacing: CameraFacing, preferredCameraName: String?): Notification { private fun buildNotification(settings: StreamSettings): Notification {
val cameraText = when (cameraFacing) { val cameraText = when (settings.cameraFacing) {
CameraFacing.FRONT -> "前置摄像头" CameraFacing.FRONT -> "前置摄像头"
CameraFacing.BACK -> "后置摄像头" 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) { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Notification.Builder(this, CHANNEL_ID) Notification.Builder(this, CHANNEL_ID)
.setContentTitle("PoseFit 正在推流") .setContentTitle("PoseFit 正在推流")
.setContentText("$cameraModeText 横屏720p -> $signalingUrl") .setContentText("$cameraModeText $streamText -> ${settings.signalingUrl}")
.setSmallIcon(R.mipmap.ic_launcher) .setSmallIcon(R.mipmap.ic_launcher)
.setOngoing(true) .setOngoing(true)
.build() .build()
} else { } else {
Notification.Builder(this) Notification.Builder(this)
.setContentTitle("PoseFit 正在推流") .setContentTitle("PoseFit 正在推流")
.setContentText("$cameraModeText 横屏720p -> $signalingUrl") .setContentText("$cameraModeText $streamText -> ${settings.signalingUrl}")
.setSmallIcon(R.mipmap.ic_launcher) .setSmallIcon(R.mipmap.ic_launcher)
.setOngoing(true) .setOngoing(true)
.build() .build()
@@ -123,28 +141,38 @@ class PosefitStreamingService : Service() {
val channel = NotificationChannel( val channel = NotificationChannel(
CHANNEL_ID, CHANNEL_ID,
"PoseFit 推流", "PoseFit 推流",
NotificationManager.IMPORTANCE_LOW NotificationManager.IMPORTANCE_LOW,
) )
val manager = getSystemService(NotificationManager::class.java) val manager = getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(channel) manager.createNotificationChannel(channel)
} }
private fun getSavedSignalingUrl(): String { private fun getSavedSettings(): StreamSettings {
val prefs = getSharedPreferences("posefit_prefs", Context.MODE_PRIVATE) val prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
return prefs.getString("signaling_url", DEFAULT_SIGNALING_URL) ?: DEFAULT_SIGNALING_URL 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 { private data class StreamSettings(
val prefs = getSharedPreferences("posefit_prefs", Context.MODE_PRIVATE) val signalingUrl: String,
val value = prefs.getString("camera_facing", CameraFacing.BACK.name) val cameraFacing: CameraFacing,
return runCatching { CameraFacing.valueOf(value ?: CameraFacing.BACK.name) } val preferredCameraName: String?,
.getOrDefault(CameraFacing.BACK) val resolution: StreamResolution,
} val orientation: StreamOrientation,
)
private fun getSavedCameraName(): String? {
val prefs = getSharedPreferences("posefit_prefs", Context.MODE_PRIVATE)
return prefs.getString("camera_name", null)?.takeIf { it.isNotBlank() }
}
companion object { companion object {
private const val CHANNEL_ID = "posefit_streaming" 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_SIGNALING_URL = "extra_signaling_url"
private const val EXTRA_CAMERA_FACING = "extra_camera_facing" private const val EXTRA_CAMERA_FACING = "extra_camera_facing"
private const val EXTRA_CAMERA_NAME = "extra_camera_name" 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" 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 @Volatile
var isRunning: Boolean = false var isRunning: Boolean = false
private set private set
@@ -164,13 +203,18 @@ class PosefitStreamingService : Service() {
context: Context, context: Context,
signalingUrl: String, signalingUrl: String,
cameraFacing: CameraFacing, cameraFacing: CameraFacing,
preferredCameraName: String? preferredCameraName: String?,
resolution: StreamResolution,
orientation: StreamOrientation,
): Intent { ): Intent {
return Intent(context, PosefitStreamingService::class.java).apply { return Intent(context, PosefitStreamingService::class.java).apply {
action = ACTION_START action = ACTION_START
putExtra(EXTRA_SIGNALING_URL, signalingUrl) putExtra(EXTRA_SIGNALING_URL, signalingUrl)
putExtra(EXTRA_CAMERA_FACING, cameraFacing.name) putExtra(EXTRA_CAMERA_FACING, cameraFacing.name)
putExtra(EXTRA_CAMERA_NAME, preferredCameraName) 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() ?.toList()
.orEmpty() .orEmpty()
}.getOrDefault(emptyList()) }.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( CameraOption(
name = cameraName, name = cameraName,
facing = facing, facing = facing,
focalLengths = focalLengths focalLengths = focalLengths,
supportedResolutions = supportedResolutions,
) )
}.sortedWith( }.sortedWith(
compareBy<CameraOption> { it.facing != CameraFacing.BACK } compareBy<CameraOption> { it.facing != CameraFacing.BACK }
@@ -3,7 +3,8 @@ package com.kimgo.posefit.sender
data class CameraOption( data class CameraOption(
val name: String, val name: String,
val facing: CameraFacing, val facing: CameraFacing,
val focalLengths: List<Float> val focalLengths: List<Float>,
val supportedResolutions: List<StreamResolution>,
) { ) {
val minFocalLength: Float? val minFocalLength: Float?
get() = focalLengths.minOrNull() 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 context: Context,
private val signalingUrl: String, private val signalingUrl: String,
private val cameraFacing: CameraFacing = CameraFacing.BACK, 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 { private companion object {
const val VIDEO_WIDTH = 1080
const val VIDEO_HEIGHT = 720
const val VIDEO_FPS = 30 const val VIDEO_FPS = 30
const val VIDEO_MIN_BITRATE_BPS = 3_000_000 const val VIDEO_MIN_BITRATE_BPS = 3_000_000
const val VIDEO_MAX_BITRATE_BPS = 6_000_000 const val VIDEO_MAX_BITRATE_BPS = 6_000_000
@@ -33,13 +34,18 @@ class WebRtcSenderClient(
private var videoTrack: VideoTrack? = null private var videoTrack: VideoTrack? = null
private var videoSender: RtpSender? = null private var videoSender: RtpSender? = null
private var surfaceTextureHelper: SurfaceTextureHelper? = null private var surfaceTextureHelper: SurfaceTextureHelper? = null
private var capturedFrameCount = 0L
private var lastFrameSignature: String? = null
fun start() { fun start() {
Timber.i( Timber.i(
"WebRTC starting, signalingUrl=%s, cameraFacing=%s, preferredCameraName=%s", "WebRTC starting, signalingUrl=%s, cameraFacing=%s, preferredCameraName=%s, resolution=%dx%d, orientation=%s",
signalingUrl, signalingUrl,
cameraFacing, cameraFacing,
preferredCameraName preferredCameraName,
videoWidth,
videoHeight,
streamOrientation,
) )
initPeerConnectionFactory() initPeerConnectionFactory()
connectSignaling() connectSignaling()
@@ -155,14 +161,25 @@ class WebRtcSenderClient(
) )
videoSource = factory.createVideoSource(false) videoSource = factory.createVideoSource(false)
capturedFrameCount = 0L
lastFrameSignature = null
val capturerObserver = videoSource?.capturerObserver
?.let { createLoggingCapturerObserver(it) }
videoCapturer?.initialize( videoCapturer?.initialize(
surfaceTextureHelper, surfaceTextureHelper,
context, 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) videoTrack = factory.createVideoTrack("video_track", videoSource)
@@ -172,7 +189,7 @@ class WebRtcSenderClient(
) )
videoSender?.let { configureVideoSender(it) } 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) { private fun configureVideoSender(sender: RtpSender) {
@@ -194,8 +211,8 @@ class WebRtcSenderClient(
Timber.d( Timber.d(
"Video sender configured: applied=%s, resolution=%dx%d@%dfps, bitrate=%d-%d", "Video sender configured: applied=%s, resolution=%dx%d@%dfps, bitrate=%d-%d",
applied, applied,
VIDEO_WIDTH, videoWidth,
VIDEO_HEIGHT, videoHeight,
VIDEO_FPS, VIDEO_FPS,
VIDEO_MIN_BITRATE_BPS, VIDEO_MIN_BITRATE_BPS,
VIDEO_MAX_BITRATE_BPS, VIDEO_MAX_BITRATE_BPS,
@@ -237,14 +254,73 @@ class WebRtcSenderClient(
private fun logAvailableCameras(enumerator: Camera2Enumerator) { private fun logAvailableCameras(enumerator: Camera2Enumerator) {
val knownOptions = CameraCatalog.listOptions(context).associateBy { it.name } val knownOptions = CameraCatalog.listOptions(context).associateBy { it.name }
Timber.i("Available WebRTC cameras: count=%d", enumerator.deviceNames.size)
enumerator.deviceNames.forEach { cameraName -> enumerator.deviceNames.forEach { cameraName ->
val option = knownOptions[cameraName] val option = knownOptions[cameraName]
val formats = enumerator.getSupportedFormats(cameraName) ?: emptyList()
Timber.i( Timber.i(
"Camera available: name=%s, facing=%s, focalLengths=%s", "Camera available: name=%s, facing=%s, focalLengths=%s, supportedFormats=%d",
cameraName, cameraName,
option?.facing ?: "unknown", option?.facing ?: "unknown",
option?.focalLengths, 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)
}
} }
} }