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