Reorganize settings into sub-screens with per-platform controller layouts

Move controller-related settings (devices, remap, button mapping, touch
overlay, layout editor) into a dedicated ControllerSettingsScreen. Add
per-platform layout entries for all implemented consoles and make layout
editor button labels platform-aware. Auto-focus search field on open.
This commit is contained in:
2026-04-12 16:15:53 -04:00
parent 89f590480f
commit f19aeffdce
6 changed files with 620 additions and 502 deletions

View File

@@ -16,6 +16,7 @@ import com.lazy.emulate.ui.screens.controller.ControllerLayoutScreen
import com.lazy.emulate.ui.screens.controller.ControllerTestScreen import com.lazy.emulate.ui.screens.controller.ControllerTestScreen
import com.lazy.emulate.ui.screens.game.GameScreen import com.lazy.emulate.ui.screens.game.GameScreen
import com.lazy.emulate.ui.screens.home.HomeScreen import com.lazy.emulate.ui.screens.home.HomeScreen
import com.lazy.emulate.ui.screens.settings.ControllerSettingsScreen
import com.lazy.emulate.ui.screens.settings.SettingsScreen import com.lazy.emulate.ui.screens.settings.SettingsScreen
@Composable @Composable
@@ -40,20 +41,28 @@ fun EmulateNavGraph(
}, },
onSettingsClick = { onSettingsClick = {
navController.navigate(Screen.Settings.route) navController.navigate(Screen.Settings.route)
},
onControllerLayoutClick = { consoleType ->
navController.navigate(Screen.ControllerLayout.createRoute(consoleType.name))
} }
) )
} }
composable(Screen.Settings.route) { composable(Screen.Settings.route) {
SettingsScreen( SettingsScreen(
preferencesManager = preferencesManager,
onBack = { navController.popBackStack() },
onControllerSettings = { navController.navigate(Screen.ControllerSettings.route) }
)
}
composable(Screen.ControllerSettings.route) {
ControllerSettingsScreen(
controllerManager = controllerManager, controllerManager = controllerManager,
preferencesManager = preferencesManager, preferencesManager = preferencesManager,
buttonMappingManager = buttonMappingManager, buttonMappingManager = buttonMappingManager,
onBack = { navController.popBackStack() }, onBack = { navController.popBackStack() },
onTestController = { navController.navigate(Screen.ControllerTest.route) } onTestController = { navController.navigate(Screen.ControllerTest.route) },
onControllerLayout = { consoleType ->
navController.navigate(Screen.ControllerLayout.createRoute(consoleType.name))
}
) )
} }

View File

