diff --git a/app/src/main/java/com/lazy/emulate/ui/navigation/NavGraph.kt b/app/src/main/java/com/lazy/emulate/ui/navigation/NavGraph.kt index 4a89a3c..0903e9c 100644 --- a/app/src/main/java/com/lazy/emulate/ui/navigation/NavGraph.kt +++ b/app/src/main/java/com/lazy/emulate/ui/navigation/NavGraph.kt @@ -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)) + } ) } diff --git a/app/src/main/java/com/lazy/emulate/ui/navigation/Screen.kt b/app/src/main/java/com/lazy/emulate/ui/navigation/Screen.kt index 2abf34d..2b2e751 100644 --- a/app/src/main/java/com/lazy/emulate/ui/navigation/Screen.kt +++ b/app/src/main/java/com/lazy/emulate/ui/navigation/Screen.kt @@ -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" diff --git a/app/src/main/java/com/lazy/emulate/ui/screens/controller/ControllerLayoutScreen.kt b/app/src/main/java/com/lazy/emulate/ui/screens/controller/ControllerLayoutScreen.kt index 9c444ed..c3aad1d 100644 --- a/app/src/main/java/com/lazy/emulate/ui/screens/controller/ControllerLayoutScreen.kt +++ b/app/src/main/java/com/lazy/emulate/ui/screens/controller/ControllerLayoutScreen.kt @@ -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 -> "" + } + } } diff --git a/app/src/main/java/com/lazy/emulate/ui/screens/home/HomeScreen.kt b/app/src/main/java/com/lazy/emulate/ui/screens/home/HomeScreen.kt index 4e87254..b505ffa 100644 --- a/app/src/main/java/com/lazy/emulate/ui/screens/home/HomeScreen.kt +++ b/app/src/main/java/com/lazy/emulate/ui/screens/home/HomeScreen.kt @@ -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") } diff --git a/app/src/main/java/com/lazy/emulate/ui/screens/settings/ControllerSettingsScreen.kt b/app/src/main/java/com/lazy/emulate/ui/screens/settings/ControllerSettingsScreen.kt new file mode 100644 index 0000000..95d6be4 --- /dev/null +++ b/app/src/main/java/com/lazy/emulate/ui/screens/settings/ControllerSettingsScreen.kt @@ -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(null) } + var remapVersion by remember { mutableStateOf(0) } + + // Controller remap state + var showControllerRemapDialog by remember { mutableStateOf(false) } + var controllerRemapFrom by remember { mutableStateOf(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 + ) + } + } +} diff --git a/app/src/main/java/com/lazy/emulate/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/com/lazy/emulate/ui/screens/settings/SettingsScreen.kt index 3a81571..54c8fc3 100644 --- a/app/src/main/java/com/lazy/emulate/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/java/com/lazy/emulate/ui/screens/settings/SettingsScreen.kt @@ -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(null) } - var remapTarget by remember { mutableStateOf(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(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 - ) - } - } }