refactor(app): 重构应用架构并优化设置管理
- 将 MainActivity 中的 UI 组件提取到独立的 PosefitApp Composable 函数中 - 创建 StreamSettings 和 StreamSettingsStore 类以统一管理流媒体设置 - 移除 MainActivity 中大量的内联 Composable 函数和状态管理代码 - 更新 PosefitStreamingService 以使用新的设置存储机制 - 简化服务启动逻辑并将 StreamSettings 对象作为参数传递 - 迁移所有设置相关的 SharedPreferences 操作到专门的设置存储类中
This commit is contained in:
@@ -1,65 +1,32 @@
|
|||||||
package com.kimgo.posefit
|
package com.kimgo.posefit
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.content.Context
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.WindowManager
|
import android.view.WindowManager
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
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.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.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
|
||||||
import androidx.compose.material3.Surface
|
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.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.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
|
import timber.log.Timber
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
|
|
||||||
|
private lateinit var settingsStore: StreamSettingsStore
|
||||||
private var permissionResultHandler: ((Boolean) -> Unit)? = null
|
private var permissionResultHandler: ((Boolean) -> Unit)? = null
|
||||||
|
|
||||||
private val requestPermission =
|
private val requestPermission =
|
||||||
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { grants ->
|
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { grants ->
|
||||||
val cameraGranted = grants[Manifest.permission.CAMERA] == true
|
val cameraGranted = grants[Manifest.permission.CAMERA] == true
|
||||||
if (cameraGranted) {
|
if (cameraGranted) {
|
||||||
startWebRtc(getSavedSettings())
|
startWebRtc(settingsStore.get())
|
||||||
} else {
|
} else {
|
||||||
Timber.w("Camera permission denied")
|
Timber.w("Camera permission denied")
|
||||||
}
|
}
|
||||||
@@ -70,7 +37,9 @@ class MainActivity : ComponentActivity() {
|
|||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
Timber.d("MainActivity onCreate")
|
Timber.d("MainActivity onCreate")
|
||||||
applyStreamOrientation(getSavedOrientation())
|
|
||||||
|
settingsStore = StreamSettingsStore(this)
|
||||||
|
applyStreamOrientation(settingsStore.get().orientation)
|
||||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
@@ -79,395 +48,27 @@ class MainActivity : ComponentActivity() {
|
|||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
color = MaterialTheme.colorScheme.background,
|
color = MaterialTheme.colorScheme.background,
|
||||||
) {
|
) {
|
||||||
PosefitApp()
|
PosefitApp(
|
||||||
}
|
context = this,
|
||||||
}
|
initialSettings = settingsStore.get(),
|
||||||
}
|
isInitiallyStreaming = PosefitStreamingService.isRunning,
|
||||||
}
|
onStartStreaming = { _, onPermissionResult ->
|
||||||
|
permissionResultHandler = onPermissionResult
|
||||||
@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())
|
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
|
|
||||||
},
|
},
|
||||||
|
onStopStreaming = ::stopWebRtc,
|
||||||
|
onSaveSettings = settingsStore::save,
|
||||||
|
onApplyOrientation = { applyStreamOrientation(it.orientation) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 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) {
|
private fun applyStreamOrientation(orientation: StreamOrientation) {
|
||||||
requestedOrientation = orientation.activityOrientation
|
requestedOrientation = orientation.activityOrientation
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun resolveSelectedCameraOption(
|
|
||||||
cameraOptions: List<com.kimgo.posefit.sender.CameraOption>,
|
|
||||||
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<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 fun startWebRtc(settings: StreamSettings) {
|
private fun startWebRtc(settings: StreamSettings) {
|
||||||
Timber.i(
|
Timber.i(
|
||||||
"startWebRtc: %s, cameraFacing=%s, cameraName=%s, resolution=%s, orientation=%s",
|
"startWebRtc: %s, cameraFacing=%s, cameraName=%s, resolution=%s, orientation=%s",
|
||||||
@@ -480,11 +81,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
applyStreamOrientation(settings.orientation)
|
applyStreamOrientation(settings.orientation)
|
||||||
val intent = PosefitStreamingService.startIntent(
|
val intent = PosefitStreamingService.startIntent(
|
||||||
context = this,
|
context = this,
|
||||||
signalingUrl = settings.signalingUrl,
|
settings = settings,
|
||||||
cameraFacing = settings.cameraFacing,
|
|
||||||
preferredCameraName = settings.cameraName,
|
|
||||||
resolution = settings.resolution,
|
|
||||||
orientation = settings.orientation,
|
|
||||||
)
|
)
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
startForegroundService(intent)
|
startForegroundService(intent)
|
||||||
@@ -511,21 +108,4 @@ class MainActivity : ComponentActivity() {
|
|||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
Timber.d("MainActivity 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"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import com.kimgo.posefit.sender.CameraFacing
|
|||||||
import com.kimgo.posefit.sender.StreamOrientation
|
import com.kimgo.posefit.sender.StreamOrientation
|
||||||
import com.kimgo.posefit.sender.StreamResolution
|
import com.kimgo.posefit.sender.StreamResolution
|
||||||
import com.kimgo.posefit.sender.WebRtcSenderClient
|
import com.kimgo.posefit.sender.WebRtcSenderClient
|
||||||
|
import com.kimgo.posefit.settings.StreamSettings
|
||||||
|
import com.kimgo.posefit.settings.StreamSettingsStore
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
||||||
class PosefitStreamingService : Service() {
|
class PosefitStreamingService : Service() {
|
||||||
@@ -34,11 +36,12 @@ class PosefitStreamingService : Service() {
|
|||||||
|
|
||||||
ACTION_START -> {
|
ACTION_START -> {
|
||||||
val settings = StreamSettings(
|
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)
|
cameraFacing = intent.getStringExtra(EXTRA_CAMERA_FACING)
|
||||||
?.let { runCatching { CameraFacing.valueOf(it) }.getOrNull() }
|
?.let { runCatching { CameraFacing.valueOf(it) }.getOrNull() }
|
||||||
?: CameraFacing.BACK,
|
?: CameraFacing.BACK,
|
||||||
preferredCameraName = intent.getStringExtra(EXTRA_CAMERA_NAME),
|
cameraName = intent.getStringExtra(EXTRA_CAMERA_NAME),
|
||||||
resolution = StreamResolution.from(
|
resolution = StreamResolution.from(
|
||||||
intent.getIntExtra(EXTRA_VIDEO_WIDTH, StreamResolution.DEFAULT.width),
|
intent.getIntExtra(EXTRA_VIDEO_WIDTH, StreamResolution.DEFAULT.width),
|
||||||
intent.getIntExtra(EXTRA_VIDEO_HEIGHT, StreamResolution.DEFAULT.height),
|
intent.getIntExtra(EXTRA_VIDEO_HEIGHT, StreamResolution.DEFAULT.height),
|
||||||
@@ -53,7 +56,7 @@ class PosefitStreamingService : Service() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
val settings = getSavedSettings()
|
val settings = StreamSettingsStore(this).get()
|
||||||
startForegroundNotification(settings)
|
startForegroundNotification(settings)
|
||||||
startStreaming(settings)
|
startStreaming(settings)
|
||||||
return START_STICKY
|
return START_STICKY
|
||||||
@@ -73,7 +76,7 @@ class PosefitStreamingService : Service() {
|
|||||||
"Streaming service start: %s, cameraFacing=%s, preferredCameraName=%s, resolution=%s, orientation=%s",
|
"Streaming service start: %s, cameraFacing=%s, preferredCameraName=%s, resolution=%s, orientation=%s",
|
||||||
settings.signalingUrl,
|
settings.signalingUrl,
|
||||||
settings.cameraFacing,
|
settings.cameraFacing,
|
||||||
settings.preferredCameraName,
|
settings.cameraName,
|
||||||
settings.resolution.label,
|
settings.resolution.label,
|
||||||
settings.orientation,
|
settings.orientation,
|
||||||
)
|
)
|
||||||
@@ -82,7 +85,7 @@ class PosefitStreamingService : Service() {
|
|||||||
context = applicationContext,
|
context = applicationContext,
|
||||||
signalingUrl = settings.signalingUrl,
|
signalingUrl = settings.signalingUrl,
|
||||||
cameraFacing = settings.cameraFacing,
|
cameraFacing = settings.cameraFacing,
|
||||||
preferredCameraName = settings.preferredCameraName,
|
preferredCameraName = settings.cameraName,
|
||||||
videoWidth = settings.resolution.width,
|
videoWidth = settings.resolution.width,
|
||||||
videoHeight = settings.resolution.height,
|
videoHeight = settings.resolution.height,
|
||||||
streamOrientation = settings.orientation,
|
streamOrientation = settings.orientation,
|
||||||
@@ -111,7 +114,7 @@ class PosefitStreamingService : Service() {
|
|||||||
CameraFacing.FRONT -> "前置摄像头"
|
CameraFacing.FRONT -> "前置摄像头"
|
||||||
CameraFacing.BACK -> "后置摄像头"
|
CameraFacing.BACK -> "后置摄像头"
|
||||||
}
|
}
|
||||||
val cameraModeText = settings.preferredCameraName
|
val cameraModeText = settings.cameraName
|
||||||
?.let { "$cameraText $it" }
|
?.let { "$cameraText $it" }
|
||||||
?: "$cameraText 自动最广角"
|
?: "$cameraText 自动最广角"
|
||||||
val streamText = "${settings.resolution.label} ${settings.orientation.label}"
|
val streamText = "${settings.resolution.label} ${settings.orientation.label}"
|
||||||
@@ -147,33 +150,6 @@ class PosefitStreamingService : Service() {
|
|||||||
manager.createNotificationChannel(channel)
|
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 {
|
companion object {
|
||||||
private const val CHANNEL_ID = "posefit_streaming"
|
private const val CHANNEL_ID = "posefit_streaming"
|
||||||
private const val NOTIFICATION_ID = 1001
|
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_WIDTH = "extra_video_width"
|
||||||
private const val EXTRA_VIDEO_HEIGHT = "extra_video_height"
|
private const val EXTRA_VIDEO_HEIGHT = "extra_video_height"
|
||||||
private const val EXTRA_STREAM_ORIENTATION = "extra_stream_orientation"
|
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
|
@Volatile
|
||||||
var isRunning: Boolean = false
|
var isRunning: Boolean = false
|
||||||
@@ -201,20 +168,16 @@ class PosefitStreamingService : Service() {
|
|||||||
|
|
||||||
fun startIntent(
|
fun startIntent(
|
||||||
context: Context,
|
context: Context,
|
||||||
signalingUrl: String,
|
settings: StreamSettings,
|
||||||
cameraFacing: CameraFacing,
|
|
||||||
preferredCameraName: String?,
|
|
||||||
resolution: StreamResolution,
|
|
||||||
orientation: StreamOrientation,
|
|
||||||
): Intent {
|
): Intent {
|
||||||
return Intent(context, PosefitStreamingService::class.java).apply {
|
return Intent(context, PosefitStreamingService::class.java).apply {
|
||||||
action = ACTION_START
|
action = ACTION_START
|
||||||
putExtra(EXTRA_SIGNALING_URL, signalingUrl)
|
putExtra(EXTRA_SIGNALING_URL, settings.signalingUrl)
|
||||||
putExtra(EXTRA_CAMERA_FACING, cameraFacing.name)
|
putExtra(EXTRA_CAMERA_FACING, settings.cameraFacing.name)
|
||||||
putExtra(EXTRA_CAMERA_NAME, preferredCameraName)
|
putExtra(EXTRA_CAMERA_NAME, settings.cameraName)
|
||||||
putExtra(EXTRA_VIDEO_WIDTH, resolution.width)
|
putExtra(EXTRA_VIDEO_WIDTH, settings.resolution.width)
|
||||||
putExtra(EXTRA_VIDEO_HEIGHT, resolution.height)
|
putExtra(EXTRA_VIDEO_HEIGHT, settings.resolution.height)
|
||||||
putExtra(EXTRA_STREAM_ORIENTATION, orientation.name)
|
putExtra(EXTRA_STREAM_ORIENTATION, settings.orientation.name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user