diff --git a/app/src/main/kotlin/com/kimgo/posefit/MainActivity.kt b/app/src/main/kotlin/com/kimgo/posefit/MainActivity.kt index fede724..4f93d32 100644 --- a/app/src/main/kotlin/com/kimgo/posefit/MainActivity.kt +++ b/app/src/main/kotlin/com/kimgo/posefit/MainActivity.kt @@ -1,65 +1,32 @@ package com.kimgo.posefit import android.Manifest -import android.content.Context 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.rememberScrollState -import androidx.compose.foundation.verticalScroll -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.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.Surface -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.PosefitStreamingService.Companion.KEY_CAMERA_FACING -import com.kimgo.posefit.PosefitStreamingService.Companion.KEY_CAMERA_NAME -import com.kimgo.posefit.PosefitStreamingService.Companion.KEY_SIGNALING_URL -import com.kimgo.posefit.PosefitStreamingService.Companion.KEY_STREAM_ORIENTATION -import com.kimgo.posefit.PosefitStreamingService.Companion.KEY_VIDEO_HEIGHT -import com.kimgo.posefit.PosefitStreamingService.Companion.KEY_VIDEO_WIDTH -import com.kimgo.posefit.PosefitStreamingService.Companion.PREFS_NAME -import com.kimgo.posefit.sender.CameraCatalog -import com.kimgo.posefit.sender.CameraFacing import com.kimgo.posefit.sender.StreamOrientation -import com.kimgo.posefit.sender.StreamResolution +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(getSavedSettings()) + startWebRtc(settingsStore.get()) } else { Timber.w("Camera permission denied") } @@ -70,7 +37,9 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) Timber.d("MainActivity onCreate") - applyStreamOrientation(getSavedOrientation()) + + settingsStore = StreamSettingsStore(this) + applyStreamOrientation(settingsStore.get().orientation) window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) setContent { @@ -79,395 +48,27 @@ class MainActivity : ComponentActivity() { modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background, ) { - PosefitApp() - } - } - } - } - - @Composable - private fun PosefitApp() { - var screen by remember { mutableStateOf(AppScreen.HOME) } - var isStreaming by remember { mutableStateOf(PosefitStreamingService.isRunning) } - var settings by remember { mutableStateOf(getSavedSettings()) } - - when (screen) { - AppScreen.HOME -> HomeScreen( - isStreaming = isStreaming, - settings = settings, - onToggleStreaming = { - if (isStreaming) { - stopWebRtc() - isStreaming = false - } else { - saveSettings(settings) - applyStreamOrientation(settings.orientation) - permissionResultHandler = { granted -> - isStreaming = granted - } - requestPermission.launch(requiredPermissions()) - } - }, - onOpenSettings = { screen = AppScreen.SETTINGS }, - onRotate = { - val updated = settings.copy(orientation = settings.orientation.next()) - settings = updated - saveSettings(updated) - applyStreamOrientation(updated.orientation) - }, - ) - - AppScreen.SETTINGS -> SettingsScreen( - initialSettings = settings, - isStreaming = isStreaming, - onSave = { updated -> - settings = updated - saveSettings(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( - 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(this) }.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, - ) + 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) }, ) - }, - 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 getSavedSettings(): StreamSettings { - return StreamSettings( - signalingUrl = getSavedSignalingUrl(), - cameraFacing = getSavedCameraFacing(), - cameraName = getSavedCameraName(), - resolution = getSavedResolution(), - orientation = getSavedOrientation(), - ) - } - - private fun saveSettings(settings: StreamSettings) { - getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - .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() - } - - private fun getSavedSignalingUrl(): String { - val prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - return prefs.getString(KEY_SIGNALING_URL, DEFAULT_SIGNALING_URL) ?: DEFAULT_SIGNALING_URL - } - - private fun getSavedCameraFacing(): CameraFacing { - val prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - val value = prefs.getString(KEY_CAMERA_FACING, CameraFacing.BACK.name) - return runCatching { CameraFacing.valueOf(value ?: CameraFacing.BACK.name) } - .getOrDefault(CameraFacing.BACK) - } - - private fun getSavedCameraName(): String? { - val prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - return prefs.getString(KEY_CAMERA_NAME, null)?.takeIf { it.isNotBlank() } - } - - private fun getSavedResolution(): StreamResolution { - val prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - return StreamResolution.from( - prefs.getInt(KEY_VIDEO_WIDTH, StreamResolution.DEFAULT.width), - prefs.getInt(KEY_VIDEO_HEIGHT, StreamResolution.DEFAULT.height), - ) - } - - private fun getSavedOrientation(): StreamOrientation { - val prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - val value = prefs.getString(KEY_STREAM_ORIENTATION, StreamOrientation.DEFAULT.name) - return runCatching { StreamOrientation.valueOf(value ?: StreamOrientation.DEFAULT.name) } - .getOrDefault(StreamOrientation.DEFAULT) - } - private fun applyStreamOrientation(orientation: StreamOrientation) { requestedOrientation = orientation.activityOrientation } - private fun resolveSelectedCameraOption( - cameraOptions: List, - selectedCameraName: String?, - ): com.kimgo.posefit.sender.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, - 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 fun startWebRtc(settings: StreamSettings) { Timber.i( "startWebRtc: %s, cameraFacing=%s, cameraName=%s, resolution=%s, orientation=%s", @@ -480,11 +81,7 @@ class MainActivity : ComponentActivity() { applyStreamOrientation(settings.orientation) val intent = PosefitStreamingService.startIntent( context = this, - signalingUrl = settings.signalingUrl, - cameraFacing = settings.cameraFacing, - preferredCameraName = settings.cameraName, - resolution = settings.resolution, - orientation = settings.orientation, + settings = settings, ) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { startForegroundService(intent) @@ -511,21 +108,4 @@ class MainActivity : ComponentActivity() { super.onDestroy() Timber.d("MainActivity onDestroy") } - - private enum class AppScreen { - HOME, - SETTINGS, - } - - private data class StreamSettings( - val signalingUrl: String, - val cameraFacing: CameraFacing, - val cameraName: String?, - val resolution: StreamResolution, - val orientation: StreamOrientation, - ) - - private companion object { - private const val DEFAULT_SIGNALING_URL = "ws://192.168.2.6:8765" - } } diff --git a/app/src/main/kotlin/com/kimgo/posefit/PosefitStreamingService.kt b/app/src/main/kotlin/com/kimgo/posefit/PosefitStreamingService.kt index ea043ca..c89d5e2 100644 --- a/app/src/main/kotlin/com/kimgo/posefit/PosefitStreamingService.kt +++ b/app/src/main/kotlin/com/kimgo/posefit/PosefitStreamingService.kt @@ -13,6 +13,8 @@ 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() { @@ -34,11 +36,12 @@ class PosefitStreamingService : Service() { ACTION_START -> { val settings = StreamSettings( - signalingUrl = intent.getStringExtra(EXTRA_SIGNALING_URL) ?: DEFAULT_SIGNALING_URL, + signalingUrl = intent.getStringExtra(EXTRA_SIGNALING_URL) + ?: StreamSettingsStore.DEFAULT_SIGNALING_URL, cameraFacing = intent.getStringExtra(EXTRA_CAMERA_FACING) ?.let { runCatching { CameraFacing.valueOf(it) }.getOrNull() } ?: CameraFacing.BACK, - preferredCameraName = intent.getStringExtra(EXTRA_CAMERA_NAME), + 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), @@ -53,7 +56,7 @@ class PosefitStreamingService : Service() { } else -> { - val settings = getSavedSettings() + val settings = StreamSettingsStore(this).get() startForegroundNotification(settings) startStreaming(settings) return START_STICKY @@ -73,7 +76,7 @@ class PosefitStreamingService : Service() { "Streaming service start: %s, cameraFacing=%s, preferredCameraName=%s, resolution=%s, orientation=%s", settings.signalingUrl, settings.cameraFacing, - settings.preferredCameraName, + settings.cameraName, settings.resolution.label, settings.orientation, ) @@ -82,7 +85,7 @@ class PosefitStreamingService : Service() { context = applicationContext, signalingUrl = settings.signalingUrl, cameraFacing = settings.cameraFacing, - preferredCameraName = settings.preferredCameraName, + preferredCameraName = settings.cameraName, videoWidth = settings.resolution.width, videoHeight = settings.resolution.height, streamOrientation = settings.orientation, @@ -111,7 +114,7 @@ class PosefitStreamingService : Service() { CameraFacing.FRONT -> "前置摄像头" CameraFacing.BACK -> "后置摄像头" } - val cameraModeText = settings.preferredCameraName + val cameraModeText = settings.cameraName ?.let { "$cameraText $it" } ?: "$cameraText 自动最广角" val streamText = "${settings.resolution.label} ${settings.orientation.label}" @@ -147,33 +150,6 @@ class PosefitStreamingService : Service() { manager.createNotificationChannel(channel) } - private fun getSavedSettings(): StreamSettings { - val prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - 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), - preferredCameraName = 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), - ) - } - - private data class StreamSettings( - val signalingUrl: String, - val cameraFacing: CameraFacing, - val preferredCameraName: String?, - val resolution: StreamResolution, - val orientation: StreamOrientation, - ) - companion object { private const val CHANNEL_ID = "posefit_streaming" private const val NOTIFICATION_ID = 1001 @@ -185,15 +161,6 @@ class PosefitStreamingService : Service() { 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" - private const val DEFAULT_SIGNALING_URL = "ws://192.168.2.6:8765" - - 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" @Volatile var isRunning: Boolean = false @@ -201,20 +168,16 @@ class PosefitStreamingService : Service() { fun startIntent( context: Context, - signalingUrl: String, - cameraFacing: CameraFacing, - preferredCameraName: String?, - resolution: StreamResolution, - orientation: StreamOrientation, + settings: StreamSettings, ): 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) - putExtra(EXTRA_VIDEO_WIDTH, resolution.width) - putExtra(EXTRA_VIDEO_HEIGHT, resolution.height) - putExtra(EXTRA_STREAM_ORIENTATION, orientation.name) + 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) } } diff --git a/app/src/main/kotlin/com/kimgo/posefit/settings/StreamSettings.kt b/app/src/main/kotlin/com/kimgo/posefit/settings/StreamSettings.kt new file mode 100644 index 0000000..0954ec9 --- /dev/null +++ b/app/src/main/kotlin/com/kimgo/posefit/settings/StreamSettings.kt @@ -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, +) diff --git a/app/src/main/kotlin/com/kimgo/posefit/settings/StreamSettingsStore.kt b/app/src/main/kotlin/com/kimgo/posefit/settings/StreamSettingsStore.kt new file mode 100644 index 0000000..4c4eb93 --- /dev/null +++ b/app/src/main/kotlin/com/kimgo/posefit/settings/StreamSettingsStore.kt @@ -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" + } +} diff --git a/app/src/main/kotlin/com/kimgo/posefit/ui/PosefitApp.kt b/app/src/main/kotlin/com/kimgo/posefit/ui/PosefitApp.kt new file mode 100644 index 0000000..347f710 --- /dev/null +++ b/app/src/main/kotlin/com/kimgo/posefit/ui/PosefitApp.kt @@ -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, + 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, + 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, +}