diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 0b3981d..19fb11d 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -4,6 +4,9 @@
+
+
+
@@ -33,13 +36,19 @@
+ android:exported="true"
+ android:screenOrientation="landscape">
+
+
-
\ No newline at end of file
+
diff --git a/app/src/main/kotlin/com/kimgo/posefit/MainActivity.kt b/app/src/main/kotlin/com/kimgo/posefit/MainActivity.kt
index 53f20cc..f6bb62b 100644
--- a/app/src/main/kotlin/com/kimgo/posefit/MainActivity.kt
+++ b/app/src/main/kotlin/com/kimgo/posefit/MainActivity.kt
@@ -2,7 +2,11 @@ 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
@@ -11,21 +15,22 @@ import androidx.compose.material3.*
import androidx.compose.runtime.*
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.WebRtcSenderClient
import timber.log.Timber
class MainActivity : ComponentActivity() {
- private var webRtcClient: WebRtcSenderClient? = null
-
private val requestPermission =
- registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
- if (granted) {
+ registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { grants ->
+ val cameraGranted = grants[Manifest.permission.CAMERA] == true
+ if (cameraGranted) {
val url = getSavedSignalingUrl()
val cameraFacing = getSavedCameraFacing()
- startWebRtc(url, cameraFacing)
+ val cameraName = getSavedCameraName()
+ startWebRtc(url, cameraFacing, cameraName)
} else {
Timber.w("Camera permission denied")
}
@@ -34,6 +39,8 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Timber.d("MainActivity onCreate")
+ requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
+ window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
setContent {
MaterialTheme {
@@ -51,70 +58,148 @@ class MainActivity : ComponentActivity() {
fun ConfigScreen() {
var url by remember { mutableStateOf(getSavedSignalingUrl()) }
var cameraFacing by remember { mutableStateOf(getSavedCameraFacing()) }
- var isStreaming by remember { mutableStateOf(false) }
+ var selectedCameraName by remember { mutableStateOf(getSavedCameraName()) }
+ var cameraMenuExpanded by remember { mutableStateOf(false) }
+ val cameraOptions = remember { CameraCatalog.listOptions(this) }
+ val selectedCameraLabel = remember(selectedCameraName, cameraOptions) {
+ cameraOptions.firstOrNull { it.name == selectedCameraName }?.label ?: "自动选择后置最广角"
+ }
+ var isStreaming by remember { mutableStateOf(PosefitStreamingService.isRunning) }
- Column(
+ Row(
modifier = Modifier
.fillMaxSize()
- .padding(24.dp),
- verticalArrangement = Arrangement.Center,
- horizontalAlignment = Alignment.CenterHorizontally
+ .padding(horizontal = 32.dp, vertical = 20.dp),
+ horizontalArrangement = Arrangement.spacedBy(28.dp),
+ verticalAlignment = Alignment.CenterVertically
) {
- 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))
- Text(
- text = "选择摄像头",
- style = MaterialTheme.typography.titleMedium,
- modifier = Modifier.fillMaxWidth()
- )
- Spacer(modifier = Modifier.height(8.dp))
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.spacedBy(12.dp)
+ Column(
+ modifier = Modifier
+ .weight(0.9f)
+ .fillMaxHeight(),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.Start
) {
- CameraFacingButton(
- text = "前置摄像头",
- selected = cameraFacing == CameraFacing.FRONT,
- enabled = !isStreaming,
- modifier = Modifier.weight(1f),
- onClick = { cameraFacing = CameraFacing.FRONT }
+ Text(
+ text = "PoseFit",
+ style = MaterialTheme.typography.headlineLarge,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.primary
)
- CameraFacingButton(
- text = "后置摄像头",
- selected = cameraFacing == CameraFacing.BACK,
- enabled = !isStreaming,
- modifier = Modifier.weight(1f),
- onClick = { cameraFacing = CameraFacing.BACK }
- )
- }
- Spacer(modifier = Modifier.height(16.dp))
- Button(
- onClick = {
- if (isStreaming) {
- stopWebRtc()
- isStreaming = false
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = if (isStreaming) "正在横屏 1080x720 推流" else "横屏 1080x720 推流未开始",
+ style = MaterialTheme.typography.titleMedium,
+ color = if (isStreaming) {
+ MaterialTheme.colorScheme.primary
} else {
- saveSignalingUrl(url)
- saveCameraFacing(cameraFacing)
- requestPermission.launch(Manifest.permission.CAMERA)
- isStreaming = true
+ MaterialTheme.colorScheme.onSurfaceVariant
}
- },
- modifier = Modifier.fillMaxWidth()
+ )
+ 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)
+ ) {
+ Text(if (isStreaming) "停止推流" else "开始推流")
+ }
+ }
+
+ Column(
+ modifier = Modifier
+ .weight(1.4f)
+ .fillMaxHeight(),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.Start
) {
- Text(if (isStreaming) "停止推流" else "开始推流")
+ TextField(
+ value = url,
+ onValueChange = { url = it },
+ label = { Text("服务器地址 (ws://...)") },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true,
+ enabled = !isStreaming
+ )
+ Spacer(modifier = Modifier.height(14.dp))
+ Text(text = "选择摄像头", style = MaterialTheme.typography.titleSmall)
+ Spacer(modifier = Modifier.height(8.dp))
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ CameraFacingButton(
+ text = "前置",
+ selected = cameraFacing == CameraFacing.FRONT && selectedCameraName == null,
+ enabled = !isStreaming,
+ 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
+ }
+ )
+ }
+ Spacer(modifier = Modifier.height(10.dp))
+ 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
+ cameraMenuExpanded = false
+ }
+ )
+ cameraOptions.forEach { option ->
+ DropdownMenuItem(
+ text = { Text(option.label) },
+ onClick = {
+ cameraFacing = option.facing
+ selectedCameraName = option.name
+ cameraMenuExpanded = false
+ }
+ )
+ }
+ }
+ }
}
}
}
@@ -155,7 +240,7 @@ class MainActivity : ComponentActivity() {
private fun getSavedSignalingUrl(): String {
val prefs = getSharedPreferences("posefit_prefs", Context.MODE_PRIVATE)
- return prefs.getString("signaling_url", "ws://192.168.2.10:8765") ?: "ws://192.168.2.10:8765"
+ return prefs.getString("signaling_url", "ws://192.168.2.6:8765") ?: "ws://192.168.2.6:8765"
}
private fun saveSignalingUrl(url: String) {
@@ -165,9 +250,9 @@ class MainActivity : ComponentActivity() {
private fun getSavedCameraFacing(): CameraFacing {
val prefs = getSharedPreferences("posefit_prefs", Context.MODE_PRIVATE)
- val value = prefs.getString("camera_facing", CameraFacing.FRONT.name)
- return runCatching { CameraFacing.valueOf(value ?: CameraFacing.FRONT.name) }
- .getOrDefault(CameraFacing.FRONT)
+ val value = prefs.getString("camera_facing", CameraFacing.BACK.name)
+ return runCatching { CameraFacing.valueOf(value ?: CameraFacing.BACK.name) }
+ .getOrDefault(CameraFacing.BACK)
}
private fun saveCameraFacing(cameraFacing: CameraFacing) {
@@ -175,26 +260,42 @@ class MainActivity : ComponentActivity() {
prefs.edit().putString("camera_facing", cameraFacing.name).apply()
}
- private fun startWebRtc(url: String, cameraFacing: CameraFacing) {
- Timber.i("startWebRtc: %s, cameraFacing=%s", url, cameraFacing)
- webRtcClient?.release()
- webRtcClient = WebRtcSenderClient(
- context = this,
- signalingUrl = url,
- cameraFacing = cameraFacing
- )
- webRtcClient?.start()
+ private fun getSavedCameraName(): String? {
+ val prefs = getSharedPreferences("posefit_prefs", Context.MODE_PRIVATE)
+ return prefs.getString("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 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)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ startForegroundService(intent)
+ } else {
+ startService(intent)
+ }
}
private fun stopWebRtc() {
Timber.i("stopWebRtc")
- webRtcClient?.release()
- webRtcClient = null
+ startService(PosefitStreamingService.stopIntent(this))
+ }
+
+ private fun requiredPermissions(): Array {
+ return buildList {
+ add(Manifest.permission.CAMERA)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ add(Manifest.permission.POST_NOTIFICATIONS)
+ }
+ }.toTypedArray()
}
override fun onDestroy() {
super.onDestroy()
- stopWebRtc()
Timber.d("MainActivity onDestroy")
}
}
diff --git a/app/src/main/kotlin/com/kimgo/posefit/PosefitStreamingService.kt b/app/src/main/kotlin/com/kimgo/posefit/PosefitStreamingService.kt
new file mode 100644
index 0000000..3f51360
--- /dev/null
+++ b/app/src/main/kotlin/com/kimgo/posefit/PosefitStreamingService.kt
@@ -0,0 +1,183 @@
+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.WebRtcSenderClient
+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 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)
+ return START_STICKY
+ }
+ else -> {
+ val signalingUrl = getSavedSignalingUrl()
+ val cameraFacing = getSavedCameraFacing()
+ val preferredCameraName = getSavedCameraName()
+ startForegroundNotification(signalingUrl, cameraFacing, preferredCameraName)
+ startStreaming(signalingUrl, cameraFacing, preferredCameraName)
+ return START_STICKY
+ }
+ }
+ }
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ override fun onDestroy() {
+ stopStreaming()
+ super.onDestroy()
+ }
+
+ private fun startStreaming(signalingUrl: String, cameraFacing: CameraFacing, preferredCameraName: String?) {
+ Timber.i(
+ "Streaming service start: %s, cameraFacing=%s, preferredCameraName=%s, orientation=landscape",
+ signalingUrl,
+ cameraFacing,
+ preferredCameraName,
+ )
+ webRtcClient?.release()
+ webRtcClient = WebRtcSenderClient(
+ context = applicationContext,
+ signalingUrl = signalingUrl,
+ cameraFacing = cameraFacing,
+ preferredCameraName = preferredCameraName,
+ ).also { it.start() }
+ isRunning = true
+ }
+
+ private fun stopStreaming() {
+ Timber.i("Streaming service stop")
+ webRtcClient?.release()
+ webRtcClient = null
+ isRunning = false
+ }
+
+ private fun startForegroundNotification(signalingUrl: String, cameraFacing: CameraFacing, preferredCameraName: String?) {
+ val notification = buildNotification(signalingUrl, cameraFacing, preferredCameraName)
+ 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(signalingUrl: String, cameraFacing: CameraFacing, preferredCameraName: String?): Notification {
+ val cameraText = when (cameraFacing) {
+ CameraFacing.FRONT -> "前置摄像头"
+ CameraFacing.BACK -> "后置摄像头"
+ }
+ val cameraModeText = preferredCameraName?.let { "$cameraText $it" } ?: "$cameraText 自动最广角"
+
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ Notification.Builder(this, CHANNEL_ID)
+ .setContentTitle("PoseFit 正在推流")
+ .setContentText("$cameraModeText 横屏720p -> $signalingUrl")
+ .setSmallIcon(R.mipmap.ic_launcher)
+ .setOngoing(true)
+ .build()
+ } else {
+ Notification.Builder(this)
+ .setContentTitle("PoseFit 正在推流")
+ .setContentText("$cameraModeText 横屏720p -> $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)
+ }
+
+ 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 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() }
+ }
+
+ 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 DEFAULT_SIGNALING_URL = "ws://192.168.2.6:8765"
+
+ @Volatile
+ var isRunning: Boolean = false
+ private set
+
+ fun startIntent(
+ context: Context,
+ signalingUrl: String,
+ cameraFacing: CameraFacing,
+ preferredCameraName: String?
+ ): 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)
+ }
+ }
+
+ fun stopIntent(context: Context): Intent {
+ return Intent(context, PosefitStreamingService::class.java).apply {
+ action = ACTION_STOP
+ }
+ }
+ }
+}
diff --git a/app/src/main/kotlin/com/kimgo/posefit/sender/CameraCatalog.kt b/app/src/main/kotlin/com/kimgo/posefit/sender/CameraCatalog.kt
new file mode 100644
index 0000000..2db4b7e
--- /dev/null
+++ b/app/src/main/kotlin/com/kimgo/posefit/sender/CameraCatalog.kt
@@ -0,0 +1,44 @@
+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 {
+ 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())
+
+ CameraOption(
+ name = cameraName,
+ facing = facing,
+ focalLengths = focalLengths
+ )
+ }.sortedWith(
+ compareBy { 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
+ }
+}
diff --git a/app/src/main/kotlin/com/kimgo/posefit/sender/CameraOption.kt b/app/src/main/kotlin/com/kimgo/posefit/sender/CameraOption.kt
new file mode 100644
index 0000000..abd263b
--- /dev/null
+++ b/app/src/main/kotlin/com/kimgo/posefit/sender/CameraOption.kt
@@ -0,0 +1,20 @@
+package com.kimgo.posefit.sender
+
+data class CameraOption(
+ val name: String,
+ val facing: CameraFacing,
+ val focalLengths: List
+) {
+ 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)"
+ }
+}
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 b342424..e6772ef 100644
--- a/app/src/main/kotlin/com/kimgo/posefit/sender/WebRtcSenderClient.kt
+++ b/app/src/main/kotlin/com/kimgo/posefit/sender/WebRtcSenderClient.kt
@@ -9,9 +9,18 @@ import timber.log.Timber
class WebRtcSenderClient(
private val context: Context,
private val signalingUrl: String,
- private val cameraFacing: CameraFacing = CameraFacing.FRONT
+ private val cameraFacing: CameraFacing = CameraFacing.BACK,
+ private val preferredCameraName: String? = null
) {
+ 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
+ }
+
private val eglBase = EglBase.create()
private val okHttpClient = OkHttpClient()
@@ -22,10 +31,16 @@ class WebRtcSenderClient(
private var videoCapturer: CameraVideoCapturer? = null
private var videoSource: VideoSource? = null
private var videoTrack: VideoTrack? = null
+ private var videoSender: RtpSender? = null
private var surfaceTextureHelper: SurfaceTextureHelper? = null
fun start() {
- Timber.i("WebRTC starting, signalingUrl=%s, cameraFacing=%s", signalingUrl, cameraFacing)
+ Timber.i(
+ "WebRTC starting, signalingUrl=%s, cameraFacing=%s, preferredCameraName=%s",
+ signalingUrl,
+ cameraFacing,
+ preferredCameraName
+ )
initPeerConnectionFactory()
connectSignaling()
}
@@ -147,24 +162,57 @@ class WebRtcSenderClient(
videoSource?.capturerObserver
)
- videoCapturer?.startCapture(1280, 720, 30)
+ videoCapturer?.startCapture(VIDEO_WIDTH, VIDEO_HEIGHT, VIDEO_FPS)
videoTrack = factory.createVideoTrack("video_track", videoSource)
- peerConnection?.addTrack(
+ videoSender = peerConnection?.addTrack(
videoTrack,
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, VIDEO_WIDTH, VIDEO_HEIGHT, 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,
+ VIDEO_WIDTH,
+ VIDEO_HEIGHT,
+ VIDEO_FPS,
+ VIDEO_MIN_BITRATE_BPS,
+ VIDEO_MAX_BITRATE_BPS,
+ )
}
private fun selectCamera(enumerator: Camera2Enumerator): String {
- val preferredCamera = enumerator.deviceNames.firstOrNull { cameraName ->
- when (cameraFacing) {
- CameraFacing.FRONT -> enumerator.isFrontFacing(cameraName)
- CameraFacing.BACK -> enumerator.isBackFacing(cameraName)
- }
+ 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) {
@@ -175,6 +223,31 @@ class WebRtcSenderClient(
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 }
+ enumerator.deviceNames.forEach { cameraName ->
+ val option = knownOptions[cameraName]
+ Timber.i(
+ "Camera available: name=%s, facing=%s, focalLengths=%s",
+ cameraName,
+ option?.facing ?: "unknown",
+ option?.focalLengths,
+ )
+ }
+ }
+
private fun createOffer() {
val constraints = MediaConstraints().apply {
mandatory.add(
@@ -188,11 +261,16 @@ class WebRtcSenderClient(
peerConnection?.createOffer(object : SdpObserver {
override fun onCreateSuccess(desc: SessionDescription) {
+ val fixedVideoDesc = SessionDescription(
+ desc.type,
+ forceVideoBandwidth(desc.description)
+ )
+
peerConnection?.setLocalDescription(object : SdpObserver {
override fun onSetSuccess() {
val json = JSONObject().apply {
- put("type", desc.type.canonicalForm())
- put("sdp", desc.description)
+ put("type", fixedVideoDesc.type.canonicalForm())
+ put("sdp", fixedVideoDesc.description)
}
webSocket.send(json.toString())
@@ -207,7 +285,7 @@ class WebRtcSenderClient(
override fun onCreateFailure(error: String) {
Timber.e("createOffer failed: %s", error)
}
- }, desc)
+ }, fixedVideoDesc)
}
override fun onSetSuccess() {}
@@ -221,6 +299,35 @@ class WebRtcSenderClient(
}, 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) {
val json = JSONObject(text)
@@ -261,12 +368,17 @@ class WebRtcSenderClient(
videoCapturer?.dispose()
videoSource?.dispose()
videoTrack?.dispose()
+ videoSender = null
surfaceTextureHelper?.dispose()
peerConnection?.close()
peerConnection?.dispose()
- factory.dispose()
+ if (::factory.isInitialized) {
+ factory.dispose()
+ }
eglBase.release()
- webSocket.close(1000, "close")
+ if (::webSocket.isInitialized) {
+ webSocket.close(1000, "close")
+ }
okHttpClient.dispatcher.executorService.shutdown()
Timber.d("WebRTC released")