Compare commits

...

5 Commits

Author SHA1 Message Date
wsy182 d451507589 refactor(app): 重构应用架构并优化设置管理
- 将 MainActivity 中的 UI 组件提取到独立的 PosefitApp Composable 函数中
- 创建 StreamSettings 和 StreamSettingsStore 类以统一管理流媒体设置
- 移除 MainActivity 中大量的内联 Composable 函数和状态管理代码
- 更新 PosefitStreamingService 以使用新的设置存储机制
- 简化服务启动逻辑并将 StreamSettings 对象作为参数传递
- 迁移所有设置相关的 SharedPreferences 操作到专门的设置存储类中
2026-06-10 11:30:04 +08:00
wsy182 8c8239502e feat(app): 添加摄像头配置和视频流设置功能
- 在 CameraOption 中新增 supportedResolutions 属性以支持分辨率选择
- 实现 StreamOrientation 和 StreamResolution 数据类用于视频方向和分辨率管理
- 重构 MainActivity 为多页面应用,添加设置页面支持摄像头、分辨率、方向配置
- 集成下拉菜单选择摄像头和视频分辨率功能
- 更新 PosefitStreamingService 支持视频分辨率和方向参数传递
- 移除 AndroidManifest 中 MainActivity 的屏幕方向锁定设置
- 添加详细的视频捕获帧日志记录和可用摄像头格式输出
- 优化视频流启动流程,支持多种分辨率和方向设置
2026-06-10 11:24:47 +08:00
wsy182 1d9c5f9f20 Improve streaming camera controls 2026-06-09 23:07:42 +08:00
wsy182 aea5efec56 Add camera facing selection 2026-06-02 01:04:45 +08:00
wsy182 1031b20d29 fix: allow cleartext traffic for WebSocket signaling 2026-06-02 00:50:47 +08:00
12 changed files with 1050 additions and 93 deletions
+10 -1
View File
@@ -4,6 +4,9 @@
<!-- 摄像头权限 --> <!-- 摄像头权限 -->
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CAMERA" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- 网络权限:WebRTC / WebSocket / 信令服务器需要 --> <!-- 网络权限:WebRTC / WebSocket / 信令服务器需要 -->
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
@@ -28,7 +31,8 @@
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.Posefit"> android:theme="@style/Theme.Posefit"
android:usesCleartextTraffic="true">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
@@ -39,6 +43,11 @@
</intent-filter> </intent-filter>
</activity> </activity>
<service
android:name=".PosefitStreamingService"
android:exported="false"
android:foregroundServiceType="camera" />
</application> </application>
</manifest> </manifest>
@@ -1,123 +1,111 @@
package com.kimgo.posefit package com.kimgo.posefit
import android.Manifest import android.Manifest
import android.content.Context import android.os.Build
import android.os.Bundle import android.os.Bundle
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.layout.fillMaxSize
import androidx.compose.material3.* import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.* import androidx.compose.material3.Surface
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import com.kimgo.posefit.sender.StreamOrientation
import com.kimgo.posefit.sender.WebRtcSenderClient import com.kimgo.posefit.settings.StreamSettings
import com.kimgo.posefit.settings.StreamSettingsStore
import com.kimgo.posefit.ui.PosefitApp
import timber.log.Timber import timber.log.Timber
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private var webRtcClient: WebRtcSenderClient? = null private lateinit var settingsStore: StreamSettingsStore
private var permissionResultHandler: ((Boolean) -> Unit)? = null
private val requestPermission = private val requestPermission =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { grants ->
if (granted) { val cameraGranted = grants[Manifest.permission.CAMERA] == true
val url = getSavedSignalingUrl() if (cameraGranted) {
startWebRtc(url) startWebRtc(settingsStore.get())
} 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")
settingsStore = StreamSettingsStore(this)
applyStreamOrientation(settingsStore.get().orientation)
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(
context = this,
initialSettings = settingsStore.get(),
isInitiallyStreaming = PosefitStreamingService.isRunning,
onStartStreaming = { _, onPermissionResult ->
permissionResultHandler = onPermissionResult
requestPermission.launch(requiredPermissions())
},
onStopStreaming = ::stopWebRtc,
onSaveSettings = settingsStore::save,
onApplyOrientation = { applyStreamOrientation(it.orientation) },
)
} }
} }
} }
} }
@Composable private fun applyStreamOrientation(orientation: StreamOrientation) {
fun ConfigScreen() { requestedOrientation = orientation.activityOrientation
var url by remember { mutableStateOf(getSavedSignalingUrl()) }
var isStreaming by remember { mutableStateOf(false) }
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "PoseFit 配置",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(32.dp))
TextField(
value = url,
onValueChange = { url = it },
label = { Text("服务器地址 (ws://...)") },
modifier = Modifier.fillMaxWidth(),
enabled = !isStreaming
)
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = {
if (isStreaming) {
stopWebRtc()
isStreaming = false
} else {
saveSignalingUrl(url)
requestPermission.launch(Manifest.permission.CAMERA)
isStreaming = true
}
},
modifier = Modifier.fillMaxWidth()
) {
Text(if (isStreaming) "停止推流" else "开始推流")
}
}
} }
private fun getSavedSignalingUrl(): String { private fun startWebRtc(settings: StreamSettings) {
val prefs = getSharedPreferences("posefit_prefs", Context.MODE_PRIVATE) Timber.i(
return prefs.getString("signaling_url", "ws://192.168.2.10:8765") ?: "ws://192.168.2.10:8765" "startWebRtc: %s, cameraFacing=%s, cameraName=%s, resolution=%s, orientation=%s",
} settings.signalingUrl,
settings.cameraFacing,
private fun saveSignalingUrl(url: String) { settings.cameraName,
val prefs = getSharedPreferences("posefit_prefs", Context.MODE_PRIVATE) settings.resolution.label,
prefs.edit().putString("signaling_url", url).apply() settings.orientation,
}
private fun startWebRtc(url: String) {
Timber.i("startWebRtc: %s", url)
webRtcClient?.release()
webRtcClient = WebRtcSenderClient(
context = this,
signalingUrl = url
) )
webRtcClient?.start() applyStreamOrientation(settings.orientation)
val intent = PosefitStreamingService.startIntent(
context = this,
settings = settings,
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(intent)
} else {
startService(intent)
}
} }
private fun stopWebRtc() { private fun stopWebRtc() {
Timber.i("stopWebRtc") Timber.i("stopWebRtc")
webRtcClient?.release() startService(PosefitStreamingService.stopIntent(this))
webRtcClient = null }
private fun requiredPermissions(): Array<String> {
return buildList {
add(Manifest.permission.CAMERA)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
add(Manifest.permission.POST_NOTIFICATIONS)
}
}.toTypedArray()
} }
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
stopWebRtc()
Timber.d("MainActivity onDestroy") Timber.d("MainActivity onDestroy")
} }
} }
@@ -0,0 +1,190 @@
package com.kimgo.posefit
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import com.kimgo.posefit.sender.CameraFacing
import com.kimgo.posefit.sender.StreamOrientation
import com.kimgo.posefit.sender.StreamResolution
import com.kimgo.posefit.sender.WebRtcSenderClient
import com.kimgo.posefit.settings.StreamSettings
import com.kimgo.posefit.settings.StreamSettingsStore
import timber.log.Timber
class PosefitStreamingService : Service() {
private var webRtcClient: WebRtcSenderClient? = null
override fun onCreate() {
super.onCreate()
createNotificationChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_STOP -> {
stopStreaming()
stopSelf()
return START_NOT_STICKY
}
ACTION_START -> {
val settings = StreamSettings(
signalingUrl = intent.getStringExtra(EXTRA_SIGNALING_URL)
?: StreamSettingsStore.DEFAULT_SIGNALING_URL,
cameraFacing = intent.getStringExtra(EXTRA_CAMERA_FACING)
?.let { runCatching { CameraFacing.valueOf(it) }.getOrNull() }
?: CameraFacing.BACK,
cameraName = 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 settings = StreamSettingsStore(this).get()
startForegroundNotification(settings)
startStreaming(settings)
return START_STICKY
}
}
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onDestroy() {
stopStreaming()
super.onDestroy()
}
private fun startStreaming(settings: StreamSettings) {
Timber.i(
"Streaming service start: %s, cameraFacing=%s, preferredCameraName=%s, resolution=%s, orientation=%s",
settings.signalingUrl,
settings.cameraFacing,
settings.cameraName,
settings.resolution.label,
settings.orientation,
)
webRtcClient?.release()
webRtcClient = WebRtcSenderClient(
context = applicationContext,
signalingUrl = settings.signalingUrl,
cameraFacing = settings.cameraFacing,
preferredCameraName = settings.cameraName,
videoWidth = settings.resolution.width,
videoHeight = settings.resolution.height,
streamOrientation = settings.orientation,
).also { it.start() }
isRunning = true
}
private fun stopStreaming() {
Timber.i("Streaming service stop")
webRtcClient?.release()
webRtcClient = null
isRunning = false
}
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 {
startForeground(NOTIFICATION_ID, notification)
}
}
private fun buildNotification(settings: StreamSettings): Notification {
val cameraText = when (settings.cameraFacing) {
CameraFacing.FRONT -> "前置摄像头"
CameraFacing.BACK -> "后置摄像头"
}
val cameraModeText = settings.cameraName
?.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 $streamText -> ${settings.signalingUrl}")
.setSmallIcon(R.mipmap.ic_launcher)
.setOngoing(true)
.build()
} else {
Notification.Builder(this)
.setContentTitle("PoseFit 正在推流")
.setContentText("$cameraModeText $streamText -> ${settings.signalingUrl}")
.setSmallIcon(R.mipmap.ic_launcher)
.setOngoing(true)
.build()
}
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return
}
val channel = NotificationChannel(
CHANNEL_ID,
"PoseFit 推流",
NotificationManager.IMPORTANCE_LOW,
)
val manager = getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(channel)
}
companion object {
private const val CHANNEL_ID = "posefit_streaming"
private const val NOTIFICATION_ID = 1001
private const val ACTION_START = "com.kimgo.posefit.action.START_STREAMING"
private const val ACTION_STOP = "com.kimgo.posefit.action.STOP_STREAMING"
private const val EXTRA_SIGNALING_URL = "extra_signaling_url"
private const val EXTRA_CAMERA_FACING = "extra_camera_facing"
private const val EXTRA_CAMERA_NAME = "extra_camera_name"
private const val EXTRA_VIDEO_WIDTH = "extra_video_width"
private const val EXTRA_VIDEO_HEIGHT = "extra_video_height"
private const val EXTRA_STREAM_ORIENTATION = "extra_stream_orientation"
@Volatile
var isRunning: Boolean = false
private set
fun startIntent(
context: Context,
settings: StreamSettings,
): Intent {
return Intent(context, PosefitStreamingService::class.java).apply {
action = ACTION_START
putExtra(EXTRA_SIGNALING_URL, settings.signalingUrl)
putExtra(EXTRA_CAMERA_FACING, settings.cameraFacing.name)
putExtra(EXTRA_CAMERA_NAME, settings.cameraName)
putExtra(EXTRA_VIDEO_WIDTH, settings.resolution.width)
putExtra(EXTRA_VIDEO_HEIGHT, settings.resolution.height)
putExtra(EXTRA_STREAM_ORIENTATION, settings.orientation.name)
}
}
fun stopIntent(context: Context): Intent {
return Intent(context, PosefitStreamingService::class.java).apply {
action = ACTION_STOP
}
}
}
}
@@ -0,0 +1,50 @@
package com.kimgo.posefit.sender
import android.content.Context
import android.hardware.camera2.CameraCharacteristics
import android.hardware.camera2.CameraManager
import org.webrtc.Camera2Enumerator
object CameraCatalog {
fun listOptions(context: Context): List<CameraOption> {
val enumerator = Camera2Enumerator(context)
val cameraManager = context.getSystemService(Context.CAMERA_SERVICE) as CameraManager
return enumerator.deviceNames.mapNotNull { cameraName ->
val facing = when {
enumerator.isFrontFacing(cameraName) -> CameraFacing.FRONT
enumerator.isBackFacing(cameraName) -> CameraFacing.BACK
else -> return@mapNotNull null
}
val focalLengths = runCatching {
cameraManager.getCameraCharacteristics(cameraName)
.get(CameraCharacteristics.LENS_INFO_AVAILABLE_FOCAL_LENGTHS)
?.toList()
.orEmpty()
}.getOrDefault(emptyList())
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,
supportedResolutions = supportedResolutions,
)
}.sortedWith(
compareBy<CameraOption> { it.facing != CameraFacing.BACK }
.thenBy { it.minFocalLength ?: Float.MAX_VALUE }
.thenBy { it.name }
)
}
fun widestBackCameraName(context: Context): String? {
return listOptions(context)
.filter { it.facing == CameraFacing.BACK }
.minByOrNull { it.minFocalLength ?: Float.MAX_VALUE }
?.name
}
}
@@ -0,0 +1,6 @@
package com.kimgo.posefit.sender
enum class CameraFacing {
FRONT,
BACK
}
@@ -0,0 +1,21 @@
package com.kimgo.posefit.sender
data class CameraOption(
val name: String,
val facing: CameraFacing,
val focalLengths: List<Float>,
val supportedResolutions: List<StreamResolution>,
) {
val minFocalLength: Float?
get() = focalLengths.minOrNull()
val label: String
get() {
val facingText = when (facing) {
CameraFacing.FRONT -> "前置"
CameraFacing.BACK -> "后置"
}
val focalText = minFocalLength?.let { "%.2fmm".format(it) } ?: "未知焦距"
return "$facingText $focalText ($name)"
}
}
@@ -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)
}
}
}
@@ -8,9 +8,20 @@ import timber.log.Timber
class WebRtcSenderClient( 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 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_FPS = 30
const val VIDEO_MIN_BITRATE_BPS = 3_000_000
const val VIDEO_MAX_BITRATE_BPS = 6_000_000
}
private val eglBase = EglBase.create() private val eglBase = EglBase.create()
private val okHttpClient = OkHttpClient() private val okHttpClient = OkHttpClient()
@@ -21,10 +32,21 @@ class WebRtcSenderClient(
private var videoCapturer: CameraVideoCapturer? = null private var videoCapturer: CameraVideoCapturer? = null
private var videoSource: VideoSource? = null private var videoSource: VideoSource? = null
private var videoTrack: VideoTrack? = null private var videoTrack: VideoTrack? = 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("WebRTC starting, signalingUrl=%s", signalingUrl) Timber.i(
"WebRTC starting, signalingUrl=%s, cameraFacing=%s, preferredCameraName=%s, resolution=%dx%d, orientation=%s",
signalingUrl,
cameraFacing,
preferredCameraName,
videoWidth,
videoHeight,
streamOrientation,
)
initPeerConnectionFactory() initPeerConnectionFactory()
connectSignaling() connectSignaling()
} }
@@ -129,9 +151,7 @@ class WebRtcSenderClient(
private fun startCamera() { private fun startCamera() {
val enumerator = Camera2Enumerator(context) val enumerator = Camera2Enumerator(context)
val cameraName = enumerator.deviceNames.firstOrNull { val cameraName = selectCamera(enumerator)
enumerator.isFrontFacing(it)
} ?: enumerator.deviceNames.first()
videoCapturer = enumerator.createCapturer(cameraName, null) videoCapturer = enumerator.createCapturer(cameraName, null)
@@ -141,23 +161,167 @@ 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(1280, 720, 30) 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)
peerConnection?.addTrack( videoSender = peerConnection?.addTrack(
videoTrack, videoTrack,
listOf("posefit_stream") listOf("posefit_stream")
) )
videoSender?.let { configureVideoSender(it) }
Timber.d("Camera started: %s, resolution=1280x720@30fps", cameraName) Timber.d("Camera started: %s, resolution=%dx%d@%dfps", cameraName, videoWidth, videoHeight, VIDEO_FPS)
}
private fun configureVideoSender(sender: RtpSender) {
val parameters = sender.parameters ?: run {
Timber.w("Video sender parameters unavailable")
return
}
parameters.degradationPreference = RtpParameters.DegradationPreference.MAINTAIN_RESOLUTION
parameters.encodings.forEach { encoding ->
encoding.active = true
encoding.scaleResolutionDownBy = 1.0
encoding.maxFramerate = VIDEO_FPS
encoding.minBitrateBps = VIDEO_MIN_BITRATE_BPS
encoding.maxBitrateBps = VIDEO_MAX_BITRATE_BPS
}
val applied = sender.setParameters(parameters)
Timber.d(
"Video sender configured: applied=%s, resolution=%dx%d@%dfps, bitrate=%d-%d",
applied,
videoWidth,
videoHeight,
VIDEO_FPS,
VIDEO_MIN_BITRATE_BPS,
VIDEO_MAX_BITRATE_BPS,
)
}
private fun selectCamera(enumerator: Camera2Enumerator): String {
logAvailableCameras(enumerator)
if (!preferredCameraName.isNullOrBlank() && preferredCameraName in enumerator.deviceNames) {
Timber.i("Selected explicit camera: %s", preferredCameraName)
return preferredCameraName
}
val preferredCamera = when (cameraFacing) {
CameraFacing.FRONT -> enumerator.deviceNames.firstOrNull { enumerator.isFrontFacing(it) }
CameraFacing.BACK -> selectWidestBackCamera(enumerator)
}
if (preferredCamera != null) {
return preferredCamera
}
Timber.w("Preferred camera not found: %s, falling back to first available camera", cameraFacing)
return enumerator.deviceNames.first()
}
private fun selectWidestBackCamera(enumerator: Camera2Enumerator): String? {
val backCameras = enumerator.deviceNames.filter { enumerator.isBackFacing(it) }
if (backCameras.isEmpty()) {
return null
}
val widestCamera = CameraCatalog.widestBackCameraName(context)
Timber.i("Selected widest back camera: %s", widestCamera)
return widestCamera ?: backCameras.first()
}
private fun logAvailableCameras(enumerator: Camera2Enumerator) {
val knownOptions = CameraCatalog.listOptions(context).associateBy { it.name }
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, 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)
}
}
} }
private fun createOffer() { private fun createOffer() {
@@ -173,11 +337,16 @@ class WebRtcSenderClient(
peerConnection?.createOffer(object : SdpObserver { peerConnection?.createOffer(object : SdpObserver {
override fun onCreateSuccess(desc: SessionDescription) { override fun onCreateSuccess(desc: SessionDescription) {
val fixedVideoDesc = SessionDescription(
desc.type,
forceVideoBandwidth(desc.description)
)
peerConnection?.setLocalDescription(object : SdpObserver { peerConnection?.setLocalDescription(object : SdpObserver {
override fun onSetSuccess() { override fun onSetSuccess() {
val json = JSONObject().apply { val json = JSONObject().apply {
put("type", desc.type.canonicalForm()) put("type", fixedVideoDesc.type.canonicalForm())
put("sdp", desc.description) put("sdp", fixedVideoDesc.description)
} }
webSocket.send(json.toString()) webSocket.send(json.toString())
@@ -192,7 +361,7 @@ class WebRtcSenderClient(
override fun onCreateFailure(error: String) { override fun onCreateFailure(error: String) {
Timber.e("createOffer failed: %s", error) Timber.e("createOffer failed: %s", error)
} }
}, desc) }, fixedVideoDesc)
} }
override fun onSetSuccess() {} override fun onSetSuccess() {}
@@ -206,6 +375,35 @@ class WebRtcSenderClient(
}, constraints) }, constraints)
} }
private fun forceVideoBandwidth(sdp: String): String {
val lines = sdp.split("\r\n").toMutableList()
val videoStart = lines.indexOfFirst { it.startsWith("m=video") }
if (videoStart == -1) {
return sdp
}
val videoEnd = lines.drop(videoStart + 1)
.indexOfFirst { it.startsWith("m=") }
.let { if (it == -1) lines.size else videoStart + 1 + it }
val bandwidthLine = "b=AS:${VIDEO_MAX_BITRATE_BPS / 1000}"
val existingBandwidth = (videoStart until videoEnd).firstOrNull { index ->
lines[index].startsWith("b=AS:") || lines[index].startsWith("b=TIAS:")
}
if (existingBandwidth != null) {
lines[existingBandwidth] = bandwidthLine
} else {
val insertAt = (videoStart + 1 until videoEnd).firstOrNull { index ->
lines[index].startsWith("a=")
} ?: videoEnd
lines.add(insertAt, bandwidthLine)
}
Timber.d("Forced video SDP bandwidth: %s", bandwidthLine)
return lines.joinToString("\r\n")
}
private fun handleSignalingMessage(text: String) { private fun handleSignalingMessage(text: String) {
val json = JSONObject(text) val json = JSONObject(text)
@@ -246,12 +444,17 @@ class WebRtcSenderClient(
videoCapturer?.dispose() videoCapturer?.dispose()
videoSource?.dispose() videoSource?.dispose()
videoTrack?.dispose() videoTrack?.dispose()
videoSender = null
surfaceTextureHelper?.dispose() surfaceTextureHelper?.dispose()
peerConnection?.close() peerConnection?.close()
peerConnection?.dispose() peerConnection?.dispose()
factory.dispose() if (::factory.isInitialized) {
factory.dispose()
}
eglBase.release() eglBase.release()
webSocket.close(1000, "close") if (::webSocket.isInitialized) {
webSocket.close(1000, "close")
}
okHttpClient.dispatcher.executorService.shutdown() okHttpClient.dispatcher.executorService.shutdown()
Timber.d("WebRTC released") Timber.d("WebRTC released")
@@ -0,0 +1,13 @@
package com.kimgo.posefit.settings
import com.kimgo.posefit.sender.CameraFacing
import com.kimgo.posefit.sender.StreamOrientation
import com.kimgo.posefit.sender.StreamResolution
data class StreamSettings(
val signalingUrl: String,
val cameraFacing: CameraFacing,
val cameraName: String?,
val resolution: StreamResolution,
val orientation: StreamOrientation,
)
@@ -0,0 +1,51 @@
package com.kimgo.posefit.settings
import android.content.Context
import com.kimgo.posefit.sender.CameraFacing
import com.kimgo.posefit.sender.StreamOrientation
import com.kimgo.posefit.sender.StreamResolution
class StreamSettingsStore(private val context: Context) {
private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
fun get(): StreamSettings {
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),
cameraName = 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),
)
}
fun save(settings: StreamSettings) {
prefs.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()
}
companion object {
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"
const val DEFAULT_SIGNALING_URL = "ws://192.168.2.6:8765"
}
}
@@ -0,0 +1,376 @@
package com.kimgo.posefit.ui
import android.content.Context
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.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
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.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.sender.CameraCatalog
import com.kimgo.posefit.sender.CameraFacing
import com.kimgo.posefit.sender.CameraOption
import com.kimgo.posefit.sender.StreamResolution
import com.kimgo.posefit.settings.StreamSettings
@Composable
fun PosefitApp(
context: Context,
initialSettings: StreamSettings,
isInitiallyStreaming: Boolean,
onStartStreaming: (StreamSettings, (Boolean) -> Unit) -> Unit,
onStopStreaming: () -> Unit,
onSaveSettings: (StreamSettings) -> Unit,
onApplyOrientation: (StreamSettings) -> Unit,
) {
var screen by remember { mutableStateOf(AppScreen.HOME) }
var isStreaming by remember { mutableStateOf(isInitiallyStreaming) }
var settings by remember { mutableStateOf(initialSettings) }
when (screen) {
AppScreen.HOME -> HomeScreen(
isStreaming = isStreaming,
settings = settings,
onToggleStreaming = {
if (isStreaming) {
onStopStreaming()
isStreaming = false
} else {
onSaveSettings(settings)
onApplyOrientation(settings)
onStartStreaming(settings) { granted ->
isStreaming = granted
}
}
},
onOpenSettings = { screen = AppScreen.SETTINGS },
onRotate = {
val updated = settings.copy(orientation = settings.orientation.next())
settings = updated
onSaveSettings(updated)
onApplyOrientation(updated)
},
)
AppScreen.SETTINGS -> SettingsScreen(
context = context,
initialSettings = settings,
isStreaming = isStreaming,
onSave = { updated ->
settings = updated
onSaveSettings(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(24.dp),
) {
Row(
modifier = Modifier.align(Alignment.TopEnd),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
TextButton(onClick = onRotate) {
Text("旋转")
}
TextButton(
onClick = onOpenSettings,
enabled = !isStreaming,
) {
Text("设置")
}
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = "PoseFit",
style = MaterialTheme.typography.headlineLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
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),
) {
Text(if (isStreaming) "停止推流" else "开始推流")
}
}
}
}
@Composable
private fun SettingsScreen(
context: Context,
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(context) }.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),
) {
Text(selectedCameraLabel, maxLines = 1)
}
DropdownMenu(
expanded = cameraMenuExpanded,
onDismissRequest = { cameraMenuExpanded = false },
) {
DropdownMenuItem(
text = { Text("自动选择后置最广角") },
onClick = {
cameraFacing = CameraFacing.BACK
selectedCameraName = null
selectedResolution = chooseResolution(
resolveSelectedCameraOption(cameraOptions, null)
?.supportedResolutions
?.takeIf { it.isNotEmpty() }
?: StreamResolution.PRESETS,
selectedResolution,
)
cameraMenuExpanded = false
},
)
cameraOptions.forEach { option ->
DropdownMenuItem(
text = { Text(option.label) },
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 SettingGroup(
title: String,
content: @Composable ColumnScope.() -> Unit,
) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = title,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
content()
}
}
private fun resolveSelectedCameraOption(
cameraOptions: List<CameraOption>,
selectedCameraName: String?,
): 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 enum class AppScreen {
HOME,
SETTINGS,
}