From 8c8239502eb05ba7c2abeb832dc83561606530be Mon Sep 17 00:00:00 2001 From: hjwang <2392948297@qq.com> Date: Wed, 10 Jun 2026 11:24:47 +0800 Subject: [PATCH] =?UTF-8?q?feat(app):=20=E6=B7=BB=E5=8A=A0=E6=91=84?= =?UTF-8?q?=E5=83=8F=E5=A4=B4=E9=85=8D=E7=BD=AE=E5=92=8C=E8=A7=86=E9=A2=91?= =?UTF-8?q?=E6=B5=81=E8=AE=BE=E7=BD=AE=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 CameraOption 中新增 supportedResolutions 属性以支持分辨率选择 - 实现 StreamOrientation 和 StreamResolution 数据类用于视频方向和分辨率管理 - 重构 MainActivity 为多页面应用,添加设置页面支持摄像头、分辨率、方向配置 - 集成下拉菜单选择摄像头和视频分辨率功能 - 更新 PosefitStreamingService 支持视频分辨率和方向参数传递 - 移除 AndroidManifest 中 MainActivity 的屏幕方向锁定设置 - 添加详细的视频捕获帧日志记录和可用摄像头格式输出 - 优化视频流启动流程,支持多种分辨率和方向设置 --- app/src/main/AndroidManifest.xml | 3 +- .../kotlin/com/kimgo/posefit/MainActivity.kt | 548 +++++++++++++----- .../kimgo/posefit/PosefitStreamingService.kt | 132 +++-- .../com/kimgo/posefit/sender/CameraCatalog.kt | 8 +- .../com/kimgo/posefit/sender/CameraOption.kt | 3 +- .../kimgo/posefit/sender/StreamOrientation.kt | 24 + .../kimgo/posefit/sender/StreamResolution.kt | 26 + .../posefit/sender/WebRtcSenderClient.kt | 98 +++- 8 files changed, 624 insertions(+), 218 deletions(-) create mode 100644 app/src/main/kotlin/com/kimgo/posefit/sender/StreamOrientation.kt create mode 100644 app/src/main/kotlin/com/kimgo/posefit/sender/StreamResolution.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 19fb11d..72efbb5 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -36,8 +36,7 @@ + android:exported="true"> diff --git a/app/src/main/kotlin/com/kimgo/posefit/MainActivity.kt b/app/src/main/kotlin/com/kimgo/posefit/MainActivity.kt index f6bb62b..fede724 100644 --- a/app/src/main/kotlin/com/kimgo/posefit/MainActivity.kt +++ b/app/src/main/kotlin/com/kimgo/posefit/MainActivity.kt @@ -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, + 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, + 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" + } } diff --git a/app/src/main/kotlin/com/kimgo/posefit/PosefitStreamingService.kt b/app/src/main/kotlin/com/kimgo/posefit/PosefitStreamingService.kt index 3f51360..ea043ca 100644 --- a/app/src/main/kotlin/com/kimgo/posefit/PosefitStreamingService.kt +++ b/app/src/main/kotlin/com/kimgo/posefit/PosefitStreamingService.kt @@ -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) } } diff --git a/app/src/main/kotlin/com/kimgo/posefit/sender/CameraCatalog.kt b/app/src/main/kotlin/com/kimgo/posefit/sender/CameraCatalog.kt index 2db4b7e..ad41185 100644 --- a/app/src/main/kotlin/com/kimgo/posefit/sender/CameraCatalog.kt +++ b/app/src/main/kotlin/com/kimgo/posefit/sender/CameraCatalog.kt @@ -22,11 +22,17 @@ object CameraCatalog { ?.toList() .orEmpty() }.getOrDefault(emptyList()) + val supportedResolutions = enumerator.getSupportedFormats(cameraName) + ?.map { StreamResolution(it.width, it.height) } + ?.distinct() + ?.sortedWith(compareBy { it.width * it.height }.thenBy { it.width }) + .orEmpty() CameraOption( name = cameraName, facing = facing, - focalLengths = focalLengths + focalLengths = focalLengths, + supportedResolutions = supportedResolutions, ) }.sortedWith( compareBy { it.facing != CameraFacing.BACK } diff --git a/app/src/main/kotlin/com/kimgo/posefit/sender/CameraOption.kt b/app/src/main/kotlin/com/kimgo/posefit/sender/CameraOption.kt index abd263b..9b2eb81 100644 --- a/app/src/main/kotlin/com/kimgo/posefit/sender/CameraOption.kt +++ b/app/src/main/kotlin/com/kimgo/posefit/sender/CameraOption.kt @@ -3,7 +3,8 @@ package com.kimgo.posefit.sender data class CameraOption( val name: String, val facing: CameraFacing, - val focalLengths: List + val focalLengths: List, + val supportedResolutions: List, ) { val minFocalLength: Float? get() = focalLengths.minOrNull() diff --git a/app/src/main/kotlin/com/kimgo/posefit/sender/StreamOrientation.kt b/app/src/main/kotlin/com/kimgo/posefit/sender/StreamOrientation.kt new file mode 100644 index 0000000..b6e7d25 --- /dev/null +++ b/app/src/main/kotlin/com/kimgo/posefit/sender/StreamOrientation.kt @@ -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 + } +} diff --git a/app/src/main/kotlin/com/kimgo/posefit/sender/StreamResolution.kt b/app/src/main/kotlin/com/kimgo/posefit/sender/StreamResolution.kt new file mode 100644 index 0000000..dab75e7 --- /dev/null +++ b/app/src/main/kotlin/com/kimgo/posefit/sender/StreamResolution.kt @@ -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) + } + } +} diff --git a/app/src/main/kotlin/com/kimgo/posefit/sender/WebRtcSenderClient.kt b/app/src/main/kotlin/com/kimgo/posefit/sender/WebRtcSenderClient.kt index e6772ef..bd90814 100644 --- a/app/src/main/kotlin/com/kimgo/posefit/sender/WebRtcSenderClient.kt +++ b/app/src/main/kotlin/com/kimgo/posefit/sender/WebRtcSenderClient.kt @@ -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 { 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) + } } }