@@ -3,6 +3,7 @@ package com.lazy.emulate.ui.navigation
sealed class Screen(val route: String) { sealed class Screen(val route: String) {
data object Home : Screen("home") data object Home : Screen("home")
data object Settings : Screen("settings") data object Settings : Screen("settings")
data object ControllerSettings : Screen("controller_settings")
data object ControllerTest : Screen("controller_test") data object ControllerTest : Screen("controller_test")
data object ControllerLayout : Screen("controller_layout/{consoleType}") { data object ControllerLayout : Screen("controller_layout/{consoleType}") {
fun createRoute(consoleType: String) = "controller_layout/$consoleType" fun createRoute(consoleType: String) = "controller_layout/$consoleType"

View File

@@ -97,6 +97,7 @@ fun ControllerLayoutScreen(
layout.buttons.forEach { buttonPos -> layout.buttons.forEach { buttonPos ->
DraggableButton( DraggableButton(
buttonPosition = buttonPos, buttonPosition = buttonPos,
consoleType = consoleType,
screenWidth = screenWidth, screenWidth = screenWidth,
screenHeight = screenHeight, screenHeight = screenHeight,
opacity = opacity, opacity = opacity,
@@ -170,6 +171,7 @@ fun ControllerLayoutScreen(
@Composable @Composable
private fun DraggableButton( private fun DraggableButton(
buttonPosition: ButtonPosition, buttonPosition: ButtonPosition,
consoleType: ConsoleType,
screenWidth: Float, screenWidth: Float,
screenHeight: Float, screenHeight: Float,
opacity: Float, opacity: Float,
@@ -198,18 +200,16 @@ private fun DraggableButton(
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text( Text(
text = buttonPosition.button.toLabel(), text = buttonPosition.button.toLabel(consoleType),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = Color.White.copy(alpha = opacity) color = Color.White.copy(alpha = opacity)
) )
} }
} }
private fun GamepadButton.toLabel(): String = when (this) { private fun GamepadButton.toLabel(consoleType: ConsoleType): String {
GamepadButton.FACE_BOTTOM -> "X" // Shared labels for non-face buttons
GamepadButton.FACE_RIGHT -> "O" val shared = when (this) {
GamepadButton.FACE_LEFT -> "[]"
GamepadButton.FACE_TOP -> "/\\"
GamepadButton.DPAD_UP -> "Up" GamepadButton.DPAD_UP -> "Up"
GamepadButton.DPAD_DOWN -> "Dn" GamepadButton.DPAD_DOWN -> "Dn"
GamepadButton.DPAD_LEFT -> "Lt" GamepadButton.DPAD_LEFT -> "Lt"
@@ -225,4 +225,45 @@ private fun GamepadButton.toLabel(): String = when (this) {
GamepadButton.MODE -> "MD" GamepadButton.MODE -> "MD"
GamepadButton.Z_BUTTON -> "Z" GamepadButton.Z_BUTTON -> "Z"
GamepadButton.C_BUTTON -> "C" GamepadButton.C_BUTTON -> "C"
else -> null
}
if (shared != null) return shared
return when (consoleType) {
ConsoleType.PS1, ConsoleType.PS2 -> when (this) {
GamepadButton.FACE_BOTTOM -> "X"
GamepadButton.FACE_RIGHT -> "O"
GamepadButton.FACE_LEFT -> "[]"
GamepadButton.FACE_TOP -> "/\\"
else -> ""
}
ConsoleType.NES -> when (this) {
GamepadButton.FACE_BOTTOM -> "A"
GamepadButton.FACE_RIGHT -> "B"
GamepadButton.FACE_LEFT -> "A"
GamepadButton.FACE_TOP -> "B"
else -> ""
}
ConsoleType.SNES -> when (this) {
GamepadButton.FACE_BOTTOM -> "B"
GamepadButton.FACE_RIGHT -> "A"
GamepadButton.FACE_LEFT -> "Y"
GamepadButton.FACE_TOP -> "X"
else -> ""
}
ConsoleType.N64 -> when (this) {
GamepadButton.FACE_BOTTOM -> "A"
GamepadButton.FACE_RIGHT -> "B"
GamepadButton.FACE_LEFT -> "A"
GamepadButton.FACE_TOP -> "B"
else -> ""
}
ConsoleType.GENESIS -> when (this) {
GamepadButton.FACE_BOTTOM -> "A"
GamepadButton.FACE_RIGHT -> "B"
GamepadButton.FACE_LEFT -> "X"
GamepadButton.FACE_TOP -> "Y"
else -> ""
}
}
} }

View File

@@ -19,7 +19,6 @@ import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Gamepad
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.SportsEsports import androidx.compose.material.icons.filled.SportsEsports
@@ -36,6 +35,7 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.windowsizeclass.WindowSizeClass import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -44,6 +44,8 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.lazy.emulate.data.model.Game import com.lazy.emulate.data.model.Game
import com.lazy.emulate.data.repository.GameRepository import com.lazy.emulate.data.repository.GameRepository
@@ -65,12 +67,12 @@ fun HomeScreen(
windowSizeClass: WindowSizeClass, windowSizeClass: WindowSizeClass,
gameRepository: GameRepository, gameRepository: GameRepository,
onGameSelected: (Game) -> Unit, onGameSelected: (Game) -> Unit,
onSettingsClick: () -> Unit, onSettingsClick: () -> Unit
onControllerLayoutClick: (ConsoleType) -> Unit
) { ) {
val games by gameRepository.games.collectAsState() val games by gameRepository.games.collectAsState()
var searchQuery by remember { mutableStateOf("") } var searchQuery by remember { mutableStateOf("") }
var searchActive by remember { mutableStateOf(false) } var searchActive by remember { mutableStateOf(false) }
val searchFocusRequester = remember { FocusRequester() }
val layoutType = windowSizeClass.toLayoutType() val layoutType = windowSizeClass.toLayoutType()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -100,10 +102,14 @@ fun HomeScreen(
TopAppBar( TopAppBar(
title = { title = {
if (searchActive) { if (searchActive) {
LaunchedEffect(Unit) {
searchFocusRequester.requestFocus()
}
androidx.compose.foundation.text.BasicTextField( androidx.compose.foundation.text.BasicTextField(
value = searchQuery, value = searchQuery,
onValueChange = { searchQuery = it }, onValueChange = { searchQuery = it },
singleLine = true, singleLine = true,
modifier = Modifier.focusRequester(searchFocusRequester),
textStyle = MaterialTheme.typography.bodyLarge.copy( textStyle = MaterialTheme.typography.bodyLarge.copy(
color = MaterialTheme.colorScheme.onSurface color = MaterialTheme.colorScheme.onSurface
), ),
@@ -146,13 +152,6 @@ fun HomeScreen(
Icon(Icons.Default.Search, contentDescription = "Search") Icon(Icons.Default.Search, contentDescription = "Search")
} }
} }
val controllerConsole = when (currentTab) {
is HomeTab.Console -> currentTab.consoleType
else -> ConsoleType.PS1
}
IconButton(onClick = { onControllerLayoutClick(controllerConsole) }) {
Icon(Icons.Default.Gamepad, contentDescription = "Controller Layout")
}
IconButton(onClick = onSettingsClick) { IconButton(onClick = onSettingsClick) {
Icon(Icons.Default.Settings, contentDescription = "Settings") Icon(Icons.Default.Settings, contentDescription = "Settings")
} }

View File

@@ -0,0 +1,516 @@
package com.lazy.emulate.ui.screens.settings
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
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.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Bluetooth
import androidx.compose.material.icons.filled.BluetoothConnected
import androidx.compose.material.icons.filled.Gamepad
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
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.lazy.emulate.data.PreferencesManager
import com.lazy.emulate.emulation.ConsoleType
import com.lazy.emulate.input.ButtonMappingManager
import com.lazy.emulate.input.ControllerManager
import com.lazy.emulate.input.GameController
import com.lazy.emulate.input.GamepadButton
import com.lazy.emulate.input.OverlayVisibility
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ControllerSettingsScreen(
controllerManager: ControllerManager,
preferencesManager: PreferencesManager,
buttonMappingManager: ButtonMappingManager,
onBack: () -> Unit,
onTestController: () -> Unit,
onControllerLayout: (ConsoleType) -> Unit
) {
val controllers by controllerManager.connectedControllers.collectAsState()
val activeController by controllerManager.activeController.collectAsState()
var overlayVisibility by remember { mutableStateOf(preferencesManager.overlayVisibility) }
// Button remapping state
var showRemapDialog by remember { mutableStateOf(false) }
var remapConsole by remember { mutableStateOf(ConsoleType.PS1) }
var remapButton by remember { mutableStateOf<GamepadButton?>(null) }
var remapVersion by remember { mutableStateOf(0) }
// Controller remap state
var showControllerRemapDialog by remember { mutableStateOf(false) }
var controllerRemapFrom by remember { mutableStateOf<GamepadButton?>(null) }
var controllerRemapVersion by remember { mutableStateOf(0) }
Scaffold(
topBar = {
TopAppBar(
title = { Text("Controllers") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(16.dp)
) {
// Connected controllers
Text("Devices", style = MaterialTheme.typography.titleLarge)
Spacer(modifier = Modifier.height(12.dp))
if (controllers.isEmpty()) {
Card(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Bluetooth,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.width(12.dp))
Column {
Text(
"No controllers connected",
style = MaterialTheme.typography.bodyLarge
)
Text(
"Pair a Bluetooth controller in system settings",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
} else {
Card(modifier = Modifier.fillMaxWidth()) {
controllers.forEachIndexed { index, controller ->
ControllerItem(
controller = controller,
isActive = controller.deviceId == activeController?.deviceId,
onClick = { controllerManager.setActiveController(controller) }
)
if (index < controllers.lastIndex) {
HorizontalDivider()
}
}
}
}
Spacer(modifier = Modifier.height(12.dp))
Card(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onTestController)
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Gamepad,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(12.dp))
Column {
Text("Test Controller", style = MaterialTheme.typography.bodyLarge)
Text(
"Live button tester and raw event log",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
Spacer(modifier = Modifier.height(24.dp))
// Touch Overlay
Text("Touch Overlay", style = MaterialTheme.typography.titleLarge)
Spacer(modifier = Modifier.height(12.dp))
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier
.fillMaxWidth()
.clickable {
overlayVisibility = overlayVisibility.next()
preferencesManager.overlayVisibility = overlayVisibility
}
.padding(16.dp)
) {
Text("Visibility", style = MaterialTheme.typography.bodyLarge)
Text(
overlayVisibility.label,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
HorizontalDivider()
// Per-platform layout editors
val implementedConsoles = ConsoleType.entries.filter { it.isImplemented }
implementedConsoles.forEachIndexed { index, console ->
ListItem(
headlineContent = { Text("${console.displayName} Layout") },
trailingContent = {
Icon(
Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null
)
},
modifier = Modifier.clickable { onControllerLayout(console) }
)
if (index < implementedConsoles.lastIndex) {
HorizontalDivider()
}
}
}
Spacer(modifier = Modifier.height(24.dp))
// Controller Button Remap
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Controller Remap", style = MaterialTheme.typography.titleLarge)
IconButton(onClick = {
buttonMappingManager.resetControllerRemaps()
controllerRemapVersion++
}) {
Icon(Icons.Default.Refresh, contentDescription = "Reset remaps")
}
}
Spacer(modifier = Modifier.height(4.dp))
Text(
"Remap physical controller buttons",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(12.dp))
val controllerRemaps = remember(controllerRemapVersion) {
buttonMappingManager.getControllerRemaps()
}
val controllerButtons = listOf(
GamepadButton.FACE_BOTTOM, GamepadButton.FACE_RIGHT,
GamepadButton.FACE_LEFT, GamepadButton.FACE_TOP,
GamepadButton.L1, GamepadButton.R1, GamepadButton.L2, GamepadButton.R2,
GamepadButton.START, GamepadButton.SELECT
)
Card(modifier = Modifier.fillMaxWidth()) {
controllerButtons.forEachIndexed { index, button ->
val currentTarget = controllerRemaps[button]
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
controllerRemapFrom = button
showControllerRemapDialog = true
}
.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
buttonDisplayName(button),
style = MaterialTheme.typography.bodyLarge
)
Text(
if (currentTarget != null) buttonDisplayName(currentTarget) else "Default",
style = MaterialTheme.typography.bodyMedium,
color = if (currentTarget != null)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (index < controllerButtons.lastIndex) {
HorizontalDivider()
}
}
}
Spacer(modifier = Modifier.height(24.dp))
// Button Mapping (per-console libretro mapping)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Button Mapping", style = MaterialTheme.typography.titleLarge)
IconButton(onClick = {
buttonMappingManager.resetToDefaults(remapConsole)
remapVersion++
}) {
Icon(Icons.Default.Refresh, contentDescription = "Reset to defaults")
}
}
Spacer(modifier = Modifier.height(4.dp))
Text(
"Map buttons to libretro IDs for ${remapConsole.displayName}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(12.dp))
val currentMapping = remember(remapVersion, remapConsole) {
buttonMappingManager.getMapping(remapConsole)
}
val mappableButtons = listOf(
GamepadButton.FACE_BOTTOM, GamepadButton.FACE_RIGHT,
GamepadButton.FACE_LEFT, GamepadButton.FACE_TOP,
GamepadButton.L1, GamepadButton.R1, GamepadButton.L2, GamepadButton.R2,
GamepadButton.START, GamepadButton.SELECT
)
Card(modifier = Modifier.fillMaxWidth()) {
mappableButtons.forEachIndexed { index, button ->
val retroId = currentMapping[button] ?: -1
val defaultId = buttonMappingManager.getDefaultMapping(remapConsole)[button] ?: -1
val isCustom = retroId != defaultId
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
remapButton = button
showRemapDialog = true
}
.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
buttonDisplayName(button),
style = MaterialTheme.typography.bodyLarge
)
Text(
retroIdDisplayName(retroId),
style = MaterialTheme.typography.bodyMedium,
color = if (isCustom) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = if (isCustom) FontWeight.Bold else FontWeight.Normal
)
}
if (index < mappableButtons.lastIndex) {
HorizontalDivider()
}
}
}
}
}
// Button mapping dialog (libretro ID picker)
if (showRemapDialog && remapButton != null) {
val retroIds = listOf(
0 to "B (Cross)", 1 to "Y (Square)", 2 to "Select", 3 to "Start",
4 to "D-Up", 5 to "D-Down", 6 to "D-Left", 7 to "D-Right",
8 to "A (Circle)", 9 to "X (Triangle)", 10 to "L1", 11 to "R1",
12 to "L2", 13 to "R2", 14 to "L3", 15 to "R3"
)
AlertDialog(
onDismissRequest = { showRemapDialog = false },
title = { Text("Map ${buttonDisplayName(remapButton!!)}") },
text = {
Column {
Text(
"Select the libretro button to map to:",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(bottom = 8.dp)
)
retroIds.forEach { (id, name) ->
Text(
text = name,
modifier = Modifier
.fillMaxWidth()
.clickable {
buttonMappingManager.setButtonMapping(remapConsole, remapButton!!, id)
remapVersion++
showRemapDialog = false
}
.padding(vertical = 10.dp, horizontal = 4.dp),
style = MaterialTheme.typography.bodyLarge
)
}
}
},
confirmButton = {},
dismissButton = {
TextButton(onClick = { showRemapDialog = false }) {
Text("Cancel")
}
}
)
}
// Controller remap dialog (button-to-button picker)
if (showControllerRemapDialog && controllerRemapFrom != null) {
val targetButtons = listOf(
GamepadButton.FACE_BOTTOM, GamepadButton.FACE_RIGHT,
GamepadButton.FACE_LEFT, GamepadButton.FACE_TOP,
GamepadButton.L1, GamepadButton.R1, GamepadButton.L2, GamepadButton.R2,
GamepadButton.L3, GamepadButton.R3,
GamepadButton.DPAD_UP, GamepadButton.DPAD_DOWN,
GamepadButton.DPAD_LEFT, GamepadButton.DPAD_RIGHT,
GamepadButton.START, GamepadButton.SELECT
)
AlertDialog(
onDismissRequest = { showControllerRemapDialog = false },
title = { Text("Remap ${buttonDisplayName(controllerRemapFrom!!)}") },
text = {
Column {
Text(
"Select what this button should act as:",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(bottom = 8.dp)
)
targetButtons.forEach { target ->
Text(
text = buttonDisplayName(target),
modifier = Modifier
.fillMaxWidth()
.clickable {
buttonMappingManager.setControllerRemap(controllerRemapFrom!!, target)
controllerRemapVersion++
showControllerRemapDialog = false
}
.padding(vertical = 10.dp, horizontal = 4.dp),
style = MaterialTheme.typography.bodyLarge
)
}
}
},
confirmButton = {},
dismissButton = {
TextButton(onClick = { showControllerRemapDialog = false }) {
Text("Cancel")
}
}
)
}
}
private fun buttonDisplayName(button: GamepadButton): String {
return when (button) {
GamepadButton.FACE_BOTTOM -> "Cross / A"
GamepadButton.FACE_RIGHT -> "Circle / B"
GamepadButton.FACE_LEFT -> "Square / X"
GamepadButton.FACE_TOP -> "Triangle / Y"
GamepadButton.DPAD_UP -> "D-Pad Up"
GamepadButton.DPAD_DOWN -> "D-Pad Down"
GamepadButton.DPAD_LEFT -> "D-Pad Left"
GamepadButton.DPAD_RIGHT -> "D-Pad Right"
GamepadButton.L1 -> "L1"
GamepadButton.R1 -> "R1"
GamepadButton.L2 -> "L2"
GamepadButton.R2 -> "R2"
GamepadButton.L3 -> "L3"
GamepadButton.R3 -> "R3"
GamepadButton.START -> "Start"
GamepadButton.SELECT -> "Select"
GamepadButton.MODE -> "Mode"
GamepadButton.Z_BUTTON -> "Z"
GamepadButton.C_BUTTON -> "C"
}
}
private fun retroIdDisplayName(id: Int): String {
return when (id) {
0 -> "B (Cross)"
1 -> "Y (Square)"
2 -> "Select"
3 -> "Start"
4 -> "D-Up"
5 -> "D-Down"
6 -> "D-Left"
7 -> "D-Right"
8 -> "A (Circle)"
9 -> "X (Triangle)"
10 -> "L1"
11 -> "R1"
12 -> "L2"
13 -> "R2"
14 -> "L3"
15 -> "R3"
else -> "Unknown ($id)"
}
}
@Composable
private fun ControllerItem(
controller: GameController,
isActive: Boolean,
onClick: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = if (isActive) Icons.Default.BluetoothConnected else Icons.Default.Gamepad,
contentDescription = null,
tint = if (isActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = controller.name,
style = MaterialTheme.typography.bodyLarge,
color = if (isActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
)
Text(
text = if (isActive) "Active" else if (controller.isExternal) "Bluetooth" else "Built-in",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}

View File

@@ -1,80 +1,45 @@
package com.lazy.emulate.ui.screens.settings package com.lazy.emulate.ui.screens.settings
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer 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.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Bluetooth import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.BluetoothConnected
import androidx.compose.material.icons.filled.Gamepad
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue 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 androidx.compose.ui.unit.dp
import com.lazy.emulate.data.PreferencesManager import com.lazy.emulate.data.PreferencesManager
import com.lazy.emulate.emulation.ConsoleType
import com.lazy.emulate.emulation.DisplayMode import com.lazy.emulate.emulation.DisplayMode
import com.lazy.emulate.input.ButtonMappingManager
import com.lazy.emulate.input.ControllerManager
import com.lazy.emulate.input.GameController
import com.lazy.emulate.input.GamepadButton
import com.lazy.emulate.input.OverlayVisibility
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun SettingsScreen( fun SettingsScreen(
controllerManager: ControllerManager,
preferencesManager: PreferencesManager, preferencesManager: PreferencesManager,
buttonMappingManager: ButtonMappingManager,
onBack: () -> Unit, onBack: () -> Unit,
onTestController: () -> Unit = {} onControllerSettings: () -> Unit
) { ) {
val controllers by controllerManager.connectedControllers.collectAsState()
val activeController by controllerManager.activeController.collectAsState()
var displayMode by remember { mutableStateOf(preferencesManager.displayMode) } var displayMode by remember { mutableStateOf(preferencesManager.displayMode) }
var overlayVisibility by remember { mutableStateOf(preferencesManager.overlayVisibility) }
// Button remapping state
var showRemapDialog by remember { mutableStateOf(false) }
var remapConsole by remember { mutableStateOf(ConsoleType.PS1) }
var remapButton by remember { mutableStateOf<GamepadButton?>(null) }
var remapTarget by remember { mutableStateOf<String?>(null) }
// Force recomposition after remap changes
var remapVersion by remember { mutableStateOf(0) }
// Controller remap state
var showControllerRemapDialog by remember { mutableStateOf(false) }
var controllerRemapFrom by remember { mutableStateOf<GamepadButton?>(null) }
var controllerRemapVersion by remember { mutableStateOf(0) }
Scaffold( Scaffold(
topBar = { topBar = {
@@ -95,266 +60,28 @@ fun SettingsScreen(
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.padding(16.dp) .padding(16.dp)
) { ) {
// Controllers section // Controllers
Text( Text("Controllers", style = MaterialTheme.typography.titleLarge)
text = "Controllers",
style = MaterialTheme.typography.titleLarge
)
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
Card(modifier = Modifier.fillMaxWidth()) {
if (controllers.isEmpty()) { ListItem(
Card( headlineContent = { Text("Controller & Touch Overlay") },
modifier = Modifier.fillMaxWidth() supportingContent = { Text("Devices, remapping, touch layout") },
) { trailingContent = {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon( Icon(
Icons.Default.Bluetooth, Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null, contentDescription = null
tint = MaterialTheme.colorScheme.onSurfaceVariant
) )
Spacer(modifier = Modifier.width(12.dp)) },
Column { modifier = Modifier.clickable(onClick = onControllerSettings)
Text(
text = "No controllers connected",
style = MaterialTheme.typography.bodyLarge
) )
Text(
text = "Pair a Bluetooth controller in system settings",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
} else {
Card(modifier = Modifier.fillMaxWidth()) {
controllers.forEachIndexed { index, controller ->
ControllerItem(
controller = controller,
isActive = controller.deviceId == activeController?.deviceId,
onClick = { controllerManager.setActiveController(controller) }
)
if (index < controllers.lastIndex) {
HorizontalDivider()
}
}
}
}
Spacer(modifier = Modifier.height(12.dp))
// Test Controller — live tester with raw event log
Card(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onTestController)
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Gamepad,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(12.dp))
Column {
Text(
"Test Controller",
style = MaterialTheme.typography.bodyLarge
)
Text(
"Live button tester and raw event log",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
} }
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
// Touch overlay section // Emulation
Text( Text("Emulation", style = MaterialTheme.typography.titleLarge)
text = "Touch Overlay",
style = MaterialTheme.typography.titleLarge
)
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier
.fillMaxWidth()
.clickable {
overlayVisibility = overlayVisibility.next()
preferencesManager.overlayVisibility = overlayVisibility
}
.padding(16.dp)
) {
Text("Visibility", style = MaterialTheme.typography.bodyLarge)
Text(
overlayVisibility.label,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Spacer(modifier = Modifier.height(24.dp))
// Controller Button Remap section
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Controller Remap",
style = MaterialTheme.typography.titleLarge
)
IconButton(onClick = {
buttonMappingManager.resetControllerRemaps()
controllerRemapVersion++
}) {
Icon(Icons.Default.Refresh, contentDescription = "Reset remaps")
}
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Remap physical controller buttons",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(12.dp))
val controllerRemaps = remember(controllerRemapVersion) {
buttonMappingManager.getControllerRemaps()
}
val controllerButtons = listOf(
GamepadButton.FACE_BOTTOM, GamepadButton.FACE_RIGHT,
GamepadButton.FACE_LEFT, GamepadButton.FACE_TOP,
GamepadButton.L1, GamepadButton.R1, GamepadButton.L2, GamepadButton.R2,
GamepadButton.START, GamepadButton.SELECT
)
Card(modifier = Modifier.fillMaxWidth()) {
controllerButtons.forEachIndexed { index, button ->
val currentTarget = controllerRemaps[button]
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
controllerRemapFrom = button
showControllerRemapDialog = true
}
.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
buttonDisplayName(button),
style = MaterialTheme.typography.bodyLarge
)
Text(
if (currentTarget != null) buttonDisplayName(currentTarget) else "Default",
style = MaterialTheme.typography.bodyMedium,
color = if (currentTarget != null)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (index < controllerButtons.lastIndex) {
HorizontalDivider()
}
}
}
Spacer(modifier = Modifier.height(24.dp))
// Button Mapping section (per-console libretro mapping)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Button Mapping",
style = MaterialTheme.typography.titleLarge
)
IconButton(onClick = {
buttonMappingManager.resetToDefaults(remapConsole)
remapVersion++
}) {
Icon(Icons.Default.Refresh, contentDescription = "Reset to defaults")
}
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Map buttons to libretro IDs for ${remapConsole.displayName}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(12.dp))
val currentMapping = remember(remapVersion, remapConsole) {
buttonMappingManager.getMapping(remapConsole)
}
val mappableButtons = listOf(
GamepadButton.FACE_BOTTOM, GamepadButton.FACE_RIGHT,
GamepadButton.FACE_LEFT, GamepadButton.FACE_TOP,
GamepadButton.L1, GamepadButton.R1, GamepadButton.L2, GamepadButton.R2,
GamepadButton.START, GamepadButton.SELECT
)
Card(modifier = Modifier.fillMaxWidth()) {
mappableButtons.forEachIndexed { index, button ->
val retroId = currentMapping[button] ?: -1
val defaultId = buttonMappingManager.getDefaultMapping(remapConsole)[button] ?: -1
val isCustom = retroId != defaultId
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
remapButton = button
remapTarget = null
showRemapDialog = true
}
.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
buttonDisplayName(button),
style = MaterialTheme.typography.bodyLarge
)
Text(
retroIdDisplayName(retroId),
style = MaterialTheme.typography.bodyMedium,
color = if (isCustom) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = if (isCustom) FontWeight.Bold else FontWeight.Normal
)
}
if (index < mappableButtons.lastIndex) {
HorizontalDivider()
}
}
}
Spacer(modifier = Modifier.height(24.dp))
// Emulation section
Text(
text = "Emulation",
style = MaterialTheme.typography.titleLarge
)
Spacer(modifier = Modifier.height(12.dp))
Card(modifier = Modifier.fillMaxWidth()) { Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) { Column(modifier = Modifier.padding(16.dp)) {
Text("Audio", style = MaterialTheme.typography.bodyLarge) Text("Audio", style = MaterialTheme.typography.bodyLarge)
@@ -377,13 +104,9 @@ fun SettingsScreen(
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
// Display section // Display
Text( Text("Display", style = MaterialTheme.typography.titleLarge)
text = "Display",
style = MaterialTheme.typography.titleLarge
)
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
Card(modifier = Modifier.fillMaxWidth()) { Card(modifier = Modifier.fillMaxWidth()) {
Column( Column(
modifier = Modifier modifier = Modifier
@@ -406,10 +129,7 @@ fun SettingsScreen(
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
// About // About
Text( Text("About", style = MaterialTheme.typography.titleLarge)
text = "About",
style = MaterialTheme.typography.titleLarge
)
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
Card(modifier = Modifier.fillMaxWidth()) { Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) { Column(modifier = Modifier.padding(16.dp)) {
@@ -423,172 +143,4 @@ fun SettingsScreen(
} }
} }
} }
// Button mapping dialog (libretro ID picker)
if (showRemapDialog && remapButton != null) {
val retroIds = listOf(
0 to "B (Cross)", 1 to "Y (Square)", 2 to "Select", 3 to "Start",
4 to "D-Up", 5 to "D-Down", 6 to "D-Left", 7 to "D-Right",
8 to "A (Circle)", 9 to "X (Triangle)", 10 to "L1", 11 to "R1",
12 to "L2", 13 to "R2", 14 to "L3", 15 to "R3"
)
AlertDialog(
onDismissRequest = { showRemapDialog = false },
title = { Text("Map ${buttonDisplayName(remapButton!!)}") },
text = {
Column {
Text(
"Select the libretro button to map to:",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(bottom = 8.dp)
)
retroIds.forEach { (id, name) ->
Text(
text = name,
modifier = Modifier
.fillMaxWidth()
.clickable {
buttonMappingManager.setButtonMapping(remapConsole, remapButton!!, id)
remapVersion++
showRemapDialog = false
}
.padding(vertical = 10.dp, horizontal = 4.dp),
style = MaterialTheme.typography.bodyLarge
)
}
}
},
confirmButton = {},
dismissButton = {
TextButton(onClick = { showRemapDialog = false }) {
Text("Cancel")
}
}
)
}
// Controller remap dialog (button-to-button picker)
if (showControllerRemapDialog && controllerRemapFrom != null) {
val targetButtons = listOf(
GamepadButton.FACE_BOTTOM, GamepadButton.FACE_RIGHT,
GamepadButton.FACE_LEFT, GamepadButton.FACE_TOP,
GamepadButton.L1, GamepadButton.R1, GamepadButton.L2, GamepadButton.R2,
GamepadButton.L3, GamepadButton.R3,
GamepadButton.DPAD_UP, GamepadButton.DPAD_DOWN,
GamepadButton.DPAD_LEFT, GamepadButton.DPAD_RIGHT,
GamepadButton.START, GamepadButton.SELECT
)
AlertDialog(
onDismissRequest = { showControllerRemapDialog = false },
title = { Text("Remap ${buttonDisplayName(controllerRemapFrom!!)}") },
text = {
Column {
Text(
"Select what this button should act as:",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(bottom = 8.dp)
)
targetButtons.forEach { target ->
Text(
text = buttonDisplayName(target),
modifier = Modifier
.fillMaxWidth()
.clickable {
buttonMappingManager.setControllerRemap(controllerRemapFrom!!, target)
controllerRemapVersion++
showControllerRemapDialog = false
}
.padding(vertical = 10.dp, horizontal = 4.dp),
style = MaterialTheme.typography.bodyLarge
)
}
}
},
confirmButton = {},
dismissButton = {
TextButton(onClick = { showControllerRemapDialog = false }) {
Text("Cancel")
}
}
)
}
}
private fun buttonDisplayName(button: GamepadButton): String {
return when (button) {
GamepadButton.FACE_BOTTOM -> "Cross / A"
GamepadButton.FACE_RIGHT -> "Circle / B"
GamepadButton.FACE_LEFT -> "Square / X"
GamepadButton.FACE_TOP -> "Triangle / Y"
GamepadButton.DPAD_UP -> "D-Pad Up"
GamepadButton.DPAD_DOWN -> "D-Pad Down"
GamepadButton.DPAD_LEFT -> "D-Pad Left"
GamepadButton.DPAD_RIGHT -> "D-Pad Right"
GamepadButton.L1 -> "L1"
GamepadButton.R1 -> "R1"
GamepadButton.L2 -> "L2"
GamepadButton.R2 -> "R2"
GamepadButton.L3 -> "L3"
GamepadButton.R3 -> "R3"
GamepadButton.START -> "Start"
GamepadButton.SELECT -> "Select"
GamepadButton.MODE -> "Mode"
GamepadButton.Z_BUTTON -> "Z"
GamepadButton.C_BUTTON -> "C"
}
}
private fun retroIdDisplayName(id: Int): String {
return when (id) {
0 -> "B (Cross)"
1 -> "Y (Square)"
2 -> "Select"
3 -> "Start"
4 -> "D-Up"
5 -> "D-Down"
6 -> "D-Left"
7 -> "D-Right"
8 -> "A (Circle)"
9 -> "X (Triangle)"
10 -> "L1"
11 -> "R1"
12 -> "L2"
13 -> "R2"
14 -> "L3"
15 -> "R3"
else -> "Unknown ($id)"
}
}
@Composable
private fun ControllerItem(
controller: GameController,
isActive: Boolean,
onClick: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = if (isActive) Icons.Default.BluetoothConnected else Icons.Default.Gamepad,
contentDescription = null,
tint = if (isActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = controller.name,
style = MaterialTheme.typography.bodyLarge,
color = if (isActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
)
Text(
text = if (isActive) "Active" else if (controller.isExternal) "Bluetooth" else "Built-in",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
} }