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.game.GameScreen
import com.lazy.emulate.ui.screens.home.HomeScreen
import com.lazy.emulate.ui.screens.settings.ControllerSettingsScreen
import com.lazy.emulate.ui.screens.settings.SettingsScreen
@Composable
@@ -40,20 +41,28 @@ fun EmulateNavGraph(
},
onSettingsClick = {
navController.navigate(Screen.Settings.route)
},
onControllerLayoutClick = { consoleType ->
navController.navigate(Screen.ControllerLayout.createRoute(consoleType.name))
}
)
}
composable(Screen.Settings.route) {
SettingsScreen(
preferencesManager = preferencesManager,
onBack = { navController.popBackStack() },
onControllerSettings = { navController.navigate(Screen.ControllerSettings.route) }
)
}
composable(Screen.ControllerSettings.route) {
ControllerSettingsScreen(
controllerManager = controllerManager,
preferencesManager = preferencesManager,
buttonMappingManager = buttonMappingManager,
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) {
data object Home : Screen("home")
data object Settings : Screen("settings")
data object ControllerSettings : Screen("controller_settings")
data object ControllerTest : Screen("controller_test")
data object ControllerLayout : Screen("controller_layout/{consoleType}") {
fun createRoute(consoleType: String) = "controller_layout/$consoleType"

View File

@@ -97,6 +97,7 @@ fun ControllerLayoutScreen(
layout.buttons.forEach { buttonPos ->
DraggableButton(
buttonPosition = buttonPos,
consoleType = consoleType,
screenWidth = screenWidth,
screenHeight = screenHeight,
opacity = opacity,
@@ -170,6 +171,7 @@ fun ControllerLayoutScreen(
@Composable
private fun DraggableButton(
buttonPosition: ButtonPosition,
consoleType: ConsoleType,
screenWidth: Float,
screenHeight: Float,
opacity: Float,
@@ -198,31 +200,70 @@ private fun DraggableButton(
contentAlignment = Alignment.Center
) {
Text(
text = buttonPosition.button.toLabel(),
text = buttonPosition.button.toLabel(consoleType),
style = MaterialTheme.typography.labelSmall,
color = Color.White.copy(alpha = opacity)
)
}
}
private fun GamepadButton.toLabel(): String = when (this) {
GamepadButton.FACE_BOTTOM -> "X"
GamepadButton.FACE_RIGHT -> "O"
GamepadButton.FACE_LEFT -> "[]"
GamepadButton.FACE_TOP -> "/\\"
GamepadButton.DPAD_UP -> "Up"
GamepadButton.DPAD_DOWN -> "Dn"
GamepadButton.DPAD_LEFT -> "Lt"
GamepadButton.DPAD_RIGHT -> "Rt"
GamepadButton.L1 -> "L1"
GamepadButton.R1 -> "R1"
GamepadButton.L2 -> "L2"
GamepadButton.R2 -> "R2"
GamepadButton.L3 -> "L3"
GamepadButton.R3 -> "R3"
GamepadButton.START -> "ST"
GamepadButton.SELECT -> "SL"
GamepadButton.MODE -> "MD"
GamepadButton.Z_BUTTON -> "Z"
GamepadButton.C_BUTTON -> "C"
private fun GamepadButton.toLabel(consoleType: ConsoleType): String {
// Shared labels for non-face buttons
val shared = when (this) {
GamepadButton.DPAD_UP -> "Up"
GamepadButton.DPAD_DOWN -> "Dn"
GamepadButton.DPAD_LEFT -> "Lt"
GamepadButton.DPAD_RIGHT -> "Rt"
GamepadButton.L1 -> "L1"
GamepadButton.R1 -> "R1"
GamepadButton.L2 -> "L2"
GamepadButton.R2 -> "R2"
GamepadButton.L3 -> "L3"
GamepadButton.R3 -> "R3"
GamepadButton.START -> "ST"
GamepadButton.SELECT -> "SL"
GamepadButton.MODE -> "MD"
GamepadButton.Z_BUTTON -> "Z"
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.filled.Add
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.Settings
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.windowsizeclass.WindowSizeClass
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -44,6 +44,8 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.unit.dp
import com.lazy.emulate.data.model.Game
import com.lazy.emulate.data.repository.GameRepository
@@ -65,12 +67,12 @@ fun HomeScreen(
windowSizeClass: WindowSizeClass,
gameRepository: GameRepository,
onGameSelected: (Game) -> Unit,
onSettingsClick: () -> Unit,
onControllerLayoutClick: (ConsoleType) -> Unit
onSettingsClick: () -> Unit
) {
val games by gameRepository.games.collectAsState()
var searchQuery by remember { mutableStateOf("") }
var searchActive by remember { mutableStateOf(false) }
val searchFocusRequester = remember { FocusRequester() }
val layoutType = windowSizeClass.toLayoutType()
val scope = rememberCoroutineScope()
@@ -100,10 +102,14 @@ fun HomeScreen(
TopAppBar(
title = {
if (searchActive) {
LaunchedEffect(Unit) {
searchFocusRequester.requestFocus()
}
androidx.compose.foundation.text.BasicTextField(
value = searchQuery,
onValueChange = { searchQuery = it },
singleLine = true,
modifier = Modifier.focusRequester(searchFocusRequester),
textStyle = MaterialTheme.typography.bodyLarge.copy(
color = MaterialTheme.colorScheme.onSurface
),
@@ -146,13 +152,6 @@ fun HomeScreen(
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) {
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
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.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.material.icons.automirrored.filled.KeyboardArrowRight
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.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)
@Composable
fun SettingsScreen(
controllerManager: ControllerManager,
preferencesManager: PreferencesManager,
buttonMappingManager: ButtonMappingManager,
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 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(
topBar = {
@@ -95,266 +60,28 @@ fun SettingsScreen(
.verticalScroll(rememberScrollState())
.padding(16.dp)
) {
// Controllers section
Text(
text = "Controllers",
style = MaterialTheme.typography.titleLarge
)
// Controllers
Text("Controllers", 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
) {
Card(modifier = Modifier.fillMaxWidth()) {
ListItem(
headlineContent = { Text("Controller & Touch Overlay") },
supportingContent = { Text("Devices, remapping, touch layout") },
trailingContent = {
Icon(
Icons.Default.Bluetooth,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant
Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null
)
Spacer(modifier = Modifier.width(12.dp))
Column {
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))
// Touch overlay section
Text(
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
)
}
}
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
},
modifier = Modifier.clickable(onClick = onControllerSettings)
)
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
)
// Emulation
Text("Emulation", style = MaterialTheme.typography.titleLarge)
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()) {
Column(modifier = Modifier.padding(16.dp)) {
Text("Audio", style = MaterialTheme.typography.bodyLarge)
@@ -377,13 +104,9 @@ fun SettingsScreen(
Spacer(modifier = Modifier.height(24.dp))
// Display section
Text(
text = "Display",
style = MaterialTheme.typography.titleLarge
)
// Display
Text("Display", style = MaterialTheme.typography.titleLarge)
Spacer(modifier = Modifier.height(12.dp))
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier
@@ -406,10 +129,7 @@ fun SettingsScreen(
Spacer(modifier = Modifier.height(24.dp))
// About
Text(
text = "About",
style = MaterialTheme.typography.titleLarge
)
Text("About", style = MaterialTheme.typography.titleLarge)
Spacer(modifier = Modifier.height(12.dp))
Card(modifier = Modifier.fillMaxWidth()) {
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
)
}
}
}