Merge feature/nes: NES emulation, search, favorites, save state delete

This commit is contained in:
2026-04-10 19:36:28 -04:00
18 changed files with 614 additions and 71 deletions

Binary file not shown.

Binary file not shown.

View File

@@ -121,4 +121,23 @@ Java_com_lazy_emulate_emulation_NativeLibretro_nativeSetSaveDir(JNIEnv* env, job
env->ReleaseStringUTFChars(dir, d);
}
JNIEXPORT void JNICALL
Java_com_lazy_emulate_emulation_NativeLibretro_nativeSetControllerPortDevice(JNIEnv*, jobject, jint port, jint device) {
LibretroFrontend::instance().setControllerPortDevice(static_cast<unsigned>(port), static_cast<unsigned>(device));
}
JNIEXPORT void JNICALL
Java_com_lazy_emulate_emulation_NativeLibretro_nativeSetPendingControllerDevice(JNIEnv*, jobject, jint device) {
LibretroFrontend::instance().setPendingControllerDevice(device);
}
JNIEXPORT void JNICALL
Java_com_lazy_emulate_emulation_NativeLibretro_nativeSetCoreOption(JNIEnv* env, jobject, jstring key, jstring value) {
const char* k = env->GetStringUTFChars(key, nullptr);
const char* v = env->GetStringUTFChars(value, nullptr);
LibretroFrontend::instance().setCoreOption(k, v);
env->ReleaseStringUTFChars(value, v);
env->ReleaseStringUTFChars(key, k);
}
} // extern "C"

View File

