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
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)
}
}
}