refactor(app): 重构应用架构并优化设置管理

- 将 MainActivity 中的 UI 组件提取到独立的 PosefitApp Composable 函数中
- 创建 StreamSettings 和 StreamSettingsStore 类以统一管理流媒体设置
- 移除 MainActivity 中大量的内联 Composable 函数和状态管理代码
- 更新 PosefitStreamingService 以使用新的设置存储机制
- 简化服务启动逻辑并将 StreamSettings 对象作为参数传递
- 迁移所有设置相关的 SharedPreferences 操作到专门的设置存储类中
This commit is contained in:
2026-06-10 11:30:04 +08:00
parent 8c8239502e
commit d451507589
5 changed files with 477 additions and 494 deletions
@@ -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 requestPermission.launch(requiredPermissions())
private fun PosefitApp() { },
var screen by remember { mutableStateOf(AppScreen.HOME) } onStopStreaming = ::stopWebRtc,
var isStreaming by remember { mutableStateOf(PosefitStreamingService.isRunning) } onSaveSettings = settingsStore::save,
var settings by remember { mutableStateOf(getSavedSettings()) } onApplyOrientation = { applyStreamOrientation(it.orientation) },
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,
)
) )
}, }
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,
}