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:
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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_"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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<SaveSlotInfo> =
|
||||
(0 until saveSlotCount).map { getSaveSlotInfo(it) }
|
||||
|
||||
fun setAudioEnabled(enabled: Boolean)
|
||||
|
||||
fun setFrameSkip(skip: Int)
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<EmulatorCore?>(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<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 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,15 +145,37 @@ fun GameScreen(
|
||||
.fillMaxSize()
|
||||
.background(Color.Black)
|
||||
) {
|
||||
if (showLaunchMenu) {
|
||||
// Launch menu - shown before emulation starts
|
||||
LaunchMenu(
|
||||
gameTitle = game.title,
|
||||
slots = saveSlots,
|
||||
onContinue = { slot ->
|
||||
pendingLoadSlot = slot
|
||||
showLaunchMenu = false
|
||||
},
|
||||
onNewGame = {
|
||||
pendingLoadSlot = -1
|
||||
showLaunchMenu = false
|
||||
},
|
||||
onBack = onBack
|
||||
)
|
||||
} else {
|
||||
// Emulation surface
|
||||
if (core != null) {
|
||||
AndroidView(
|
||||
factory = { ctx ->
|
||||
SurfaceView(ctx).apply {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
override fun surfaceChanged(
|
||||
@@ -128,8 +213,8 @@ fun GameScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Touch controls (hidden when BT controller connected)
|
||||
if (showTouchControls && core != null) {
|
||||
// Touch controls
|
||||
if (showTouchControls && core != null && !showGameMenu) {
|
||||
TouchControllerOverlay(
|
||||
layout = layout,
|
||||
onButtonPressed = { core?.onButtonPressed(it) },
|
||||
@@ -138,7 +223,8 @@ fun GameScreen(
|
||||
)
|
||||
}
|
||||
|
||||
// HUD controls
|
||||
// HUD controls (hidden when game menu is open)
|
||||
if (!showGameMenu) {
|
||||
IconButton(
|
||||
onClick = onBack,
|
||||
modifier = Modifier
|
||||
@@ -169,14 +255,18 @@ fun GameScreen(
|
||||
}
|
||||
|
||||
IconButton(
|
||||
onClick = { core?.saveState(0) },
|
||||
onClick = {
|
||||
core?.pause()
|
||||
saveSlots = core?.getAllSaveSlots() ?: emptyList()
|
||||
showGameMenu = true
|
||||
},
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(top = 8.dp, end = 48.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Save,
|
||||
contentDescription = "Save State",
|
||||
Icons.Default.Menu,
|
||||
contentDescription = "Game Menu",
|
||||
tint = Color.White.copy(alpha = 0.7f)
|
||||
)
|
||||
}
|
||||
@@ -184,6 +274,7 @@ fun GameScreen(
|
||||
IconButton(
|
||||
onClick = {
|
||||
displayMode = displayMode.next()
|
||||
preferencesManager.displayMode = displayMode
|
||||
core?.setDisplayMode(displayMode)
|
||||
},
|
||||
modifier = Modifier
|
||||
@@ -197,4 +288,284 @@ fun GameScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LaunchMenu(
|
||||
gameTitle: String,
|
||||
slots: List<SaveSlotInfo>,
|
||||
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 }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black)
|
||||
.padding(24.dp)
|
||||
) {
|
||||
IconButton(
|
||||
onClick = onBack,
|
||||
modifier = Modifier.align(Alignment.TopStart)
|
||||
) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Back",
|
||||
tint = Color.White.copy(alpha = 0.7f)
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.align(Alignment.Center),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = gameTitle,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
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<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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<GamepadButton?>(null) }
|
||||
var remapTarget by remember { mutableStateOf<String?>(null) }
|
||||
// Force recomposition after remap changes
|
||||
var remapVersion by remember { mutableStateOf(0) }
|
||||
|
||||
// Controller remap state
|
||||
var showControllerRemapDialog by remember { mutableStateOf(false) }
|
||||
var controllerRemapFrom by remember { mutableStateOf<GamepadButton?>(null) }
|
||||
var controllerRemapVersion by remember { mutableStateOf(0) }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user