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.
This commit is contained in:
2026-04-09 20:46:21 -04:00
parent 14045accb5
commit 0b4e408d18
12 changed files with 1074 additions and 110 deletions

View File

@@ -14,7 +14,10 @@ import androidx.activity.enableEdgeToEdge
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import com.lazy.emulate.data.PreferencesManager
import com.lazy.emulate.data.repository.GameRepository 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.input.ControllerManager
import com.lazy.emulate.ui.navigation.EmulateNavGraph import com.lazy.emulate.ui.navigation.EmulateNavGraph
import com.lazy.emulate.ui.theme.EmulateTheme import com.lazy.emulate.ui.theme.EmulateTheme
@@ -23,6 +26,10 @@ class MainActivity : ComponentActivity() {
private lateinit var controllerManager: ControllerManager private lateinit var controllerManager: ControllerManager
private lateinit var gameRepository: GameRepository private lateinit var gameRepository: GameRepository
lateinit var preferencesManager: PreferencesManager
private set
lateinit var buttonMappingManager: ButtonMappingManager
private set
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class) @OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@@ -31,8 +38,11 @@ class MainActivity : ComponentActivity() {
requestAllFilesPermission() requestAllFilesPermission()
controllerManager = ControllerManager(this) preferencesManager = PreferencesManager(this)
buttonMappingManager = ButtonMappingManager(preferencesManager)
controllerManager = ControllerManager(this, buttonMappingManager)
gameRepository = GameRepository(this) gameRepository = GameRepository(this)
EmulationEngine.buttonMappingManager = buttonMappingManager
setContent { setContent {
EmulateTheme { EmulateTheme {
@@ -43,7 +53,9 @@ class MainActivity : ComponentActivity() {
navController = navController, navController = navController,
windowSizeClass = windowSizeClass, windowSizeClass = windowSizeClass,
gameRepository = gameRepository, gameRepository = gameRepository,
controllerManager = controllerManager controllerManager = controllerManager,
preferencesManager = preferencesManager,
buttonMappingManager = buttonMappingManager
) )
} }
} }

View File

@@ -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<GamepadButton, Int> {
val prefix = "${KEY_BUTTON_MAP_PREFIX}${consoleType.name}_"
val result = mutableMapOf<GamepadButton, Int>()
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<GamepadButton, GamepadButton> {
val result = mutableMapOf<GamepadButton, GamepadButton>()
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_"
}
}

View File

