Compare commits
7 Commits
a355919cdc
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d451507589 | |||
| 8c8239502e | |||
| 1d9c5f9f20 | |||
| aea5efec56 | |||
| 1031b20d29 | |||
| 9a23b3359b | |||
| 03675fab69 |
@@ -1,5 +1,6 @@
|
|||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
|
alias(libs.plugins.jetbrains.kotlin.android)
|
||||||
alias(libs.plugins.compose.compiler)
|
alias(libs.plugins.compose.compiler)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,6 +28,9 @@ android {
|
|||||||
sourceCompatibility JavaVersion.VERSION_11
|
sourceCompatibility JavaVersion.VERSION_11
|
||||||
targetCompatibility JavaVersion.VERSION_11
|
targetCompatibility JavaVersion.VERSION_11
|
||||||
}
|
}
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = '11'
|
||||||
|
}
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
compose true
|
compose true
|
||||||
buildConfig true
|
buildConfig true
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
package="com.kimgo.posefit">
|
||||||
|
|
||||||
<!-- 摄像头权限 -->
|
<!-- 摄像头权限 -->
|
||||||
<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" />
|
||||||
@@ -27,17 +31,23 @@
|
|||||||
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"
|
||||||
android:exported="true">
|
android:exported="true">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.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>
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
package com.kimgo.posefit
|
|
||||||
|
|
||||||
import android.Manifest
|
|
||||||
import android.content.Context
|
|
||||||
import android.os.Bundle
|
|
||||||
import androidx.activity.ComponentActivity
|
|
||||||
import androidx.activity.compose.setContent
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.compose.foundation.layout.*
|
|
||||||
import androidx.compose.material3.*
|
|
||||||
import androidx.compose.runtime.*
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
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) {
|
|
||||||
val url = getSavedSignalingUrl()
|
|
||||||
startWebRtc(url)
|
|
||||||
} else {
|
|
||||||
Timber.w("Camera permission denied")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
Timber.d("MainActivity onCreate")
|
|
||||||
|
|
||||||
setContent {
|
|
||||||
MaterialTheme {
|
|
||||||
Surface(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
color = MaterialTheme.colorScheme.background
|
|
||||||
) {
|
|
||||||
ConfigScreen()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun ConfigScreen() {
|
|
||||||
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 {
|
|
||||||
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"
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun saveSignalingUrl(url: String) {
|
|
||||||
val prefs = getSharedPreferences("posefit_prefs", Context.MODE_PRIVATE)
|
|
||||||
prefs.edit().putString("signaling_url", url).apply()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun startWebRtc(url: String) {
|
|
||||||
Timber.i("startWebRtc: %s", url)
|
|
||||||
webRtcClient?.release()
|
|
||||||
webRtcClient = WebRtcSenderClient(
|
|
||||||
context = this,
|
|
||||||
signalingUrl = url
|
|
||||||
)
|
|
||||||
webRtcClient?.start()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun stopWebRtc() {
|
|
||||||
Timber.i("stopWebRtc")
|
|
||||||
webRtcClient?.release()
|
|
||||||
webRtcClient = null
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroy() {
|
|
||||||
super.onDestroy()
|
|
||||||
stopWebRtc()
|
|
||||||
Timber.d("MainActivity onDestroy")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,259 +0,0 @@
|
|||||||
package com.kimgo.posefit.sender
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import okhttp3.*
|
|
||||||
import org.json.JSONObject
|
|
||||||
import org.webrtc.*
|
|
||||||
import timber.log.Timber
|
|
||||||
|
|
||||||
class WebRtcSenderClient(
|
|
||||||
private val context: Context,
|
|
||||||
private val signalingUrl: String
|
|
||||||
) {
|
|
||||||
|
|
||||||
private val eglBase = EglBase.create()
|
|
||||||
private val okHttpClient = OkHttpClient()
|
|
||||||
|
|
||||||
private lateinit var webSocket: WebSocket
|
|
||||||
private lateinit var factory: PeerConnectionFactory
|
|
||||||
private var peerConnection: PeerConnection? = null
|
|
||||||
|
|
||||||
private var videoCapturer: CameraVideoCapturer? = null
|
|
||||||
private var videoSource: VideoSource? = null
|
|
||||||
private var videoTrack: VideoTrack? = null
|
|
||||||
private var surfaceTextureHelper: SurfaceTextureHelper? = null
|
|
||||||
|
|
||||||
fun start() {
|
|
||||||
Timber.i("WebRTC starting, signalingUrl=%s", signalingUrl)
|
|
||||||
initPeerConnectionFactory()
|
|
||||||
connectSignaling()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun initPeerConnectionFactory() {
|
|
||||||
PeerConnectionFactory.initialize(
|
|
||||||
PeerConnectionFactory.InitializationOptions.builder(context)
|
|
||||||
.createInitializationOptions()
|
|
||||||
)
|
|
||||||
|
|
||||||
val encoderFactory = DefaultVideoEncoderFactory(
|
|
||||||
eglBase.eglBaseContext,
|
|
||||||
true,
|
|
||||||
true
|
|
||||||
)
|
|
||||||
|
|
||||||
val decoderFactory = DefaultVideoDecoderFactory(
|
|
||||||
eglBase.eglBaseContext
|
|
||||||
)
|
|
||||||
|
|
||||||
factory = PeerConnectionFactory.builder()
|
|
||||||
.setVideoEncoderFactory(encoderFactory)
|
|
||||||
.setVideoDecoderFactory(decoderFactory)
|
|
||||||
.createPeerConnectionFactory()
|
|
||||||
|
|
||||||
Timber.d("PeerConnectionFactory initialized")
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun connectSignaling() {
|
|
||||||
val request = Request.Builder()
|
|
||||||
.url(signalingUrl)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
webSocket = okHttpClient.newWebSocket(request, object : WebSocketListener() {
|
|
||||||
|
|
||||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
|
||||||
Timber.d("Signaling WebSocket connected")
|
|
||||||
createPeerConnection()
|
|
||||||
startCamera()
|
|
||||||
createOffer()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
|
||||||
handleSignalingMessage(text)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
|
||||||
Timber.e(t, "Signaling WebSocket failure")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
|
||||||
Timber.w("Signaling WebSocket closed, code=%d, reason=%s", code, reason)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun createPeerConnection() {
|
|
||||||
val iceServers = listOf(
|
|
||||||
PeerConnection.IceServer.builder("stun:stun.l.google.com:19302")
|
|
||||||
.createIceServer()
|
|
||||||
)
|
|
||||||
|
|
||||||
val rtcConfig = PeerConnection.RTCConfiguration(iceServers).apply {
|
|
||||||
sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN
|
|
||||||
}
|
|
||||||
|
|
||||||
peerConnection = factory.createPeerConnection(
|
|
||||||
rtcConfig,
|
|
||||||
object : PeerConnection.Observer {
|
|
||||||
|
|
||||||
override fun onIceCandidate(candidate: IceCandidate) {
|
|
||||||
val json = JSONObject().apply {
|
|
||||||
put("type", "candidate")
|
|
||||||
put("sdpMid", candidate.sdpMid)
|
|
||||||
put("sdpMLineIndex", candidate.sdpMLineIndex)
|
|
||||||
put("candidate", candidate.sdp)
|
|
||||||
}
|
|
||||||
|
|
||||||
webSocket.send(json.toString())
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onIceConnectionChange(state: PeerConnection.IceConnectionState) {
|
|
||||||
Timber.d("ICE connection state: %s", state)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onIceGatheringChange(state: PeerConnection.IceGatheringState) {
|
|
||||||
Timber.d("ICE gathering state: %s", state)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onSignalingChange(state: PeerConnection.SignalingState) {}
|
|
||||||
override fun onIceConnectionReceivingChange(receiving: Boolean) {}
|
|
||||||
override fun onIceCandidatesRemoved(candidates: Array<out IceCandidate>) {}
|
|
||||||
override fun onAddStream(stream: MediaStream) {}
|
|
||||||
override fun onRemoveStream(stream: MediaStream) {}
|
|
||||||
override fun onDataChannel(channel: DataChannel) {}
|
|
||||||
override fun onRenegotiationNeeded() {}
|
|
||||||
override fun onAddTrack(receiver: RtpReceiver, streams: Array<out MediaStream>) {}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun startCamera() {
|
|
||||||
val enumerator = Camera2Enumerator(context)
|
|
||||||
|
|
||||||
val cameraName = enumerator.deviceNames.firstOrNull {
|
|
||||||
enumerator.isFrontFacing(it)
|
|
||||||
} ?: enumerator.deviceNames.first()
|
|
||||||
|
|
||||||
videoCapturer = enumerator.createCapturer(cameraName, null)
|
|
||||||
|
|
||||||
surfaceTextureHelper = SurfaceTextureHelper.create(
|
|
||||||
"CameraThread",
|
|
||||||
eglBase.eglBaseContext
|
|
||||||
)
|
|
||||||
|
|
||||||
videoSource = factory.createVideoSource(false)
|
|
||||||
|
|
||||||
videoCapturer?.initialize(
|
|
||||||
surfaceTextureHelper,
|
|
||||||
context,
|
|
||||||
videoSource?.capturerObserver
|
|
||||||
)
|
|
||||||
|
|
||||||
videoCapturer?.startCapture(1280, 720, 30)
|
|
||||||
|
|
||||||
videoTrack = factory.createVideoTrack("video_track", videoSource)
|
|
||||||
|
|
||||||
peerConnection?.addTrack(
|
|
||||||
videoTrack,
|
|
||||||
listOf("posefit_stream")
|
|
||||||
)
|
|
||||||
|
|
||||||
Timber.d("Camera started: %s, resolution=1280x720@30fps", cameraName)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun createOffer() {
|
|
||||||
val constraints = MediaConstraints().apply {
|
|
||||||
mandatory.add(
|
|
||||||
MediaConstraints.KeyValuePair("OfferToReceiveVideo", "false")
|
|
||||||
)
|
|
||||||
mandatory.add(
|
|
||||||
MediaConstraints.KeyValuePair("OfferToReceiveAudio", "false")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
peerConnection?.createOffer(object : SdpObserver {
|
|
||||||
|
|
||||||
override fun onCreateSuccess(desc: SessionDescription) {
|
|
||||||
peerConnection?.setLocalDescription(object : SdpObserver {
|
|
||||||
override fun onSetSuccess() {
|
|
||||||
val json = JSONObject().apply {
|
|
||||||
put("type", desc.type.canonicalForm())
|
|
||||||
put("sdp", desc.description)
|
|
||||||
}
|
|
||||||
|
|
||||||
webSocket.send(json.toString())
|
|
||||||
Timber.d("Offer sent via signaling")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onSetFailure(error: String) {
|
|
||||||
Timber.e("setLocalDescription failed: %s", error)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreateSuccess(desc: SessionDescription) {}
|
|
||||||
override fun onCreateFailure(error: String) {
|
|
||||||
Timber.e("createOffer failed: %s", error)
|
|
||||||
}
|
|
||||||
}, desc)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onSetSuccess() {}
|
|
||||||
override fun onCreateFailure(error: String) {
|
|
||||||
Timber.e("createOffer failed: %s", error)
|
|
||||||
}
|
|
||||||
override fun onSetFailure(error: String) {
|
|
||||||
Timber.e("setLocalDescription failed: %s", error)
|
|
||||||
}
|
|
||||||
|
|
||||||
}, constraints)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleSignalingMessage(text: String) {
|
|
||||||
val json = JSONObject(text)
|
|
||||||
|
|
||||||
when (json.getString("type")) {
|
|
||||||
"answer" -> {
|
|
||||||
val sdp = SessionDescription(
|
|
||||||
SessionDescription.Type.ANSWER,
|
|
||||||
json.getString("sdp")
|
|
||||||
)
|
|
||||||
|
|
||||||
peerConnection?.setRemoteDescription(
|
|
||||||
SimpleSdpObserver(),
|
|
||||||
sdp
|
|
||||||
)
|
|
||||||
Timber.d("Answer received from server")
|
|
||||||
}
|
|
||||||
|
|
||||||
"candidate" -> {
|
|
||||||
val candidate = IceCandidate(
|
|
||||||
json.getString("sdpMid"),
|
|
||||||
json.getInt("sdpMLineIndex"),
|
|
||||||
json.getString("candidate")
|
|
||||||
)
|
|
||||||
|
|
||||||
peerConnection?.addIceCandidate(candidate)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun release() {
|
|
||||||
Timber.i("WebRTC releasing")
|
|
||||||
|
|
||||||
try {
|
|
||||||
videoCapturer?.stopCapture()
|
|
||||||
} catch (_: Exception) {
|
|
||||||
}
|
|
||||||
|
|
||||||
videoCapturer?.dispose()
|
|
||||||
videoSource?.dispose()
|
|
||||||
videoTrack?.dispose()
|
|
||||||
surfaceTextureHelper?.dispose()
|
|
||||||
peerConnection?.close()
|
|
||||||
peerConnection?.dispose()
|
|
||||||
factory.dispose()
|
|
||||||
eglBase.release()
|
|
||||||
webSocket.close(1000, "close")
|
|
||||||
okHttpClient.dispatcher.executorService.shutdown()
|
|
||||||
|
|
||||||
Timber.d("WebRTC released")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package com.kimgo.posefit
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.WindowManager
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import com.kimgo.posefit.sender.StreamOrientation
|
||||||
|
import com.kimgo.posefit.settings.StreamSettings
|
||||||
|
import com.kimgo.posefit.settings.StreamSettingsStore
|
||||||
|
import com.kimgo.posefit.ui.PosefitApp
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
|
class MainActivity : ComponentActivity() {
|
||||||
|
|
||||||
|
private lateinit var settingsStore: StreamSettingsStore
|
||||||
|
private var permissionResultHandler: ((Boolean) -> Unit)? = null
|
||||||
|
|
||||||
|
private val requestPermission =
|
||||||
|
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { grants ->
|
||||||
|
val cameraGranted = grants[Manifest.permission.CAMERA] == true
|
||||||
|
if (cameraGranted) {
|
||||||
|
startWebRtc(settingsStore.get())
|
||||||
|
} else {
|
||||||
|
Timber.w("Camera permission denied")
|
||||||
|
}
|
||||||
|
permissionResultHandler?.invoke(cameraGranted)
|
||||||
|
permissionResultHandler = null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
Timber.d("MainActivity onCreate")
|
||||||
|
|
||||||
|
settingsStore = StreamSettingsStore(this)
|
||||||
|
applyStreamOrientation(settingsStore.get().orientation)
|
||||||
|
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||||
|
|
||||||
|
setContent {
|
||||||
|
MaterialTheme {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
color = MaterialTheme.colorScheme.background,
|
||||||
|
) {
|
||||||
|
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) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyStreamOrientation(orientation: StreamOrientation) {
|
||||||
|
requestedOrientation = orientation.activityOrientation
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startWebRtc(settings: StreamSettings) {
|
||||||
|
Timber.i(
|
||||||
|
"startWebRtc: %s, cameraFacing=%s, cameraName=%s, resolution=%s, orientation=%s",
|
||||||
|
settings.signalingUrl,
|
||||||
|
settings.cameraFacing,
|
||||||
|
settings.cameraName,
|
||||||
|
settings.resolution.label,
|
||||||
|
settings.orientation,
|
||||||
|
)
|
||||||
|
applyStreamOrientation(settings.orientation)
|
||||||
|
val intent = PosefitStreamingService.startIntent(
|
||||||
|
context = this,
|
||||||
|
settings = settings,
|
||||||
|
)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
startForegroundService(intent)
|
||||||
|
} else {
|
||||||
|
startService(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopWebRtc() {
|
||||||
|
Timber.i("stopWebRtc")
|
||||||
|
startService(PosefitStreamingService.stopIntent(this))
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
||||||
|
super.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,462 @@
|
|||||||
|
package com.kimgo.posefit.sender
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import okhttp3.*
|
||||||
|
import org.json.JSONObject
|
||||||
|
import org.webrtc.*
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
|
class WebRtcSenderClient(
|
||||||
|
private val context: Context,
|
||||||
|
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 okHttpClient = OkHttpClient()
|
||||||
|
|
||||||
|
private lateinit var webSocket: WebSocket
|
||||||
|
private lateinit var factory: PeerConnectionFactory
|
||||||
|
private var peerConnection: PeerConnection? = null
|
||||||
|
|
||||||
|
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
|
||||||
|
private var capturedFrameCount = 0L
|
||||||
|
private var lastFrameSignature: String? = null
|
||||||
|
|
||||||
|
fun start() {
|
||||||
|
Timber.i(
|
||||||
|
"WebRTC starting, signalingUrl=%s, cameraFacing=%s, preferredCameraName=%s, resolution=%dx%d, orientation=%s",
|
||||||
|
signalingUrl,
|
||||||
|
cameraFacing,
|
||||||
|
preferredCameraName,
|
||||||
|
videoWidth,
|
||||||
|
videoHeight,
|
||||||
|
streamOrientation,
|
||||||
|
)
|
||||||
|
initPeerConnectionFactory()
|
||||||
|
connectSignaling()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun initPeerConnectionFactory() {
|
||||||
|
PeerConnectionFactory.initialize(
|
||||||
|
PeerConnectionFactory.InitializationOptions.builder(context)
|
||||||
|
.createInitializationOptions()
|
||||||
|
)
|
||||||
|
|
||||||
|
val encoderFactory = DefaultVideoEncoderFactory(
|
||||||
|
eglBase.eglBaseContext,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
)
|
||||||
|
|
||||||
|
val decoderFactory = DefaultVideoDecoderFactory(
|
||||||
|
eglBase.eglBaseContext
|
||||||
|
)
|
||||||
|
|
||||||
|
factory = PeerConnectionFactory.builder()
|
||||||
|
.setVideoEncoderFactory(encoderFactory)
|
||||||
|
.setVideoDecoderFactory(decoderFactory)
|
||||||
|
.createPeerConnectionFactory()
|
||||||
|
|
||||||
|
Timber.d("PeerConnectionFactory initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun connectSignaling() {
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(signalingUrl)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
webSocket = okHttpClient.newWebSocket(request, object : WebSocketListener() {
|
||||||
|
|
||||||
|
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||||
|
Timber.d("Signaling WebSocket connected")
|
||||||
|
createPeerConnection()
|
||||||
|
startCamera()
|
||||||
|
createOffer()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||||
|
handleSignalingMessage(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||||
|
Timber.e(t, "Signaling WebSocket failure")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||||
|
Timber.w("Signaling WebSocket closed, code=%d, reason=%s", code, reason)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createPeerConnection() {
|
||||||
|
val iceServers = listOf(
|
||||||
|
PeerConnection.IceServer.builder("stun:stun.l.google.com:19302")
|
||||||
|
.createIceServer()
|
||||||
|
)
|
||||||
|
|
||||||
|
val rtcConfig = PeerConnection.RTCConfiguration(iceServers).apply {
|
||||||
|
sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN
|
||||||
|
}
|
||||||
|
|
||||||
|
peerConnection = factory.createPeerConnection(
|
||||||
|
rtcConfig,
|
||||||
|
object : PeerConnection.Observer {
|
||||||
|
|
||||||
|
override fun onIceCandidate(candidate: IceCandidate) {
|
||||||
|
val json = JSONObject().apply {
|
||||||
|
put("type", "candidate")
|
||||||
|
put("sdpMid", candidate.sdpMid)
|
||||||
|
put("sdpMLineIndex", candidate.sdpMLineIndex)
|
||||||
|
put("candidate", candidate.sdp)
|
||||||
|
}
|
||||||
|
|
||||||
|
webSocket.send(json.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onIceConnectionChange(state: PeerConnection.IceConnectionState) {
|
||||||
|
Timber.d("ICE connection state: %s", state)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onIceGatheringChange(state: PeerConnection.IceGatheringState) {
|
||||||
|
Timber.d("ICE gathering state: %s", state)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSignalingChange(state: PeerConnection.SignalingState) {}
|
||||||
|
override fun onIceConnectionReceivingChange(receiving: Boolean) {}
|
||||||
|
override fun onIceCandidatesRemoved(candidates: Array<out IceCandidate>) {}
|
||||||
|
override fun onAddStream(stream: MediaStream) {}
|
||||||
|
override fun onRemoveStream(stream: MediaStream) {}
|
||||||
|
override fun onDataChannel(channel: DataChannel) {}
|
||||||
|
override fun onRenegotiationNeeded() {}
|
||||||
|
override fun onAddTrack(receiver: RtpReceiver, streams: Array<out MediaStream>) {}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startCamera() {
|
||||||
|
val enumerator = Camera2Enumerator(context)
|
||||||
|
|
||||||
|
val cameraName = selectCamera(enumerator)
|
||||||
|
|
||||||
|
videoCapturer = enumerator.createCapturer(cameraName, null)
|
||||||
|
|
||||||
|
surfaceTextureHelper = SurfaceTextureHelper.create(
|
||||||
|
"CameraThread",
|
||||||
|
eglBase.eglBaseContext
|
||||||
|
)
|
||||||
|
|
||||||
|
videoSource = factory.createVideoSource(false)
|
||||||
|
capturedFrameCount = 0L
|
||||||
|
lastFrameSignature = null
|
||||||
|
val capturerObserver = videoSource?.capturerObserver
|
||||||
|
?.let { createLoggingCapturerObserver(it) }
|
||||||
|
|
||||||
|
videoCapturer?.initialize(
|
||||||
|
surfaceTextureHelper,
|
||||||
|
context,
|
||||||
|
capturerObserver
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
videoSender = peerConnection?.addTrack(
|
||||||
|
videoTrack,
|
||||||
|
listOf("posefit_stream")
|
||||||
|
)
|
||||||
|
videoSender?.let { configureVideoSender(it) }
|
||||||
|
|
||||||
|
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() {
|
||||||
|
val constraints = MediaConstraints().apply {
|
||||||
|
mandatory.add(
|
||||||
|
MediaConstraints.KeyValuePair("OfferToReceiveVideo", "false")
|
||||||
|
)
|
||||||
|
mandatory.add(
|
||||||
|
MediaConstraints.KeyValuePair("OfferToReceiveAudio", "false")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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", fixedVideoDesc.type.canonicalForm())
|
||||||
|
put("sdp", fixedVideoDesc.description)
|
||||||
|
}
|
||||||
|
|
||||||
|
webSocket.send(json.toString())
|
||||||
|
Timber.d("Offer sent via signaling")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSetFailure(error: String) {
|
||||||
|
Timber.e("setLocalDescription failed: %s", error)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateSuccess(desc: SessionDescription) {}
|
||||||
|
override fun onCreateFailure(error: String) {
|
||||||
|
Timber.e("createOffer failed: %s", error)
|
||||||
|
}
|
||||||
|
}, fixedVideoDesc)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSetSuccess() {}
|
||||||
|
override fun onCreateFailure(error: String) {
|
||||||
|
Timber.e("createOffer failed: %s", error)
|
||||||
|
}
|
||||||
|
override fun onSetFailure(error: String) {
|
||||||
|
Timber.e("setLocalDescription failed: %s", error)
|
||||||
|
}
|
||||||
|
|
||||||
|
}, 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)
|
||||||
|
|
||||||
|
when (json.getString("type")) {
|
||||||
|
"answer" -> {
|
||||||
|
val sdp = SessionDescription(
|
||||||
|
SessionDescription.Type.ANSWER,
|
||||||
|
json.getString("sdp")
|
||||||
|
)
|
||||||
|
|
||||||
|
peerConnection?.setRemoteDescription(
|
||||||
|
SimpleSdpObserver(),
|
||||||
|
sdp
|
||||||
|
)
|
||||||
|
Timber.d("Answer received from server")
|
||||||
|
}
|
||||||
|
|
||||||
|
"candidate" -> {
|
||||||
|
val candidate = IceCandidate(
|
||||||
|
json.getString("sdpMid"),
|
||||||
|
json.getInt("sdpMLineIndex"),
|
||||||
|
json.getString("candidate")
|
||||||
|
)
|
||||||
|
|
||||||
|
peerConnection?.addIceCandidate(candidate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun release() {
|
||||||
|
Timber.i("WebRTC releasing")
|
||||||
|
|
||||||
|
try {
|
||||||
|
videoCapturer?.stopCapture()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
|
||||||
|
videoCapturer?.dispose()
|
||||||
|
videoSource?.dispose()
|
||||||
|
videoTrack?.dispose()
|
||||||
|
videoSender = null
|
||||||
|
surfaceTextureHelper?.dispose()
|
||||||
|
peerConnection?.close()
|
||||||
|
peerConnection?.dispose()
|
||||||
|
if (::factory.isInitialized) {
|
||||||
|
factory.dispose()
|
||||||
|
}
|
||||||
|
eglBase.release()
|
||||||
|
if (::webSocket.isInitialized) {
|
||||||
|
webSocket.close(1000, "close")
|
||||||
|
}
|
||||||
|
okHttpClient.dispatcher.executorService.shutdown()
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
@@ -7,7 +7,6 @@
|
|||||||
# Specifies the JVM arguments used for the daemon process.
|
# Specifies the JVM arguments used for the daemon process.
|
||||||
# The setting is particularly useful for tweaking memory settings.
|
# The setting is particularly useful for tweaking memory settings.
|
||||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
org.gradle.java.home=C\:\\Program Files\\Java\\jdk-17
|
|
||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
# When configured, Gradle will run in incubating parallel mode.
|
# When configured, Gradle will run in incubating parallel mode.
|
||||||
# This option should only be used with decoupled projects. For more details, visit
|
# This option should only be used with decoupled projects. For more details, visit
|
||||||
|
|||||||
Reference in New Issue
Block a user