Improve streaming camera controls

This commit is contained in:
2026-06-09 23:07:42 +08:00
parent aea5efec56
commit 1d9c5f9f20
6 changed files with 563 additions and 94 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" />
@@ -33,13 +36,19 @@
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true"> android:exported="true"
android:screenOrientation="landscape">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
<service
android:name=".PosefitStreamingService"
android:exported="false"
android:foregroundServiceType="camera" />
</application> </application>
</manifest> </manifest>
@@ -2,7 +2,11 @@ package com.kimgo.posefit
import android.Manifest import android.Manifest
import android.content.Context import android.content.Context
import android.content.Intent
import android.content.pm.ActivityInfo
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
@@ -11,21 +15,22 @@ import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.kimgo.posefit.sender.CameraCatalog
import com.kimgo.posefit.sender.CameraFacing import com.kimgo.posefit.sender.CameraFacing
import com.kimgo.posefit.sender.WebRtcSenderClient
import timber.log.Timber import timber.log.Timber
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private var webRtcClient: WebRtcSenderClient? = null
private val requestPermission = private val requestPermission =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { grants ->
if (granted) { val cameraGranted = grants[Manifest.permission.CAMERA] == true
if (cameraGranted) {
val url = getSavedSignalingUrl() val url = getSavedSignalingUrl()
val cameraFacing = getSavedCameraFacing() val cameraFacing = getSavedCameraFacing()
startWebRtc(url, cameraFacing) val cameraName = getSavedCameraName()
startWebRtc(url, cameraFacing, cameraName)
} else { } else {
Timber.w("Camera permission denied") Timber.w("Camera permission denied")
} }
@@ -34,6 +39,8 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
Timber.d("MainActivity onCreate") Timber.d("MainActivity onCreate")
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
setContent { setContent {
MaterialTheme { MaterialTheme {
@@ -51,70 +58,148 @@ class MainActivity : ComponentActivity() {
fun ConfigScreen() { fun ConfigScreen() {
var url by remember { mutableStateOf(getSavedSignalingUrl()) } var url by remember { mutableStateOf(getSavedSignalingUrl()) }
var cameraFacing by remember { mutableStateOf(getSavedCameraFacing()) } 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 modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(24.dp), .padding(horizontal = 32.dp, vertical = 20.dp),
verticalArrangement = Arrangement.Center, horizontalArrangement = Arrangement.spacedBy(28.dp),
horizontalAlignment = Alignment.CenterHorizontally verticalAlignment = Alignment.CenterVertically
) { ) {
Text( Column(
text = "PoseFit 配置", modifier = Modifier
style = MaterialTheme.typography.headlineMedium, .weight(0.9f)
color = MaterialTheme.colorScheme.primary .fillMaxHeight(),
) verticalArrangement = Arrangement.Center,
Spacer(modifier = Modifier.height(32.dp)) horizontalAlignment = Alignment.Start
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)
) { ) {
CameraFacingButton( Text(
text = "前置摄像头", text = "PoseFit",
selected = cameraFacing == CameraFacing.FRONT, style = MaterialTheme.typography.headlineLarge,
enabled = !isStreaming, fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f), color = MaterialTheme.colorScheme.primary
onClick = { cameraFacing = CameraFacing.FRONT }
) )
CameraFacingButton( Spacer(modifier = Modifier.height(8.dp))
text = "后置摄像头", Text(
selected = cameraFacing == CameraFacing.BACK, text = if (isStreaming) "正在横屏 1080x720 推流" else "横屏 1080x720 推流未开始",
enabled = !isStreaming, style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f), color = if (isStreaming) {
onClick = { cameraFacing = CameraFacing.BACK } MaterialTheme.colorScheme.primary
)
}
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = {
if (isStreaming) {
stopWebRtc()
isStreaming = false
} else { } else {
saveSignalingUrl(url) MaterialTheme.colorScheme.onSurfaceVariant
saveCameraFacing(cameraFacing)
requestPermission.launch(Manifest.permission.CAMERA)
isStreaming = true
} }
}, )
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 { private fun getSavedSignalingUrl(): String {
val prefs = getSharedPreferences("posefit_prefs", Context.MODE_PRIVATE) 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) { private fun saveSignalingUrl(url: String) {
@@ -165,9 +250,9 @@ class MainActivity : ComponentActivity() {
private fun getSavedCameraFacing(): CameraFacing { private fun getSavedCameraFacing(): CameraFacing {
val prefs = getSharedPreferences("posefit_prefs", Context.MODE_PRIVATE) val prefs = getSharedPreferences("posefit_prefs", Context.MODE_PRIVATE)
val value = prefs.getString("camera_facing", CameraFacing.FRONT.name) val value = prefs.getString("camera_facing", CameraFacing.BACK.name)
return runCatching { CameraFacing.valueOf(value ?: CameraFacing.FRONT.name) } return runCatching { CameraFacing.valueOf(value ?: CameraFacing.BACK.name) }
.getOrDefault(CameraFacing.FRONT) .getOrDefault(CameraFacing.BACK)
} }
private fun saveCameraFacing(cameraFacing: CameraFacing) { private fun saveCameraFacing(cameraFacing: CameraFacing) {
@@ -175,26 +260,42 @@ class MainActivity : ComponentActivity() {
prefs.edit().putString("camera_facing", cameraFacing.name).apply() prefs.edit().putString("camera_facing", cameraFacing.name).apply()
} }
private fun startWebRtc(url: String, cameraFacing: CameraFacing) { private fun getSavedCameraName(): String? {
Timber.i("startWebRtc: %s, cameraFacing=%s", url, cameraFacing) val prefs = getSharedPreferences("posefit_prefs", Context.MODE_PRIVATE)
webRtcClient?.release() return prefs.getString("camera_name", null)?.takeIf { it.isNotBlank() }
webRtcClient = WebRtcSenderClient( }
context = this,
signalingUrl = url, private fun saveCameraName(cameraName: String?) {
cameraFacing = cameraFacing val prefs = getSharedPreferences("posefit_prefs", Context.MODE_PRIVATE)
) prefs.edit().putString("camera_name", cameraName).apply()
webRtcClient?.start() }
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() { 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,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
}
}
}
}
@@ -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<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())
CameraOption(
name = cameraName,
facing = facing,
focalLengths = focalLengths
)
}.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,20 @@
package com.kimgo.posefit.sender
data class CameraOption(
val name: String,
val facing: CameraFacing,
val focalLengths: List<Float>
) {
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)"
}
}
@@ -9,9 +9,18 @@ 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.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 eglBase = EglBase.create()
private val okHttpClient = OkHttpClient() private val okHttpClient = OkHttpClient()
@@ -22,10 +31,16 @@ 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
fun start() { 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() initPeerConnectionFactory()
connectSignaling() connectSignaling()
} }
@@ -147,24 +162,57 @@ class WebRtcSenderClient(
videoSource?.capturerObserver videoSource?.capturerObserver
) )
videoCapturer?.startCapture(1280, 720, 30) videoCapturer?.startCapture(VIDEO_WIDTH, VIDEO_HEIGHT, 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, 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 { private fun selectCamera(enumerator: Camera2Enumerator): String {
val preferredCamera = enumerator.deviceNames.firstOrNull { cameraName -> logAvailableCameras(enumerator)
when (cameraFacing) {
CameraFacing.FRONT -> enumerator.isFrontFacing(cameraName) if (!preferredCameraName.isNullOrBlank() && preferredCameraName in enumerator.deviceNames) {
CameraFacing.BACK -> enumerator.isBackFacing(cameraName) 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) { if (preferredCamera != null) {
@@ -175,6 +223,31 @@ class WebRtcSenderClient(
return enumerator.deviceNames.first() 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() { private fun createOffer() {
val constraints = MediaConstraints().apply { val constraints = MediaConstraints().apply {
mandatory.add( mandatory.add(
@@ -188,11 +261,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())
@@ -207,7 +285,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() {}
@@ -221,6 +299,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)
@@ -261,12 +368,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")