@@ -74,21 +74,20 @@ bool LibretroFrontend::loadCore(const char* soPath) {
core_set_input_poll_(inputPollCallback);
core_set_input_state_(inputStateCallback);
// Set controller to analog pad (DualShock) for PS1
core_set_controller_port_device_(0, RETRO_DEVICE_ANALOG);
// Set controller device at the exact spot pcsx_rearmed expects (before get_system_info)
// Only call if explicitly requested by the frontend - some cores (FCEUmm) crash on this call
// before a game is loaded.
if (pending_controller_device_ >= 0) {
core_set_controller_port_device_(0, static_cast<unsigned>(pending_controller_device_));
LOGI("Pre-init controller port 0 set to device %d", pending_controller_device_);
}
retro_system_info sys_info{};
core_get_system_info_(&sys_info);
need_fullpath_ = sys_info.need_fullpath;
LOGI("Core loaded: %s %s", sys_info.library_name, sys_info.library_version);
LOGI(" need_fullpath: %d, block_extract: %d", sys_info.need_fullpath, sys_info.block_extract);
// Set default core options for pcsx_rearmed
core_options_["pcsx_rearmed_drc"] = "enabled";
core_options_["pcsx_rearmed_pad1type"] = "analog";
core_options_["pcsx_rearmed_frameskip_type"] = "disabled";
core_options_["pcsx_rearmed_region"] = "auto";
core_options_["pcsx_rearmed_memcard2"] = "disabled";
return true;
}
@@ -100,12 +99,28 @@ bool LibretroFrontend::loadGame(const char* romPath) {
LOGI("Loading game: %s", romPath);
// pcsx_rearmed uses need_fullpath=true, so we just pass the path
retro_game_info game_info{};
game_info.path = romPath;
game_info.meta = nullptr;
if (need_fullpath_) {
game_info.data = nullptr;
game_info.size = 0;
game_info.meta = nullptr;
} else {
// Core requires ROM data in memory
std::ifstream rom_file(romPath, std::ios::binary | std::ios::ate);
if (!rom_file) {
LOGE("Failed to open ROM file: %s", romPath);
return false;
}
size_t rom_size = rom_file.tellg();
rom_file.seekg(0);
rom_data_.resize(rom_size);
rom_file.read(reinterpret_cast<char*>(rom_data_.data()), rom_size);
game_info.data = rom_data_.data();
game_info.size = rom_size;
LOGI("ROM loaded into memory: %zu bytes", rom_size);
}
if (!core_load_game_(&game_info)) {
LOGE("retro_load_game failed");
@@ -187,6 +202,8 @@ void LibretroFrontend::stop() {
game_loaded_.store(false);
}
rom_data_.clear();
rom_data_.shrink_to_fit();
audio_engine_.stop();
LOGI("Emulation stopped");
}
@@ -207,6 +224,9 @@ void LibretroFrontend::unloadCore() {
core_deinit_();
dlclose(core_handle_);
core_handle_ = nullptr;
// Clear options so they don't leak into the next core
core_options_.clear();
pending_controller_device_ = -1;
LOGI("Core unloaded");
}
}
@@ -641,12 +661,6 @@ void LibretroFrontend::setDisplayMode(int mode) {
void LibretroFrontend::setFrameSkip(int skip) {
frame_skip_ = skip;
if (skip > 0) {
core_options_["pcsx_rearmed_frameskip_type"] = "auto";
core_options_["pcsx_rearmed_frameskip_threshold"] = "33";
} else {
core_options_["pcsx_rearmed_frameskip_type"] = "disabled";
}
options_updated_.store(true);
}
@@ -657,3 +671,19 @@ void LibretroFrontend::setSystemDir(const char* dir) {
void LibretroFrontend::setSaveDir(const char* dir) {
save_dir_ = dir;
}
void LibretroFrontend::setControllerPortDevice(unsigned port, unsigned device) {
if (core_handle_ && core_set_controller_port_device_) {
core_set_controller_port_device_(port, device);
LOGI("Controller port %u set to device %u", port, device);
}
}
void LibretroFrontend::setPendingControllerDevice(int device) {
pending_controller_device_ = device;
}
void LibretroFrontend::setCoreOption(const char* key, const char* value) {
core_options_[key] = value;
options_updated_.store(true);
}

View File

@@ -9,6 +9,7 @@
#include <string>
#include <thread>
#include <unordered_map>
#include <vector>
enum class DisplayMode : int {
STRETCH = 0,
@@ -43,6 +44,9 @@ public:
void setSystemDir(const char* dir);
void setSaveDir(const char* dir);
void setControllerPortDevice(unsigned port, unsigned device);
void setPendingControllerDevice(int device);
void setCoreOption(const char* key, const char* value);
bool isRunning() const { return running_.load(); }
@@ -114,6 +118,14 @@ private:
std::string system_dir_;
std::string save_dir_;
// ROM data (kept alive for cores with need_fullpath=false)
std::vector<uint8_t> rom_data_;
bool need_fullpath_ = true;
// Pending controller device (set by frontend before loadCore, applied during loadCore)
// -1 means "do not call retro_set_controller_port_device" (default)
int pending_controller_device_ = -1;
// Frame timing
double target_fps_ = 60.0;
int frame_skip_ = 0;

View File

@@ -31,11 +31,13 @@ class CoverArtManager(private val context: Context) {
val dest = cacheFile(gameTitle, consoleType)
// Try the title as-is first, then cleaned up
// Try the title as-is, with expanded regions, then fully cleaned
val namesToTry = buildList {
add(gameTitle)
val expanded = expandRegions(gameTitle)
expanded.filter { it != gameTitle }.forEach { add(it) }
val cleaned = cleanTitle(gameTitle)
if (cleaned != gameTitle) add(cleaned)
if (cleaned != gameTitle && cleaned !in expanded) add(cleaned)
}
for (name in namesToTry) {
@@ -87,6 +89,33 @@ class CoverArtManager(private val context: Context) {
private const val TAG = "CoverArtManager"
private const val BASE_URL = "https://raw.githubusercontent.com/libretro-thumbnails"
// GoodNES (U) can mean (USA) or (USA, Europe) — try both
private val regionExpansions = mapOf(
"(U)" to listOf("(USA, Europe)", "(USA)"),
"(E)" to listOf("(Europe)", "(Europe, Australia)"),
"(J)" to listOf("(Japan)", "(Japan, USA)"),
"(UE)" to listOf("(USA, Europe)"),
"(JU)" to listOf("(Japan, USA)"),
"(W)" to listOf("(World)"),
"(F)" to listOf("(France)"),
"(G)" to listOf("(Germany)"),
"(S)" to listOf("(Spain)"),
"(I)" to listOf("(Italy)"),
"(Unl)" to listOf("(USA) (Unl)", "(Unl)"),
)
fun expandRegions(title: String): List<String> {
val stripped = title
.replace(Regex("\\s*\\[[^]]*]"), "") // strip flags like [!], [b], [p1]
.trim()
for ((short, expansions) in regionExpansions) {
if (short in stripped) {
return expansions.map { stripped.replace(short, it) }
}
}
return listOf(stripped)
}
fun cleanTitle(title: String): String {
return title
// Remove region tags: (USA), (Europe), (Japan), etc.

View File

@@ -74,10 +74,21 @@ class PreferencesManager(context: Context) {
editor.apply()
}
fun getFavorites(): Set<String> {
return prefs.getStringSet(KEY_FAVORITES, emptySet()) ?: emptySet()
}
fun setFavorite(romPath: String, favorite: Boolean) {
val favorites = getFavorites().toMutableSet()
if (favorite) favorites.add(romPath) else favorites.remove(romPath)
prefs.edit().putStringSet(KEY_FAVORITES, favorites).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_"
private const val KEY_FAVORITES = "favorite_roms"
}
}

View File

@@ -7,6 +7,7 @@ data class Game(
val title: String,
val romPath: String,
val consoleType: ConsoleType,
val rawTitle: String = title,
val coverArtPath: String? = null,
val lastPlayed: Long? = null,
val favorite: Boolean = false,

View File

@@ -5,21 +5,27 @@ import android.net.Uri
import android.os.Environment
import android.provider.DocumentsContract
import com.lazy.emulate.data.CoverArtManager
import com.lazy.emulate.data.PreferencesManager
import com.lazy.emulate.data.model.Game
import com.lazy.emulate.emulation.ConsoleType
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import java.io.File
import java.util.UUID
class GameRepository(private val context: Context) {
private val coverArtManager = CoverArtManager(context)
private val preferencesManager = PreferencesManager(context)
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val _games = MutableStateFlow<List<Game>>(emptyList())
@@ -44,6 +50,7 @@ class GameRepository(private val context: Context) {
val scanned = mutableListOf<Game>()
val existingPaths = _games.value.map { it.romPath }.toSet()
val favorites = preferencesManager.getFavorites()
gamesRoot.listFiles()?.forEach { folder ->
if (!folder.isDirectory) return@forEach
@@ -67,15 +74,18 @@ class GameRepository(private val context: Context) {
}
}
if (ext in consoleType.fileExtensions || ext == "zip" || ext == "7z") {
val title = file.nameWithoutExtension
val cachedArt = coverArtManager.getCachedPath(title, consoleType)
val rawTitle = file.nameWithoutExtension
val displayTitle = CoverArtManager.cleanTitle(rawTitle)
val cachedArt = coverArtManager.getCachedPath(rawTitle, consoleType)
scanned.add(
Game(
id = UUID.randomUUID().toString(),
title = title,
title = displayTitle,
rawTitle = rawTitle,
romPath = file.absolutePath,
consoleType = consoleType,
coverArtPath = cachedArt
coverArtPath = cachedArt,
favorite = file.absolutePath in favorites
)
)
}
@@ -83,7 +93,7 @@ class GameRepository(private val context: Context) {
}
if (scanned.isNotEmpty()) {
_games.value = _games.value + scanned
_games.value = (_games.value + scanned).sortedBy { it.title.lowercase() }
fetchMissingCoverArt()
}
}
@@ -92,9 +102,12 @@ class GameRepository(private val context: Context) {
val gamesNeedingArt = _games.value.filter { it.coverArtPath == null }
if (gamesNeedingArt.isEmpty()) return
val semaphore = Semaphore(8)
scope.launch {
for (game in gamesNeedingArt) {
val path = coverArtManager.fetchCoverArt(game.title, game.consoleType)
gamesNeedingArt.map { game ->
async {
semaphore.withPermit {
val path = coverArtManager.fetchCoverArt(game.rawTitle, game.consoleType)
if (path != null) {
_games.value = _games.value.map {
if (it.id == game.id) it.copy(coverArtPath = path) else it
@@ -102,6 +115,8 @@ class GameRepository(private val context: Context) {
}
}
}
}.awaitAll()
}
}
fun addGame(title: String, romPath: String, consoleType: ConsoleType): Game {
@@ -127,7 +142,11 @@ class GameRepository(private val context: Context) {
fun toggleFavorite(gameId: String) {
_games.value = _games.value.map { game ->
if (game.id == gameId) game.copy(favorite = !game.favorite) else game
if (game.id == gameId) {
val newFav = !game.favorite
preferencesManager.setFavorite(game.romPath, newFav)
game.copy(favorite = newFav)
} else game
}
}

View File

@@ -20,6 +20,7 @@ enum class ConsoleType(
displayName = "Nintendo Entertainment System",
manufacturer = "Nintendo",
fileExtensions = listOf("nes", "unf", "unif"),
isImplemented = true,
fallbackAspectRatio = 256f / 240f,
libretroThumbnailRepo = "Nintendo_-_Nintendo_Entertainment_System"
),

View File

@@ -1,6 +1,7 @@
package com.lazy.emulate.emulation
import android.content.Context
import com.lazy.emulate.emulation.cores.NesCore
import com.lazy.emulate.emulation.cores.PlaceholderCore
import com.lazy.emulate.emulation.cores.Ps1Core
import com.lazy.emulate.input.ButtonMappingManager
@@ -13,7 +14,7 @@ object EmulationEngine {
fun getCoreForConsole(context: Context, consoleType: ConsoleType): EmulatorCore {
return when (consoleType) {
ConsoleType.PS1 -> Ps1Core(context, buttonMappingManager)
ConsoleType.NES -> PlaceholderCore(ConsoleType.NES)
ConsoleType.NES -> NesCore(context, buttonMappingManager)
ConsoleType.SNES -> PlaceholderCore(ConsoleType.SNES)
ConsoleType.N64 -> PlaceholderCore(ConsoleType.N64)
ConsoleType.GENESIS -> PlaceholderCore(ConsoleType.GENESIS)

View File

@@ -42,6 +42,8 @@ interface EmulatorCore {
fun loadState(slot: Int): Boolean
fun deleteState(slot: Int): Boolean = false
fun getSaveSlotInfo(slot: Int): SaveSlotInfo
fun getScreenshotPath(slot: Int): String? = null

View File

@@ -26,4 +26,7 @@ object NativeLibretro {
external fun nativeSetDisplayMode(mode: Int)
external fun nativeSetSystemDir(dir: String)
external fun nativeSetSaveDir(dir: String)
external fun nativeSetControllerPortDevice(port: Int, device: Int)
external fun nativeSetPendingControllerDevice(device: Int)
external fun nativeSetCoreOption(key: String, value: String)
}

View File

@@ -0,0 +1,233 @@
package com.lazy.emulate.emulation.cores
import android.content.Context
import android.util.Log
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.NativeLibretro
import com.lazy.emulate.emulation.SaveSlotInfo
import com.lazy.emulate.input.ButtonMappingManager
import com.lazy.emulate.input.GamepadButton
import java.io.File
class NesCore(
private val context: Context,
private val buttonMappingManager: ButtonMappingManager? = null
) : EmulatorCore {
override val consoleType = ConsoleType.NES
override var isRunning: Boolean = false
private set
private var buttonState: Int = 0
private val coresDir = File(context.filesDir, "cores")
private val systemDir = File(context.filesDir, "system")
private val baseSavesDir = File(context.filesDir, "saves")
private lateinit var savesDir: File
override fun loadRom(path: String): Boolean {
coresDir.mkdirs()
systemDir.mkdirs()
val romName = File(path).nameWithoutExtension
savesDir = File(baseSavesDir, romName)
savesDir.mkdirs()
NativeLibretro.nativeSetSystemDir(systemDir.absolutePath)
NativeLibretro.nativeSetSaveDir(savesDir.absolutePath)
val corePath = findCorePath()
if (corePath == null) {
Log.e(TAG, "NES core .so not found. Place fceumm_libretro_android.so in ${coresDir.absolutePath}")
return false
}
if (!NativeLibretro.nativeLoadCore(corePath)) {
Log.e(TAG, "Failed to load core: $corePath")
return false
}
// NES uses standard joypad (already set by native loadCore)
if (!NativeLibretro.nativeLoadGame(path)) {
Log.e(TAG, "Failed to load game: $path")
return false
}
Log.i(TAG, "Game loaded successfully: $path")
return true
}
override fun start() {
isRunning = true
NativeLibretro.nativeStart()
}
override fun pause() {
isRunning = false
NativeLibretro.nativePause()
}
override fun resume() {
isRunning = true
NativeLibretro.nativeResume()
}
override fun stop() {
isRunning = false
NativeLibretro.nativeStop()
}
override fun reset() {
NativeLibretro.nativeReset()
}
override fun setSurface(surface: Surface) {
NativeLibretro.nativeSetSurface(surface)
}
override fun onButtonPressed(button: GamepadButton) {
val bit = mapButtonToRetroId(button)
if (bit >= 0) {
buttonState = buttonState or (1 shl bit)
NativeLibretro.nativeSetButtonState(buttonState)
}
}
override fun onButtonReleased(button: GamepadButton) {
val bit = mapButtonToRetroId(button)
if (bit >= 0) {
buttonState = buttonState and (1 shl bit).inv()
NativeLibretro.nativeSetButtonState(buttonState)
}
}
override fun onAnalogStick(x: Float, y: Float, stickIndex: Int) {
// NES has no analog sticks
}
override fun saveState(slot: Int): Boolean {
val path = File(savesDir, "state_$slot.sav").absolutePath
return NativeLibretro.nativeSaveState(path)
}
override fun loadState(slot: Int): Boolean {
val path = File(savesDir, "state_$slot.sav").absolutePath
return NativeLibretro.nativeLoadState(path)
}
override fun deleteState(slot: Int): Boolean {
val state = File(savesDir, "state_$slot.sav")
val screenshot = File(savesDir, "state_$slot.jpg")
val deleted = state.exists() && state.delete()
if (screenshot.exists()) screenshot.delete()
return deleted
}
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)
}
override fun setFrameSkip(skip: Int) {
NativeLibretro.nativeSetFrameSkip(skip)
}
override fun setDisplayMode(mode: DisplayMode) {
NativeLibretro.nativeSetDisplayMode(mode.nativeValue)
}
private fun findCorePath(): String? {
val names = listOf(
"fceumm_libretro_android.so",
"fceumm_libretro.so",
"nestopia_libretro_android.so",
"nestopia_libretro.so",
"mesen_libretro_android.so",
"mesen_libretro.so",
)
// Check internal cores dir first
for (name in names) {
val file = File(coresDir, name)
if (file.exists()) return file.absolutePath
}
// Extract bundled core from assets
for (name in names) {
try {
context.assets.open("cores/$name").use { input ->
val dest = File(coresDir, name)
dest.outputStream().use { output -> input.copyTo(output) }
dest.setExecutable(true)
Log.i(TAG, "Extracted bundled core: $name")
return dest.absolutePath
}
} catch (_: Exception) {
// Asset doesn't exist, try next
}
}
// Fallback: auto-import from /sdcard/Games/cores/
val sdcardCores = File("/sdcard/Games/cores")
if (sdcardCores.exists()) {
for (name in names) {
val sdFile = File(sdcardCores, name)
if (sdFile.exists()) {
val dest = File(coresDir, name)
try {
sdFile.copyTo(dest, overwrite = true)
dest.setExecutable(true)
Log.i(TAG, "Imported core from sdcard: $name")
return dest.absolutePath
} catch (e: Exception) {
Log.e(TAG, "Failed to import core: ${e.message}")
}
}
}
}
return null
}
private fun mapButtonToRetroId(button: GamepadButton): Int {
if (buttonMappingManager != null) {
return buttonMappingManager.getRetroId(consoleType, button)
}
return defaultMapButtonToRetroId(button)
}
private fun defaultMapButtonToRetroId(button: GamepadButton): Int {
// NES: A, B, Select, Start, D-pad
// Libretro joypad IDs: B=0, Y=1, Select=2, Start=3,
// Up=4, Down=5, Left=6, Right=7, A=8, X=9
return when (button) {
GamepadButton.FACE_BOTTOM -> 0 // B
GamepadButton.FACE_RIGHT -> 8 // A
GamepadButton.DPAD_UP -> 4
GamepadButton.DPAD_DOWN -> 5
GamepadButton.DPAD_LEFT -> 6
GamepadButton.DPAD_RIGHT -> 7
GamepadButton.SELECT -> 2
GamepadButton.START -> 3
else -> -1
}
}
companion object {
private const val TAG = "NesCore"
}
}

View File

@@ -54,6 +54,17 @@ class Ps1Core(
Log.w(TAG, "BIOS not found at ${biosFile.absolutePath} — core will use HLE BIOS")
}
// Set core options BEFORE loading core - pcsx_rearmed reads them during retro_init
NativeLibretro.nativeSetCoreOption("pcsx_rearmed_drc", "enabled")
NativeLibretro.nativeSetCoreOption("pcsx_rearmed_pad1type", "analog")
NativeLibretro.nativeSetCoreOption("pcsx_rearmed_frameskip_type", "disabled")
NativeLibretro.nativeSetCoreOption("pcsx_rearmed_region", "auto")
NativeLibretro.nativeSetCoreOption("pcsx_rearmed_memcard2", "disabled")
// Pre-set the controller device so it gets applied INSIDE loadCore at the right time
// (between retro_init and retro_get_system_info, which is what pcsx_rearmed expects)
NativeLibretro.nativeSetPendingControllerDevice(5) // RETRO_DEVICE_ANALOG
if (!NativeLibretro.nativeLoadCore(corePath)) {
Log.e(TAG, "Failed to load core: $corePath")
return false
@@ -129,6 +140,14 @@ class Ps1Core(
return NativeLibretro.nativeLoadState(path)
}
override fun deleteState(slot: Int): Boolean {
val state = File(savesDir, "state_$slot.sav")
val screenshot = File(savesDir, "state_$slot.jpg")
val deleted = state.exists() && state.delete()
if (screenshot.exists()) screenshot.delete()
return deleted
}
override fun getSaveSlotInfo(slot: Int): SaveSlotInfo {
val file = File(savesDir, "state_$slot.sav")
val screenshot = File(savesDir, "state_$slot.jpg")
@@ -172,8 +191,21 @@ class Ps1Core(
val file = File(coresDir, name)
if (file.exists()) return file.absolutePath
}
// Auto-import from /sdcard/Games/cores/ into internal storage
// (dlopen requires the .so to be in app-writable storage, not sdcard)
// Extract bundled core from assets
for (name in names) {
try {
context.assets.open("cores/$name").use { input ->
val dest = File(coresDir, name)
dest.outputStream().use { output -> input.copyTo(output) }
dest.setExecutable(true)
Log.i(TAG, "Extracted bundled core: $name")
return dest.absolutePath
}
} catch (_: Exception) {
// Asset doesn't exist, try next
}
}
// Fallback: auto-import from /sdcard/Games/cores/
val sdcardCores = File("/sdcard/Games/cores")
if (sdcardCores.exists()) {
for (name in names) {

View File

@@ -23,6 +23,16 @@ class ButtonMappingManager(private val preferencesManager: PreferencesManager) {
GamepadButton.R3 to 15,
GamepadButton.SELECT to 2,
GamepadButton.START to 3
),
ConsoleType.NES to mapOf(
GamepadButton.FACE_BOTTOM to 0, // B
GamepadButton.FACE_RIGHT to 8, // A
GamepadButton.DPAD_UP to 4,
GamepadButton.DPAD_DOWN to 5,
GamepadButton.DPAD_LEFT to 6,
GamepadButton.DPAD_RIGHT to 7,
GamepadButton.SELECT to 2,
GamepadButton.START to 3
)
)

View File

@@ -27,6 +27,7 @@ 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.Delete
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
@@ -169,13 +170,13 @@ fun GameScreen(
holder.addCallback(object : SurfaceHolder.Callback {
override fun surfaceCreated(holder: SurfaceHolder) {
core?.setSurface(holder.surface)
core?.start()
// Load state BEFORE starting emulation so the game's
// first frame already has the restored state
if (pendingLoadSlot >= 0) {
core?.pause()
core?.loadState(pendingLoadSlot)
core?.resume()
pendingLoadSlot = -1
}
core?.start()
}
override fun surfaceChanged(
@@ -309,6 +310,11 @@ fun GameScreen(
if (!isPaused) c.resume()
showGameMenu = false
},
onDelete = { slot ->
val c = core ?: return@GameMenu
c.deleteState(slot)
saveSlots = c.getAllSaveSlots()
},
onResume = {
if (!isPaused) core?.resume()
showGameMenu = false
@@ -436,6 +442,7 @@ private fun GameMenu(
slots: List<SaveSlotInfo>,
onSave: (slot: Int) -> Unit,
onLoad: (slot: Int) -> Unit,
onDelete: (slot: Int) -> Unit,
onResume: () -> Unit
) {
val dateFormat = remember { SimpleDateFormat("MMM d, yyyy h:mm a", Locale.getDefault()) }
@@ -534,6 +541,19 @@ private fun GameMenu(
) {
Text("Load", style = MaterialTheme.typography.labelMedium)
}
// Delete button
IconButton(
onClick = { onDelete(slot.slot) },
enabled = slot.exists,
modifier = Modifier.size(36.dp)
) {
Icon(
Icons.Default.Delete,
contentDescription = "Delete save",
tint = if (slot.exists) Color.White.copy(alpha = 0.7f) else Color.White.copy(alpha = 0.2f)
)
}
}
}

View File

@@ -14,9 +14,13 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Gamepad
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.SportsEsports
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -25,6 +29,8 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.ScrollableTabRow
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
@@ -34,6 +40,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -43,8 +50,14 @@ import com.lazy.emulate.data.repository.GameRepository
import com.lazy.emulate.emulation.ConsoleType
import com.lazy.emulate.ui.adaptive.gridColumns
import com.lazy.emulate.ui.adaptive.toLayoutType
import com.lazy.emulate.ui.components.ConsoleFilter
import com.lazy.emulate.ui.components.GameCard
import kotlinx.coroutines.launch
private sealed class HomeTab(val title: String) {
data object All : HomeTab("All")
data object Favorites : HomeTab("Favorites")
data class Console(val consoleType: ConsoleType) : HomeTab(consoleType.displayName)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -56,20 +69,28 @@ fun HomeScreen(
onControllerLayoutClick: (ConsoleType) -> Unit
) {
val games by gameRepository.games.collectAsState()
var selectedConsole by remember { mutableStateOf<ConsoleType?>(null) }
var searchQuery by remember { mutableStateOf("") }
var searchActive by remember { mutableStateOf(false) }
val layoutType = windowSizeClass.toLayoutType()
val scope = rememberCoroutineScope()
val filteredGames = if (selectedConsole != null) {
games.filter { it.consoleType == selectedConsole }
} else {
games
val tabs = remember {
buildList {
add(HomeTab.All)
add(HomeTab.Favorites)
ConsoleType.entries.filter { it.isImplemented }.forEach {
add(HomeTab.Console(it))
}
}
}
val pagerState = rememberPagerState(pageCount = { tabs.size })
val currentTab = tabs[pagerState.currentPage]
val filePickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument()
) { uri: Uri? ->
uri?.let {
// Default to PS1 for now, could show a picker dialog
gameRepository.addGameFromUri(it, ConsoleType.PS1)
}
}
@@ -77,12 +98,59 @@ fun HomeScreen(
Scaffold(
topBar = {
TopAppBar(
title = { Text("Emulate") },
title = {
if (searchActive) {
androidx.compose.foundation.text.BasicTextField(
value = searchQuery,
onValueChange = { searchQuery = it },
singleLine = true,
textStyle = MaterialTheme.typography.bodyLarge.copy(
color = MaterialTheme.colorScheme.onSurface
),
decorationBox = { innerTextField ->
Box {
if (searchQuery.isEmpty()) {
Text(
"Search games...",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
innerTextField()
}
},
cursorBrush = androidx.compose.ui.graphics.SolidColor(
MaterialTheme.colorScheme.primary
)
)
} else {
Text("Emulate")
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
),
navigationIcon = {
if (searchActive) {
IconButton(onClick = {
searchActive = false
searchQuery = ""
}) {
Icon(Icons.Default.Close, contentDescription = "Close search")
}
}
},
actions = {
IconButton(onClick = { onControllerLayoutClick(selectedConsole ?: ConsoleType.PS1) }) {
if (!searchActive) {
IconButton(onClick = { searchActive = true }) {
Icon(Icons.Default.Search, contentDescription = "Search")
}
}
val controllerConsole = when (currentTab) {
is HomeTab.Console -> currentTab.consoleType
else -> ConsoleType.PS1
}
IconButton(onClick = { onControllerLayoutClick(controllerConsole) }) {
Icon(Icons.Default.Gamepad, contentDescription = "Controller Layout")
}
IconButton(onClick = onSettingsClick) {
@@ -93,9 +161,7 @@ fun HomeScreen(
},
floatingActionButton = {
FloatingActionButton(
onClick = {
filePickerLauncher.launch(arrayOf("*/*"))
}
onClick = { filePickerLauncher.launch(arrayOf("*/*")) }
) {
Icon(Icons.Default.Add, contentDescription = "Add Game")
}
@@ -106,15 +172,40 @@ fun HomeScreen(
.fillMaxSize()
.padding(padding)
) {
ConsoleFilter(
selectedConsole = selectedConsole,
onConsoleSelected = { selectedConsole = it }
// Tabs
ScrollableTabRow(
selectedTabIndex = pagerState.currentPage,
edgePadding = 16.dp
) {
tabs.forEachIndexed { index, tab ->
Tab(
selected = pagerState.currentPage == index,
onClick = { scope.launch { pagerState.animateScrollToPage(index) } },
text = { Text(tab.title) }
)
}
}
Spacer(modifier = Modifier.height(8.dp))
// Pager content
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize()
) { page ->
val tab = tabs[page]
val tabGames = when (tab) {
is HomeTab.All -> games
is HomeTab.Favorites -> games.filter { it.favorite }
is HomeTab.Console -> games.filter { it.consoleType == tab.consoleType }
}.let { list ->
if (searchQuery.isBlank()) list
else list.filter { it.title.contains(searchQuery, ignoreCase = true) }
}
if (filteredGames.isEmpty()) {
EmptyLibrary()
if (tabGames.isEmpty()) {
when (tab) {
is HomeTab.Favorites -> EmptyFavorites()
else -> EmptyLibrary()
}
} else {
LazyVerticalGrid(
columns = GridCells.Fixed(layoutType.gridColumns()),
@@ -122,7 +213,7 @@ fun HomeScreen(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(filteredGames, key = { it.id }) { game ->
items(tabGames, key = { it.id }) { game ->
GameCard(
game = game,
onClick = { onGameSelected(game) },
@@ -134,6 +225,7 @@ fun HomeScreen(
}
}
}
}
@Composable
private fun EmptyLibrary() {
@@ -149,7 +241,7 @@ private fun EmptyLibrary() {
modifier = Modifier.padding(16.dp)
)
Text(
text = "No games in library",
text = "No games found",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -162,3 +254,31 @@ private fun EmptyLibrary() {
}
}
}
@Composable
private fun EmptyFavorites() {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
imageVector = Icons.Default.SportsEsports,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f),
modifier = Modifier.padding(16.dp)
)
Text(
text = "No favorites yet",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Tap the heart on a game to add it here",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
)
}
}
}