@@ -3,14 +3,16 @@ package com.lazy.emulate.emulation
import android.content.Context import android.content.Context
import com.lazy.emulate.emulation.cores.PlaceholderCore import com.lazy.emulate.emulation.cores.PlaceholderCore
import com.lazy.emulate.emulation.cores.Ps1Core import com.lazy.emulate.emulation.cores.Ps1Core
import com.lazy.emulate.input.ButtonMappingManager
object EmulationEngine { object EmulationEngine {
private var activeCore: EmulatorCore? = null private var activeCore: EmulatorCore? = null
var buttonMappingManager: ButtonMappingManager? = null
fun getCoreForConsole(context: Context, consoleType: ConsoleType): EmulatorCore { fun getCoreForConsole(context: Context, consoleType: ConsoleType): EmulatorCore {
return when (consoleType) { return when (consoleType) {
ConsoleType.PS1 -> Ps1Core(context) ConsoleType.PS1 -> Ps1Core(context, buttonMappingManager)
ConsoleType.NES -> PlaceholderCore(ConsoleType.NES) ConsoleType.NES -> PlaceholderCore(ConsoleType.NES)
ConsoleType.SNES -> PlaceholderCore(ConsoleType.SNES) ConsoleType.SNES -> PlaceholderCore(ConsoleType.SNES)
ConsoleType.N64 -> PlaceholderCore(ConsoleType.N64) ConsoleType.N64 -> PlaceholderCore(ConsoleType.N64)

View File

@@ -3,12 +3,21 @@ package com.lazy.emulate.emulation
import android.view.Surface import android.view.Surface
import com.lazy.emulate.input.GamepadButton 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 { interface EmulatorCore {
val consoleType: ConsoleType val consoleType: ConsoleType
val isRunning: Boolean val isRunning: Boolean
val saveSlotCount: Int get() = 5
fun loadRom(path: String): Boolean fun loadRom(path: String): Boolean
fun start() fun start()
@@ -33,6 +42,13 @@ interface EmulatorCore {
fun loadState(slot: Int): Boolean fun loadState(slot: Int): Boolean
fun getSaveSlotInfo(slot: Int): SaveSlotInfo
fun getScreenshotPath(slot: Int): String? = null
fun getAllSaveSlots(): List<SaveSlotInfo> =
(0 until saveSlotCount).map { getSaveSlotInfo(it) }
fun setAudioEnabled(enabled: Boolean) fun setAudioEnabled(enabled: Boolean)
fun setFrameSkip(skip: Int) fun setFrameSkip(skip: Int)

View File

@@ -5,6 +5,7 @@ import android.view.Surface
import com.lazy.emulate.emulation.ConsoleType import com.lazy.emulate.emulation.ConsoleType
import com.lazy.emulate.emulation.DisplayMode import com.lazy.emulate.emulation.DisplayMode
import com.lazy.emulate.emulation.EmulatorCore import com.lazy.emulate.emulation.EmulatorCore
import com.lazy.emulate.emulation.SaveSlotInfo
import com.lazy.emulate.input.GamepadButton 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 onAnalogStick(x: Float, y: Float, stickIndex: Int) {}
override fun saveState(slot: Int): Boolean = false override fun saveState(slot: Int): Boolean = false
override fun loadState(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 setAudioEnabled(enabled: Boolean) {}
override fun setFrameSkip(skip: Int) {} override fun setFrameSkip(skip: Int) {}
override fun setDisplayMode(mode: DisplayMode) {} override fun setDisplayMode(mode: DisplayMode) {}

View File

@@ -7,10 +7,15 @@ import com.lazy.emulate.emulation.ConsoleType
import com.lazy.emulate.emulation.DisplayMode import com.lazy.emulate.emulation.DisplayMode
import com.lazy.emulate.emulation.EmulatorCore import com.lazy.emulate.emulation.EmulatorCore
import com.lazy.emulate.emulation.NativeLibretro 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 com.lazy.emulate.input.GamepadButton
import java.io.File 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 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 coresDir = File(context.filesDir, "cores")
private val systemDir = File(context.filesDir, "system") 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 { override fun loadRom(path: String): Boolean {
coresDir.mkdirs() coresDir.mkdirs()
systemDir.mkdirs() systemDir.mkdirs()
// Per-game save directory based on ROM filename
val romName = File(path).nameWithoutExtension
savesDir = File(baseSavesDir, romName)
savesDir.mkdirs() savesDir.mkdirs()
NativeLibretro.nativeSetSystemDir(systemDir.absolutePath) NativeLibretro.nativeSetSystemDir(systemDir.absolutePath)
@@ -119,6 +129,20 @@ class Ps1Core(private val context: Context) : EmulatorCore {
return NativeLibretro.nativeLoadState(path) 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) { override fun setAudioEnabled(enabled: Boolean) {
NativeLibretro.nativeSetAudioEnabled(enabled) NativeLibretro.nativeSetAudioEnabled(enabled)
} }
@@ -171,14 +195,18 @@ class Ps1Core(private val context: Context) : EmulatorCore {
} }
private fun mapButtonToRetroId(button: GamepadButton): Int { 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) { return when (button) {
// libretro joypad IDs: B=0 Y=1 SELECT=2 START=3 UP=4 DOWN=5 LEFT=6 RIGHT=7 GamepadButton.FACE_BOTTOM -> 0 // Cross -> B
// A=8 X=9 L=10 R=11 L2=12 R2=13 L3=14 R3=15 GamepadButton.FACE_RIGHT -> 8 // Circle -> A
// PS1 mapping: Cross=A(8) Circle=B(0) Square=X(9) Triangle=Y(1) GamepadButton.FACE_LEFT -> 1 // Square -> Y
GamepadButton.FACE_BOTTOM -> 8 // Cross -> A GamepadButton.FACE_TOP -> 9 // Triangle -> X
GamepadButton.FACE_RIGHT -> 0 // Circle -> B
GamepadButton.FACE_LEFT -> 9 // Square -> X
GamepadButton.FACE_TOP -> 1 // Triangle -> Y
GamepadButton.DPAD_UP -> 4 GamepadButton.DPAD_UP -> 4
GamepadButton.DPAD_DOWN -> 5 GamepadButton.DPAD_DOWN -> 5
GamepadButton.DPAD_LEFT -> 6 GamepadButton.DPAD_LEFT -> 6

View File

@@ -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<GamepadButton, Int> = emptyMap()
private var controllerRemap: Map<GamepadButton, GamepadButton> = 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<GamepadButton, Int> {
if (cachedConsole != consoleType) {
rebuildCache(consoleType)
}
return cachedMapping
}
fun getControllerRemaps(): Map<GamepadButton, GamepadButton> {
if (controllerRemap.isEmpty()) {
controllerRemap = preferencesManager.getControllerButtonRemap()
}
return controllerRemap
}
fun getDefaultMapping(consoleType: ConsoleType): Map<GamepadButton, Int> {
return defaultMappings[consoleType] ?: emptyMap()
}
private fun rebuildCache(consoleType: ConsoleType) {
val defaults = defaultMappings[consoleType] ?: emptyMap()
val overrides = preferencesManager.getButtonMapping(consoleType)
cachedMapping = defaults + overrides
cachedConsole = consoleType
}
}

View File

@@ -9,7 +9,10 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow 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 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 { fun handleKeyEvent(event: KeyEvent): Boolean {
if (!isGameControllerDevice(event.device ?: return false)) return false 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) { when (event.action) {
KeyEvent.ACTION_DOWN -> onButtonEvent?.invoke(button, true) KeyEvent.ACTION_DOWN -> onButtonEvent?.invoke(button, true)
KeyEvent.ACTION_UP -> onButtonEvent?.invoke(button, false) KeyEvent.ACTION_UP -> onButtonEvent?.invoke(button, false)

View File

@@ -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]
}
}

View File

@@ -7,8 +7,10 @@ import androidx.navigation.NavType
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.navArgument import androidx.navigation.navArgument
import com.lazy.emulate.data.PreferencesManager
import com.lazy.emulate.data.repository.GameRepository import com.lazy.emulate.data.repository.GameRepository
import com.lazy.emulate.emulation.ConsoleType import com.lazy.emulate.emulation.ConsoleType
import com.lazy.emulate.input.ButtonMappingManager
import com.lazy.emulate.input.ControllerManager import com.lazy.emulate.input.ControllerManager
import com.lazy.emulate.ui.screens.controller.ControllerLayoutScreen import com.lazy.emulate.ui.screens.controller.ControllerLayoutScreen
import com.lazy.emulate.ui.screens.game.GameScreen import com.lazy.emulate.ui.screens.game.GameScreen
@@ -20,7 +22,9 @@ fun EmulateNavGraph(
navController: NavHostController, navController: NavHostController,
windowSizeClass: WindowSizeClass, windowSizeClass: WindowSizeClass,
gameRepository: GameRepository, gameRepository: GameRepository,
controllerManager: ControllerManager controllerManager: ControllerManager,
preferencesManager: PreferencesManager,
buttonMappingManager: ButtonMappingManager
) { ) {
NavHost( NavHost(
navController = navController, navController = navController,
@@ -45,6 +49,8 @@ fun EmulateNavGraph(
composable(Screen.Settings.route) { composable(Screen.Settings.route) {
SettingsScreen( SettingsScreen(
controllerManager = controllerManager, controllerManager = controllerManager,
preferencesManager = preferencesManager,
buttonMappingManager = buttonMappingManager,
onBack = { navController.popBackStack() } onBack = { navController.popBackStack() }
) )
} }
@@ -70,6 +76,7 @@ fun EmulateNavGraph(
GameScreen( GameScreen(
game = game, game = game,
controllerManager = controllerManager, controllerManager = controllerManager,
preferencesManager = preferencesManager,
onBack = { navController.popBackStack() } onBack = { navController.popBackStack() }
) )
} }

View File

@@ -1,56 +1,108 @@
package com.lazy.emulate.ui.screens.game 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.SurfaceHolder
import android.view.SurfaceView import android.view.SurfaceView
import androidx.compose.foundation.Image
import androidx.compose.foundation.background 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.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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding 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.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack 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.Pause
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.AspectRatio import androidx.compose.material3.Button
import androidx.compose.material.icons.filled.Save import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color 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.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.unit.dp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import com.lazy.emulate.data.PreferencesManager
import com.lazy.emulate.data.model.Game import com.lazy.emulate.data.model.Game
import com.lazy.emulate.emulation.DisplayMode import com.lazy.emulate.emulation.DisplayMode
import com.lazy.emulate.emulation.EmulationEngine import com.lazy.emulate.emulation.EmulationEngine
import com.lazy.emulate.emulation.EmulatorCore import com.lazy.emulate.emulation.EmulatorCore
import com.lazy.emulate.emulation.SaveSlotInfo
import com.lazy.emulate.emulation.cores.Ps1Core import com.lazy.emulate.emulation.cores.Ps1Core
import com.lazy.emulate.input.ControllerLayout import com.lazy.emulate.input.ControllerLayout
import com.lazy.emulate.input.ControllerManager import com.lazy.emulate.input.ControllerManager
import com.lazy.emulate.input.OverlayVisibility
import com.lazy.emulate.ui.components.TouchControllerOverlay 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 @Composable
fun GameScreen( fun GameScreen(
game: Game, game: Game,
controllerManager: ControllerManager, controllerManager: ControllerManager,
preferencesManager: PreferencesManager,
onBack: () -> Unit onBack: () -> Unit
) { ) {
val context = LocalContext.current val context = LocalContext.current
var core by remember { mutableStateOf<EmulatorCore?>(null) } var core by remember { mutableStateOf<EmulatorCore?>(null) }
var isPaused by remember { mutableStateOf(false) } 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<String?>(null) } var loadError by remember { mutableStateOf<String?>(null) }
var surfaceViewRef by remember { mutableStateOf<SurfaceView?>(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<List<SaveSlotInfo>>(emptyList()) }
val activeController by controllerManager.activeController.collectAsState() 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) { val layout = remember(game.consoleType) {
ControllerLayout.defaultForConsole(game.consoleType) ControllerLayout.defaultForConsole(game.consoleType)
@@ -60,13 +112,24 @@ fun GameScreen(
val loaded = EmulationEngine.startGame(context, game.consoleType, game.romPath) val loaded = EmulationEngine.startGame(context, game.consoleType, game.romPath)
if (loaded != null) { if (loaded != null) {
core = loaded core = loaded
loaded.setDisplayMode(displayMode)
controllerManager.onButtonEvent = { button, pressed -> controllerManager.onButtonEvent = { button, pressed ->
if (pressed) loaded.onButtonPressed(button) else loaded.onButtonReleased(button) if (pressed) loaded.onButtonPressed(button) else loaded.onButtonReleased(button)
} }
controllerManager.onAnalogEvent = { x, y, stick -> controllerManager.onAnalogEvent = { x, y, stick ->
loaded.onAnalogStick(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 { } else {
showLaunchMenu = false
loadError = "Failed to load game. Check that the core .so and BIOS are installed." loadError = "Failed to load game. Check that the core .so and BIOS are installed."
} }
onDispose { onDispose {
@@ -82,68 +145,200 @@ fun GameScreen(
.fillMaxSize() .fillMaxSize()
.background(Color.Black) .background(Color.Black)
) { ) {
// Emulation surface if (showLaunchMenu) {
if (core != null) { // Launch menu - shown before emulation starts
AndroidView( LaunchMenu(
factory = { ctx -> gameTitle = game.title,
SurfaceView(ctx).apply { slots = saveSlots,
holder.addCallback(object : SurfaceHolder.Callback { onContinue = { slot ->
override fun surfaceCreated(holder: SurfaceHolder) { pendingLoadSlot = slot
core?.setSurface(holder.surface) showLaunchMenu = false
core?.start()
}
override fun surfaceChanged(
holder: SurfaceHolder, format: Int, width: Int, height: Int
) {
}
override fun surfaceDestroyed(holder: SurfaceHolder) {
core?.pause()
(core as? Ps1Core)?.releaseSurface()
}
})
}
}, },
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 override fun surfaceChanged(
val msg = loadError holder: SurfaceHolder, format: Int, width: Int, height: Int
?: if (core == null || !game.consoleType.isImplemented) ) {
"${game.consoleType.displayName} emulation coming soon" }
else null
if (msg != null) { override fun surfaceDestroyed(holder: SurfaceHolder) {
Box( core?.pause()
modifier = Modifier.fillMaxSize(), (core as? Ps1Core)?.releaseSurface()
contentAlignment = Alignment.Center }
) { })
Text( }
text = msg, },
style = MaterialTheme.typography.headlineMedium, modifier = Modifier.fillMaxSize()
color = Color.White.copy(alpha = 0.7f) )
}
// 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) @Composable
if (showTouchControls && core != null) { private fun LaunchMenu(
TouchControllerOverlay( gameTitle: String,
layout = layout, slots: List<SaveSlotInfo>,
onButtonPressed = { core?.onButtonPressed(it) }, onContinue: (slot: Int) -> Unit,
onButtonReleased = { core?.onButtonReleased(it) }, onNewGame: () -> Unit,
onAnalogStick = { x, y, stick -> core?.onAnalogStick(x, y, stick) } 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( IconButton(
onClick = onBack, onClick = onBack,
modifier = Modifier modifier = Modifier.align(Alignment.TopStart)
.align(Alignment.TopStart)
.padding(8.dp)
) { ) {
Icon( Icon(
Icons.AutoMirrored.Filled.ArrowBack, Icons.AutoMirrored.Filled.ArrowBack,
@@ -152,49 +347,225 @@ fun GameScreen(
) )
} }
IconButton( Column(
onClick = {
isPaused = !isPaused
if (isPaused) core?.pause() else core?.resume()
},
modifier = Modifier modifier = Modifier
.align(Alignment.TopEnd) .fillMaxWidth()
.padding(8.dp) .align(Alignment.Center),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
Icon( Text(
if (isPaused) Icons.Default.PlayArrow else Icons.Default.Pause, text = gameTitle,
contentDescription = if (isPaused) "Resume" else "Pause", style = MaterialTheme.typography.headlineMedium,
tint = Color.White.copy(alpha = 0.7f) color = Color.White,
textAlign = TextAlign.Center
) )
}
IconButton( Spacer(Modifier.height(8.dp))
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)
)
}
IconButton( Text(
onClick = { text = "Continue",
displayMode = displayMode.next() style = MaterialTheme.typography.titleMedium,
core?.setDisplayMode(displayMode) color = Color.White.copy(alpha = 0.7f)
},
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)
) )
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<SaveSlotInfo>,
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
}
}

View File

@@ -1,6 +1,7 @@
package com.lazy.emulate.ui.screens.settings package com.lazy.emulate.ui.screens.settings
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer 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.Bluetooth
import androidx.compose.material.icons.filled.BluetoothConnected import androidx.compose.material.icons.filled.BluetoothConnected
import androidx.compose.material.icons.filled.Gamepad import androidx.compose.material.icons.filled.Gamepad
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
@@ -24,6 +27,7 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@@ -33,20 +37,44 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.lazy.emulate.data.PreferencesManager
import com.lazy.emulate.emulation.ConsoleType
import com.lazy.emulate.emulation.DisplayMode import com.lazy.emulate.emulation.DisplayMode
import com.lazy.emulate.input.ButtonMappingManager
import com.lazy.emulate.input.ControllerManager import com.lazy.emulate.input.ControllerManager
import com.lazy.emulate.input.GameController import com.lazy.emulate.input.GameController
import com.lazy.emulate.input.GamepadButton
import com.lazy.emulate.input.OverlayVisibility
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun SettingsScreen( fun SettingsScreen(
controllerManager: ControllerManager, controllerManager: ControllerManager,
preferencesManager: PreferencesManager,
buttonMappingManager: ButtonMappingManager,
onBack: () -> Unit onBack: () -> Unit
) { ) {
val controllers by controllerManager.connectedControllers.collectAsState() val controllers by controllerManager.connectedControllers.collectAsState()
val activeController by controllerManager.activeController.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( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
@@ -117,6 +145,176 @@ fun SettingsScreen(
Spacer(modifier = Modifier.height(24.dp)) 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 // Emulation section
Text( Text(
text = "Emulation", text = "Emulation",
@@ -153,13 +351,14 @@ fun SettingsScreen(
) )
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
var displayMode by remember { mutableStateOf(DisplayMode.STRETCH) }
Card(modifier = Modifier.fillMaxWidth()) { Card(modifier = Modifier.fillMaxWidth()) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable { displayMode = displayMode.next() } .clickable {
displayMode = displayMode.next()
preferencesManager.displayMode = displayMode
}
.padding(16.dp) .padding(16.dp)
) { ) {
Text("Display Mode", style = MaterialTheme.typography.bodyLarge) 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 @Composable