From 0b4e408d18d3c2b79af103233eadafe8a79a4827 Mon Sep 17 00:00:00 2001 From: Matt Hills Date: Thu, 9 Apr 2026 20:46:21 -0400 Subject: [PATCH] Add save state slots with per-game saves, screenshots, and launch menu Save states now support 5 slots per game with per-game directories, screenshot thumbnails captured via PixelCopy, and a launch menu that shows existing saves on game start (continue/new game flow). In-game menu combines save and load into a single overlay. Also adds button remapping, controller overlay visibility settings, and preferences management. --- .../java/com/lazy/emulate/MainActivity.kt | 16 +- .../lazy/emulate/data/PreferencesManager.kt | 83 +++ .../lazy/emulate/emulation/EmulationEngine.kt | 4 +- .../lazy/emulate/emulation/EmulatorCore.kt | 16 + .../emulation/cores/PlaceholderCore.kt | 2 + .../lazy/emulate/emulation/cores/Ps1Core.kt | 46 +- .../emulate/input/ButtonMappingManager.kt | 93 +++ .../lazy/emulate/input/ControllerManager.kt | 8 +- .../lazy/emulate/input/OverlayVisibility.kt | 12 + .../lazy/emulate/ui/navigation/NavGraph.kt | 9 +- .../emulate/ui/screens/game/GameScreen.kt | 555 +++++++++++++++--- .../ui/screens/settings/SettingsScreen.kt | 340 ++++++++++- 12 files changed, 1074 insertions(+), 110 deletions(-) create mode 100644 app/src/main/java/com/lazy/emulate/data/PreferencesManager.kt create mode 100644 app/src/main/java/com/lazy/emulate/input/ButtonMappingManager.kt create mode 100644 app/src/main/java/com/lazy/emulate/input/OverlayVisibility.kt diff --git a/app/src/main/java/com/lazy/emulate/MainActivity.kt b/app/src/main/java/com/lazy/emulate/MainActivity.kt index 29b2a6a..6af4a93 100644 --- a/app/src/main/java/com/lazy/emulate/MainActivity.kt +++ b/app/src/main/java/com/lazy/emulate/MainActivity.kt @@ -14,7 +14,10 @@ import androidx.activity.enableEdgeToEdge import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass import androidx.navigation.compose.rememberNavController +import com.lazy.emulate.data.PreferencesManager import com.lazy.emulate.data.repository.GameRepository +import com.lazy.emulate.emulation.EmulationEngine +import com.lazy.emulate.input.ButtonMappingManager import com.lazy.emulate.input.ControllerManager import com.lazy.emulate.ui.navigation.EmulateNavGraph import com.lazy.emulate.ui.theme.EmulateTheme @@ -23,6 +26,10 @@ class MainActivity : ComponentActivity() { private lateinit var controllerManager: ControllerManager private lateinit var gameRepository: GameRepository + lateinit var preferencesManager: PreferencesManager + private set + lateinit var buttonMappingManager: ButtonMappingManager + private set @OptIn(ExperimentalMaterial3WindowSizeClassApi::class) override fun onCreate(savedInstanceState: Bundle?) { @@ -31,8 +38,11 @@ class MainActivity : ComponentActivity() { requestAllFilesPermission() - controllerManager = ControllerManager(this) + preferencesManager = PreferencesManager(this) + buttonMappingManager = ButtonMappingManager(preferencesManager) + controllerManager = ControllerManager(this, buttonMappingManager) gameRepository = GameRepository(this) + EmulationEngine.buttonMappingManager = buttonMappingManager setContent { EmulateTheme { @@ -43,7 +53,9 @@ class MainActivity : ComponentActivity() { navController = navController, windowSizeClass = windowSizeClass, gameRepository = gameRepository, - controllerManager = controllerManager + controllerManager = controllerManager, + preferencesManager = preferencesManager, + buttonMappingManager = buttonMappingManager ) } } diff --git a/app/src/main/java/com/lazy/emulate/data/PreferencesManager.kt b/app/src/main/java/com/lazy/emulate/data/PreferencesManager.kt new file mode 100644 index 0000000..3100924 --- /dev/null +++ b/app/src/main/java/com/lazy/emulate/data/PreferencesManager.kt @@ -0,0 +1,83 @@ +package com.lazy.emulate.data + +import android.content.Context +import android.content.SharedPreferences +import com.lazy.emulate.emulation.ConsoleType +import com.lazy.emulate.emulation.DisplayMode +import com.lazy.emulate.input.GamepadButton +import com.lazy.emulate.input.OverlayVisibility + +class PreferencesManager(context: Context) { + + private val prefs: SharedPreferences = + context.getSharedPreferences("emulate_prefs", Context.MODE_PRIVATE) + + var displayMode: DisplayMode + get() = DisplayMode.entries.getOrElse(prefs.getInt(KEY_DISPLAY_MODE, 0)) { DisplayMode.STRETCH } + set(value) = prefs.edit().putInt(KEY_DISPLAY_MODE, value.ordinal).apply() + + var overlayVisibility: OverlayVisibility + get() = OverlayVisibility.entries.getOrElse(prefs.getInt(KEY_OVERLAY_VISIBILITY, 0)) { OverlayVisibility.AUTO } + set(value) = prefs.edit().putInt(KEY_OVERLAY_VISIBILITY, value.ordinal).apply() + + fun getButtonMapping(consoleType: ConsoleType): Map { + val prefix = "${KEY_BUTTON_MAP_PREFIX}${consoleType.name}_" + val result = mutableMapOf() + for (button in GamepadButton.entries) { + val key = "$prefix${button.name}" + if (prefs.contains(key)) { + result[button] = prefs.getInt(key, -1) + } + } + return result + } + + fun setButtonMapping(consoleType: ConsoleType, button: GamepadButton, retroId: Int) { + val key = "${KEY_BUTTON_MAP_PREFIX}${consoleType.name}_${button.name}" + prefs.edit().putInt(key, retroId).apply() + } + + fun clearButtonMappings(consoleType: ConsoleType) { + val prefix = "${KEY_BUTTON_MAP_PREFIX}${consoleType.name}_" + val editor = prefs.edit() + for (button in GamepadButton.entries) { + editor.remove("$prefix${button.name}") + } + editor.apply() + } + + fun getControllerButtonRemap(): Map { + val result = mutableMapOf() + for (button in GamepadButton.entries) { + val key = "${KEY_CONTROLLER_REMAP_PREFIX}${button.name}" + if (prefs.contains(key)) { + val targetName = prefs.getString(key, null) + if (targetName != null) { + try { + result[button] = GamepadButton.valueOf(targetName) + } catch (_: IllegalArgumentException) {} + } + } + } + return result + } + + fun setControllerButtonRemap(from: GamepadButton, to: GamepadButton) { + prefs.edit().putString("${KEY_CONTROLLER_REMAP_PREFIX}${from.name}", to.name).apply() + } + + fun clearControllerRemaps() { + val editor = prefs.edit() + for (button in GamepadButton.entries) { + editor.remove("${KEY_CONTROLLER_REMAP_PREFIX}${button.name}") + } + editor.apply() + } + + companion object { + private const val KEY_DISPLAY_MODE = "display_mode" + private const val KEY_OVERLAY_VISIBILITY = "overlay_visibility" + private const val KEY_BUTTON_MAP_PREFIX = "button_map_" + private const val KEY_CONTROLLER_REMAP_PREFIX = "controller_remap_" + } +} diff --git a/app/src/main/java/com/lazy/emulate/emulation/EmulationEngine.kt b/app/src/main/java/com/lazy/emulate/emulation/EmulationEngine.kt index 6662b9d..a619598 100644 --- a/app/src/main/java/com/lazy/emulate/emulation/EmulationEngine.kt +++ b/app/src/main/java/com/lazy/emulate/emulation/EmulationEngine.kt @@ -3,14 +3,16 @@ package com.lazy.emulate.emulation import android.content.Context import com.lazy.emulate.emulation.cores.PlaceholderCore import com.lazy.emulate.emulation.cores.Ps1Core +import com.lazy.emulate.input.ButtonMappingManager object EmulationEngine { private var activeCore: EmulatorCore? = null + var buttonMappingManager: ButtonMappingManager? = null fun getCoreForConsole(context: Context, consoleType: ConsoleType): EmulatorCore { return when (consoleType) { - ConsoleType.PS1 -> Ps1Core(context) + ConsoleType.PS1 -> Ps1Core(context, buttonMappingManager) ConsoleType.NES -> PlaceholderCore(ConsoleType.NES) ConsoleType.SNES -> PlaceholderCore(ConsoleType.SNES) ConsoleType.N64 -> PlaceholderCore(ConsoleType.N64) diff --git a/app/src/main/java/com/lazy/emulate/emulation/EmulatorCore.kt b/app/src/main/java/com/lazy/emulate/emulation/EmulatorCore.kt index 60fddba..57f081e 100644 --- a/app/src/main/java/com/lazy/emulate/emulation/EmulatorCore.kt +++ b/app/src/main/java/com/lazy/emulate/emulation/EmulatorCore.kt @@ -3,12 +3,21 @@ package com.lazy.emulate.emulation import android.view.Surface import com.lazy.emulate.input.GamepadButton +data class SaveSlotInfo( + val slot: Int, + val exists: Boolean, + val lastModified: Long = 0L, + val screenshotPath: String? = null +) + interface EmulatorCore { val consoleType: ConsoleType val isRunning: Boolean + val saveSlotCount: Int get() = 5 + fun loadRom(path: String): Boolean fun start() @@ -33,6 +42,13 @@ interface EmulatorCore { fun loadState(slot: Int): Boolean + fun getSaveSlotInfo(slot: Int): SaveSlotInfo + + fun getScreenshotPath(slot: Int): String? = null + + fun getAllSaveSlots(): List = + (0 until saveSlotCount).map { getSaveSlotInfo(it) } + fun setAudioEnabled(enabled: Boolean) fun setFrameSkip(skip: Int) diff --git a/app/src/main/java/com/lazy/emulate/emulation/cores/PlaceholderCore.kt b/app/src/main/java/com/lazy/emulate/emulation/cores/PlaceholderCore.kt index b37c92a..dd4c752 100644 --- a/app/src/main/java/com/lazy/emulate/emulation/cores/PlaceholderCore.kt +++ b/app/src/main/java/com/lazy/emulate/emulation/cores/PlaceholderCore.kt @@ -5,6 +5,7 @@ import android.view.Surface import com.lazy.emulate.emulation.ConsoleType import com.lazy.emulate.emulation.DisplayMode import com.lazy.emulate.emulation.EmulatorCore +import com.lazy.emulate.emulation.SaveSlotInfo import com.lazy.emulate.input.GamepadButton /** @@ -35,6 +36,7 @@ class PlaceholderCore(override val consoleType: ConsoleType) : EmulatorCore { override fun onAnalogStick(x: Float, y: Float, stickIndex: Int) {} override fun saveState(slot: Int): Boolean = false override fun loadState(slot: Int): Boolean = false + override fun getSaveSlotInfo(slot: Int) = SaveSlotInfo(slot, false) override fun setAudioEnabled(enabled: Boolean) {} override fun setFrameSkip(skip: Int) {} override fun setDisplayMode(mode: DisplayMode) {} diff --git a/app/src/main/java/com/lazy/emulate/emulation/cores/Ps1Core.kt b/app/src/main/java/com/lazy/emulate/emulation/cores/Ps1Core.kt index 25a41e0..8880180 100644 --- a/app/src/main/java/com/lazy/emulate/emulation/cores/Ps1Core.kt +++ b/app/src/main/java/com/lazy/emulate/emulation/cores/Ps1Core.kt @@ -7,10 +7,15 @@ import com.lazy.emulate.emulation.ConsoleType import com.lazy.emulate.emulation.DisplayMode import com.lazy.emulate.emulation.EmulatorCore import com.lazy.emulate.emulation.NativeLibretro +import com.lazy.emulate.emulation.SaveSlotInfo +import com.lazy.emulate.input.ButtonMappingManager import com.lazy.emulate.input.GamepadButton import java.io.File -class Ps1Core(private val context: Context) : EmulatorCore { +class Ps1Core( + private val context: Context, + private val buttonMappingManager: ButtonMappingManager? = null +) : EmulatorCore { override val consoleType = ConsoleType.PS1 @@ -21,11 +26,16 @@ class Ps1Core(private val context: Context) : EmulatorCore { private val coresDir = File(context.filesDir, "cores") private val systemDir = File(context.filesDir, "system") - private val savesDir = File(context.filesDir, "saves") + private val baseSavesDir = File(context.filesDir, "saves") + private lateinit var savesDir: File override fun loadRom(path: String): Boolean { coresDir.mkdirs() systemDir.mkdirs() + + // Per-game save directory based on ROM filename + val romName = File(path).nameWithoutExtension + savesDir = File(baseSavesDir, romName) savesDir.mkdirs() NativeLibretro.nativeSetSystemDir(systemDir.absolutePath) @@ -119,6 +129,20 @@ class Ps1Core(private val context: Context) : EmulatorCore { return NativeLibretro.nativeLoadState(path) } + override fun getSaveSlotInfo(slot: Int): SaveSlotInfo { + val file = File(savesDir, "state_$slot.sav") + val screenshot = File(savesDir, "state_$slot.jpg") + return SaveSlotInfo( + slot = slot, + exists = file.exists(), + lastModified = if (file.exists()) file.lastModified() else 0L, + screenshotPath = if (screenshot.exists()) screenshot.absolutePath else null + ) + } + + override fun getScreenshotPath(slot: Int): String = + File(savesDir, "state_$slot.jpg").absolutePath + override fun setAudioEnabled(enabled: Boolean) { NativeLibretro.nativeSetAudioEnabled(enabled) } @@ -171,14 +195,18 @@ class Ps1Core(private val context: Context) : EmulatorCore { } private fun mapButtonToRetroId(button: GamepadButton): Int { + if (buttonMappingManager != null) { + return buttonMappingManager.getRetroId(consoleType, button) + } + return defaultMapButtonToRetroId(button) + } + + private fun defaultMapButtonToRetroId(button: GamepadButton): Int { return when (button) { - // libretro joypad IDs: B=0 Y=1 SELECT=2 START=3 UP=4 DOWN=5 LEFT=6 RIGHT=7 - // A=8 X=9 L=10 R=11 L2=12 R2=13 L3=14 R3=15 - // PS1 mapping: Cross=A(8) Circle=B(0) Square=X(9) Triangle=Y(1) - GamepadButton.FACE_BOTTOM -> 8 // Cross -> A - GamepadButton.FACE_RIGHT -> 0 // Circle -> B - GamepadButton.FACE_LEFT -> 9 // Square -> X - GamepadButton.FACE_TOP -> 1 // Triangle -> Y + GamepadButton.FACE_BOTTOM -> 0 // Cross -> B + GamepadButton.FACE_RIGHT -> 8 // Circle -> A + GamepadButton.FACE_LEFT -> 1 // Square -> Y + GamepadButton.FACE_TOP -> 9 // Triangle -> X GamepadButton.DPAD_UP -> 4 GamepadButton.DPAD_DOWN -> 5 GamepadButton.DPAD_LEFT -> 6 diff --git a/app/src/main/java/com/lazy/emulate/input/ButtonMappingManager.kt b/app/src/main/java/com/lazy/emulate/input/ButtonMappingManager.kt new file mode 100644 index 0000000..b7feed6 --- /dev/null +++ b/app/src/main/java/com/lazy/emulate/input/ButtonMappingManager.kt @@ -0,0 +1,93 @@ +package com.lazy.emulate.input + +import com.lazy.emulate.data.PreferencesManager +import com.lazy.emulate.emulation.ConsoleType + +class ButtonMappingManager(private val preferencesManager: PreferencesManager) { + + private val defaultMappings = mapOf( + ConsoleType.PS1 to mapOf( + GamepadButton.FACE_BOTTOM to 0, // Cross -> B + GamepadButton.FACE_RIGHT to 8, // Circle -> A + GamepadButton.FACE_LEFT to 1, // Square -> Y + GamepadButton.FACE_TOP to 9, // Triangle -> X + GamepadButton.DPAD_UP to 4, + GamepadButton.DPAD_DOWN to 5, + GamepadButton.DPAD_LEFT to 6, + GamepadButton.DPAD_RIGHT to 7, + GamepadButton.L1 to 10, + GamepadButton.R1 to 11, + GamepadButton.L2 to 12, + GamepadButton.R2 to 13, + GamepadButton.L3 to 14, + GamepadButton.R3 to 15, + GamepadButton.SELECT to 2, + GamepadButton.START to 3 + ) + ) + + private var cachedConsole: ConsoleType? = null + private var cachedMapping: Map = emptyMap() + private var controllerRemap: Map = emptyMap() + + fun getRetroId(consoleType: ConsoleType, button: GamepadButton): Int { + if (cachedConsole != consoleType) { + rebuildCache(consoleType) + } + return cachedMapping[button] ?: -1 + } + + fun remapControllerButton(button: GamepadButton): GamepadButton { + if (controllerRemap.isEmpty()) { + controllerRemap = preferencesManager.getControllerButtonRemap() + } + return controllerRemap[button] ?: button + } + + fun setButtonMapping(consoleType: ConsoleType, button: GamepadButton, retroId: Int) { + preferencesManager.setButtonMapping(consoleType, button, retroId) + if (cachedConsole == consoleType) { + cachedMapping = cachedMapping.toMutableMap().apply { put(button, retroId) } + } + } + + fun setControllerRemap(from: GamepadButton, to: GamepadButton) { + preferencesManager.setControllerButtonRemap(from, to) + controllerRemap = controllerRemap.toMutableMap().apply { put(from, to) } + } + + fun resetToDefaults(consoleType: ConsoleType) { + preferencesManager.clearButtonMappings(consoleType) + cachedConsole = null + } + + fun resetControllerRemaps() { + preferencesManager.clearControllerRemaps() + controllerRemap = emptyMap() + } + + fun getMapping(consoleType: ConsoleType): Map { + if (cachedConsole != consoleType) { + rebuildCache(consoleType) + } + return cachedMapping + } + + fun getControllerRemaps(): Map { + if (controllerRemap.isEmpty()) { + controllerRemap = preferencesManager.getControllerButtonRemap() + } + return controllerRemap + } + + fun getDefaultMapping(consoleType: ConsoleType): Map { + return defaultMappings[consoleType] ?: emptyMap() + } + + private fun rebuildCache(consoleType: ConsoleType) { + val defaults = defaultMappings[consoleType] ?: emptyMap() + val overrides = preferencesManager.getButtonMapping(consoleType) + cachedMapping = defaults + overrides + cachedConsole = consoleType + } +} diff --git a/app/src/main/java/com/lazy/emulate/input/ControllerManager.kt b/app/src/main/java/com/lazy/emulate/input/ControllerManager.kt index cb4a2c9..3de692f 100644 --- a/app/src/main/java/com/lazy/emulate/input/ControllerManager.kt +++ b/app/src/main/java/com/lazy/emulate/input/ControllerManager.kt @@ -9,7 +9,10 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -class ControllerManager(context: Context) : InputManager.InputDeviceListener { +class ControllerManager( + context: Context, + var buttonMappingManager: ButtonMappingManager? = null +) : InputManager.InputDeviceListener { private val inputManager = context.getSystemService(Context.INPUT_SERVICE) as InputManager @@ -54,7 +57,8 @@ class ControllerManager(context: Context) : InputManager.InputDeviceListener { fun handleKeyEvent(event: KeyEvent): Boolean { if (!isGameControllerDevice(event.device ?: return false)) return false - val button = mapKeyToButton(event.keyCode) ?: return false + val rawButton = mapKeyToButton(event.keyCode) ?: return false + val button = buttonMappingManager?.remapControllerButton(rawButton) ?: rawButton when (event.action) { KeyEvent.ACTION_DOWN -> onButtonEvent?.invoke(button, true) KeyEvent.ACTION_UP -> onButtonEvent?.invoke(button, false) diff --git a/app/src/main/java/com/lazy/emulate/input/OverlayVisibility.kt b/app/src/main/java/com/lazy/emulate/input/OverlayVisibility.kt new file mode 100644 index 0000000..6f87776 --- /dev/null +++ b/app/src/main/java/com/lazy/emulate/input/OverlayVisibility.kt @@ -0,0 +1,12 @@ +package com.lazy.emulate.input + +enum class OverlayVisibility(val label: String) { + AUTO("Auto"), + ALWAYS_SHOW("Always Show"), + ALWAYS_HIDE("Always Hide"); + + fun next(): OverlayVisibility { + val values = entries + return values[(ordinal + 1) % values.size] + } +} 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 7fee3c4..e1e8b1e 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 @@ -7,8 +7,10 @@ import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.navArgument +import com.lazy.emulate.data.PreferencesManager import com.lazy.emulate.data.repository.GameRepository import com.lazy.emulate.emulation.ConsoleType +import com.lazy.emulate.input.ButtonMappingManager import com.lazy.emulate.input.ControllerManager import com.lazy.emulate.ui.screens.controller.ControllerLayoutScreen import com.lazy.emulate.ui.screens.game.GameScreen @@ -20,7 +22,9 @@ fun EmulateNavGraph( navController: NavHostController, windowSizeClass: WindowSizeClass, gameRepository: GameRepository, - controllerManager: ControllerManager + controllerManager: ControllerManager, + preferencesManager: PreferencesManager, + buttonMappingManager: ButtonMappingManager ) { NavHost( navController = navController, @@ -45,6 +49,8 @@ fun EmulateNavGraph( composable(Screen.Settings.route) { SettingsScreen( controllerManager = controllerManager, + preferencesManager = preferencesManager, + buttonMappingManager = buttonMappingManager, onBack = { navController.popBackStack() } ) } @@ -70,6 +76,7 @@ fun EmulateNavGraph( GameScreen( game = game, controllerManager = controllerManager, + preferencesManager = preferencesManager, onBack = { navController.popBackStack() } ) } diff --git a/app/src/main/java/com/lazy/emulate/ui/screens/game/GameScreen.kt b/app/src/main/java/com/lazy/emulate/ui/screens/game/GameScreen.kt index f6e640d..269e855 100644 --- a/app/src/main/java/com/lazy/emulate/ui/screens/game/GameScreen.kt +++ b/app/src/main/java/com/lazy/emulate/ui/screens/game/GameScreen.kt @@ -1,56 +1,108 @@ package com.lazy.emulate.ui.screens.game +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.os.Handler +import android.os.Looper +import android.view.PixelCopy import android.view.SurfaceHolder import android.view.SurfaceView +import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio 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.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.AspectRatio +import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material.icons.filled.AspectRatio -import androidx.compose.material.icons.filled.Save +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf 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.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView +import com.lazy.emulate.data.PreferencesManager import com.lazy.emulate.data.model.Game import com.lazy.emulate.emulation.DisplayMode import com.lazy.emulate.emulation.EmulationEngine import com.lazy.emulate.emulation.EmulatorCore +import com.lazy.emulate.emulation.SaveSlotInfo import com.lazy.emulate.emulation.cores.Ps1Core import com.lazy.emulate.input.ControllerLayout import com.lazy.emulate.input.ControllerManager +import com.lazy.emulate.input.OverlayVisibility import com.lazy.emulate.ui.components.TouchControllerOverlay +import java.io.File +import java.io.FileOutputStream +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale @Composable fun GameScreen( game: Game, controllerManager: ControllerManager, + preferencesManager: PreferencesManager, onBack: () -> Unit ) { val context = LocalContext.current var core by remember { mutableStateOf(null) } var isPaused by remember { mutableStateOf(false) } - var displayMode by remember { mutableStateOf(DisplayMode.STRETCH) } + var displayMode by remember { mutableStateOf(preferencesManager.displayMode) } var loadError by remember { mutableStateOf(null) } + var surfaceViewRef by remember { mutableStateOf(null) } + + // Launch menu: shown before emulation starts if saves exist + var showLaunchMenu by remember { mutableStateOf(true) } + var pendingLoadSlot by remember { mutableIntStateOf(-1) } + + // In-game menu + var showGameMenu by remember { mutableStateOf(false) } + var saveSlots by remember { mutableStateOf>(emptyList()) } + val activeController by controllerManager.activeController.collectAsState() - val showTouchControls = activeController == null + val overlayVisibility = remember { preferencesManager.overlayVisibility } + val showTouchControls = when (overlayVisibility) { + OverlayVisibility.ALWAYS_SHOW -> true + OverlayVisibility.ALWAYS_HIDE -> false + OverlayVisibility.AUTO -> activeController == null + } val layout = remember(game.consoleType) { ControllerLayout.defaultForConsole(game.consoleType) @@ -60,13 +112,24 @@ fun GameScreen( val loaded = EmulationEngine.startGame(context, game.consoleType, game.romPath) if (loaded != null) { core = loaded + loaded.setDisplayMode(displayMode) controllerManager.onButtonEvent = { button, pressed -> if (pressed) loaded.onButtonPressed(button) else loaded.onButtonReleased(button) } controllerManager.onAnalogEvent = { x, y, stick -> loaded.onAnalogStick(x, y, stick) } + // Check if any saves exist to decide whether to show launch menu + val slots = loaded.getAllSaveSlots() + val hasSaves = slots.any { it.exists } + if (hasSaves) { + saveSlots = slots + showLaunchMenu = true + } else { + showLaunchMenu = false + } } else { + showLaunchMenu = false loadError = "Failed to load game. Check that the core .so and BIOS are installed." } onDispose { @@ -82,68 +145,200 @@ fun GameScreen( .fillMaxSize() .background(Color.Black) ) { - // Emulation surface - if (core != null) { - AndroidView( - factory = { ctx -> - SurfaceView(ctx).apply { - holder.addCallback(object : SurfaceHolder.Callback { - override fun surfaceCreated(holder: SurfaceHolder) { - core?.setSurface(holder.surface) - core?.start() - } - - override fun surfaceChanged( - holder: SurfaceHolder, format: Int, width: Int, height: Int - ) { - } - - override fun surfaceDestroyed(holder: SurfaceHolder) { - core?.pause() - (core as? Ps1Core)?.releaseSurface() - } - }) - } + if (showLaunchMenu) { + // Launch menu - shown before emulation starts + LaunchMenu( + gameTitle = game.title, + slots = saveSlots, + onContinue = { slot -> + pendingLoadSlot = slot + showLaunchMenu = false }, - modifier = Modifier.fillMaxSize() + onNewGame = { + pendingLoadSlot = -1 + showLaunchMenu = false + }, + onBack = onBack ) - } + } else { + // Emulation surface + if (core != null) { + AndroidView( + factory = { ctx -> + SurfaceView(ctx).also { surfaceViewRef = it }.apply { + holder.addCallback(object : SurfaceHolder.Callback { + override fun surfaceCreated(holder: SurfaceHolder) { + core?.setSurface(holder.surface) + core?.start() + if (pendingLoadSlot >= 0) { + core?.pause() + core?.loadState(pendingLoadSlot) + core?.resume() + pendingLoadSlot = -1 + } + } - // Error or placeholder message - val msg = loadError - ?: if (core == null || !game.consoleType.isImplemented) - "${game.consoleType.displayName} emulation coming soon" - else null + override fun surfaceChanged( + holder: SurfaceHolder, format: Int, width: Int, height: Int + ) { + } - if (msg != null) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Text( - text = msg, - style = MaterialTheme.typography.headlineMedium, - color = Color.White.copy(alpha = 0.7f) + override fun surfaceDestroyed(holder: SurfaceHolder) { + core?.pause() + (core as? Ps1Core)?.releaseSurface() + } + }) + } + }, + modifier = Modifier.fillMaxSize() + ) + } + + // Error or placeholder message + val msg = loadError + ?: if (core == null || !game.consoleType.isImplemented) + "${game.consoleType.displayName} emulation coming soon" + else null + + if (msg != null) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = msg, + style = MaterialTheme.typography.headlineMedium, + color = Color.White.copy(alpha = 0.7f) + ) + } + } + + // Touch controls + if (showTouchControls && core != null && !showGameMenu) { + TouchControllerOverlay( + layout = layout, + onButtonPressed = { core?.onButtonPressed(it) }, + onButtonReleased = { core?.onButtonReleased(it) }, + onAnalogStick = { x, y, stick -> core?.onAnalogStick(x, y, stick) } + ) + } + + // HUD controls (hidden when game menu is open) + if (!showGameMenu) { + IconButton( + onClick = onBack, + modifier = Modifier + .align(Alignment.TopStart) + .padding(8.dp) + ) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + tint = Color.White.copy(alpha = 0.7f) + ) + } + + IconButton( + onClick = { + isPaused = !isPaused + if (isPaused) core?.pause() else core?.resume() + }, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(8.dp) + ) { + Icon( + if (isPaused) Icons.Default.PlayArrow else Icons.Default.Pause, + contentDescription = if (isPaused) "Resume" else "Pause", + tint = Color.White.copy(alpha = 0.7f) + ) + } + + IconButton( + onClick = { + core?.pause() + saveSlots = core?.getAllSaveSlots() ?: emptyList() + showGameMenu = true + }, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(top = 8.dp, end = 48.dp) + ) { + Icon( + Icons.Default.Menu, + contentDescription = "Game Menu", + tint = Color.White.copy(alpha = 0.7f) + ) + } + + IconButton( + onClick = { + displayMode = displayMode.next() + preferencesManager.displayMode = displayMode + core?.setDisplayMode(displayMode) + }, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(top = 8.dp, end = 88.dp) + ) { + Icon( + Icons.Default.AspectRatio, + contentDescription = "Display: ${displayMode.label}", + tint = Color.White.copy(alpha = 0.7f) + ) + } + } + + // In-game menu overlay + if (showGameMenu) { + GameMenu( + slots = saveSlots, + onSave = { slot -> + val c = core ?: return@GameMenu + c.saveState(slot) + val sv = surfaceViewRef + val screenshotPath = c.getScreenshotPath(slot) + if (sv != null && screenshotPath != null) { + captureScreenshot(sv, screenshotPath) + } + saveSlots = c.getAllSaveSlots() + }, + onLoad = { slot -> + val c = core ?: return@GameMenu + c.loadState(slot) + if (!isPaused) c.resume() + showGameMenu = false + }, + onResume = { + if (!isPaused) core?.resume() + showGameMenu = false + } ) } } + } +} - // Touch controls (hidden when BT controller connected) - if (showTouchControls && core != null) { - TouchControllerOverlay( - layout = layout, - onButtonPressed = { core?.onButtonPressed(it) }, - onButtonReleased = { core?.onButtonReleased(it) }, - onAnalogStick = { x, y, stick -> core?.onAnalogStick(x, y, stick) } - ) - } +@Composable +private fun LaunchMenu( + gameTitle: String, + slots: List, + onContinue: (slot: Int) -> Unit, + onNewGame: () -> Unit, + onBack: () -> Unit +) { + val dateFormat = remember { SimpleDateFormat("MMM d, yyyy h:mm a", Locale.getDefault()) } + val existingSlots = slots.filter { it.exists } - // HUD controls + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black) + .padding(24.dp) + ) { IconButton( onClick = onBack, - modifier = Modifier - .align(Alignment.TopStart) - .padding(8.dp) + modifier = Modifier.align(Alignment.TopStart) ) { Icon( Icons.AutoMirrored.Filled.ArrowBack, @@ -152,49 +347,225 @@ fun GameScreen( ) } - IconButton( - onClick = { - isPaused = !isPaused - if (isPaused) core?.pause() else core?.resume() - }, + Column( modifier = Modifier - .align(Alignment.TopEnd) - .padding(8.dp) + .fillMaxWidth() + .align(Alignment.Center), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) ) { - Icon( - if (isPaused) Icons.Default.PlayArrow else Icons.Default.Pause, - contentDescription = if (isPaused) "Resume" else "Pause", - tint = Color.White.copy(alpha = 0.7f) + Text( + text = gameTitle, + style = MaterialTheme.typography.headlineMedium, + color = Color.White, + textAlign = TextAlign.Center ) - } - IconButton( - onClick = { core?.saveState(0) }, - modifier = Modifier - .align(Alignment.TopEnd) - .padding(top = 8.dp, end = 48.dp) - ) { - Icon( - Icons.Default.Save, - contentDescription = "Save State", - tint = Color.White.copy(alpha = 0.7f) - ) - } + Spacer(Modifier.height(8.dp)) - IconButton( - onClick = { - displayMode = displayMode.next() - core?.setDisplayMode(displayMode) - }, - modifier = Modifier - .align(Alignment.TopEnd) - .padding(top = 8.dp, end = 88.dp) - ) { - Icon( - Icons.Default.AspectRatio, - contentDescription = "Display: ${displayMode.label}", - tint = Color.White.copy(alpha = 0.7f) + Text( + text = "Continue", + style = MaterialTheme.typography.titleMedium, + color = Color.White.copy(alpha = 0.7f) ) + + existingSlots.forEach { slot -> + val thumbnail = remember(slot.screenshotPath, slot.lastModified) { + slot.screenshotPath?.let { path -> + try { BitmapFactory.decodeFile(path) } catch (_: Exception) { null } + } + } + Row( + modifier = Modifier + .fillMaxWidth(0.8f) + .clip(RoundedCornerShape(12.dp)) + .border(1.dp, Color.White.copy(alpha = 0.3f), RoundedCornerShape(12.dp)) + .clickable { onContinue(slot.slot) } + .padding(12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .width(96.dp) + .aspectRatio(4f / 3f) + .clip(RoundedCornerShape(6.dp)) + .background(Color.DarkGray), + contentAlignment = Alignment.Center + ) { + if (thumbnail != null) { + Image( + bitmap = thumbnail.asImageBitmap(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize() + ) + } + } + Column { + Text( + text = "Slot ${slot.slot + 1}", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Medium, + color = Color.White + ) + Text( + text = dateFormat.format(Date(slot.lastModified)), + style = MaterialTheme.typography.bodySmall, + color = Color.White.copy(alpha = 0.5f) + ) + } + } + } + + Spacer(Modifier.height(8.dp)) + + OutlinedButton( + onClick = onNewGame, + colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White), + modifier = Modifier.fillMaxWidth(0.8f) + ) { + Text("New Game") + } } } } + +@Composable +private fun GameMenu( + slots: List, + onSave: (slot: Int) -> Unit, + onLoad: (slot: Int) -> Unit, + onResume: () -> Unit +) { + val dateFormat = remember { SimpleDateFormat("MMM d, yyyy h:mm a", Locale.getDefault()) } + + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.85f)) + .clickable(enabled = false) {}, + contentAlignment = Alignment.Center + ) { + Column( + modifier = Modifier + .fillMaxWidth(0.9f) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "Save / Load", + style = MaterialTheme.typography.titleLarge, + color = Color.White + ) + + Spacer(Modifier.height(8.dp)) + + slots.forEach { slot -> + val thumbnail = remember(slot.screenshotPath, slot.lastModified) { + slot.screenshotPath?.let { path -> + try { BitmapFactory.decodeFile(path) } catch (_: Exception) { null } + } + } + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .border(1.dp, Color.White.copy(alpha = 0.2f), RoundedCornerShape(8.dp)) + .padding(8.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Thumbnail + Box( + modifier = Modifier + .width(72.dp) + .aspectRatio(4f / 3f) + .clip(RoundedCornerShape(4.dp)) + .background(Color.DarkGray), + contentAlignment = Alignment.Center + ) { + if (thumbnail != null) { + Image( + bitmap = thumbnail.asImageBitmap(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize() + ) + } else { + Text( + text = "Empty", + style = MaterialTheme.typography.labelSmall, + color = Color.White.copy(alpha = 0.3f) + ) + } + } + + // Slot info + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Slot ${slot.slot + 1}", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = Color.White + ) + Text( + text = if (slot.exists) dateFormat.format(Date(slot.lastModified)) + else "No save data", + style = MaterialTheme.typography.labelSmall, + color = Color.White.copy(alpha = 0.5f) + ) + } + + // Save button + TextButton( + onClick = { onSave(slot.slot) }, + modifier = Modifier.size(width = 56.dp, height = 36.dp) + ) { + Text("Save", style = MaterialTheme.typography.labelMedium) + } + + // Load button + TextButton( + onClick = { onLoad(slot.slot) }, + enabled = slot.exists, + modifier = Modifier.size(width = 56.dp, height = 36.dp) + ) { + Text("Load", style = MaterialTheme.typography.labelMedium) + } + } + } + + Spacer(Modifier.height(8.dp)) + + Button(onClick = onResume) { + Text("Resume") + } + } + } +} + +private fun captureScreenshot(surfaceView: SurfaceView, outputPath: String) { + try { + val bitmap = Bitmap.createBitmap( + surfaceView.width, surfaceView.height, Bitmap.Config.ARGB_8888 + ) + PixelCopy.request( + surfaceView, bitmap, + { result -> + if (result == PixelCopy.SUCCESS) { + val thumb = Bitmap.createScaledBitmap(bitmap, 320, 240, true) + FileOutputStream(File(outputPath)).use { out -> + thumb.compress(Bitmap.CompressFormat.JPEG, 85, out) + } + thumb.recycle() + } + bitmap.recycle() + }, + Handler(Looper.getMainLooper()) + ) + } catch (_: Exception) { + // Screenshot is best-effort + } +} 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 eab05a6..5c5584b 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,6 +1,7 @@ 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 @@ -16,6 +17,8 @@ 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.material3.Card import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider @@ -24,6 +27,7 @@ import androidx.compose.material3.IconButton 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 @@ -33,20 +37,44 @@ 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 ) { 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 = { TopAppBar( @@ -117,6 +145,176 @@ fun SettingsScreen( 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 + ) + 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", @@ -153,13 +351,14 @@ fun SettingsScreen( ) Spacer(modifier = Modifier.height(12.dp)) - var displayMode by remember { mutableStateOf(DisplayMode.STRETCH) } - Card(modifier = Modifier.fillMaxWidth()) { Column( modifier = Modifier .fillMaxWidth() - .clickable { displayMode = displayMode.next() } + .clickable { + displayMode = displayMode.next() + preferencesManager.displayMode = displayMode + } .padding(16.dp) ) { Text("Display Mode", style = MaterialTheme.typography.bodyLarge) @@ -191,6 +390,141 @@ 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