Merge feature/nes: NES emulation, search, favorites, save state delete
This commit is contained in:
BIN
app/src/main/assets/cores/fceumm_libretro_android.so
Executable file
BIN
app/src/main/assets/cores/fceumm_libretro_android.so
Executable file
Binary file not shown.
BIN
app/src/main/assets/cores/pcsx_rearmed_libretro_android.so
Executable file
BIN
app/src/main/assets/cores/pcsx_rearmed_libretro_android.so
Executable file
Binary file not shown.
@@ -121,4 +121,23 @@ Java_com_lazy_emulate_emulation_NativeLibretro_nativeSetSaveDir(JNIEnv* env, job
|
|||||||
env->ReleaseStringUTFChars(dir, d);
|
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"
|
} // extern "C"
|
||||||
|
|||||||
@@ -74,21 +74,20 @@ bool LibretroFrontend::loadCore(const char* soPath) {
|
|||||||
core_set_input_poll_(inputPollCallback);
|
core_set_input_poll_(inputPollCallback);
|
||||||
core_set_input_state_(inputStateCallback);
|
core_set_input_state_(inputStateCallback);
|
||||||
|
|
||||||
// Set controller to analog pad (DualShock) for PS1
|
// Set controller device at the exact spot pcsx_rearmed expects (before get_system_info)
|
||||||
core_set_controller_port_device_(0, RETRO_DEVICE_ANALOG);
|
// 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{};
|
retro_system_info sys_info{};
|
||||||
core_get_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("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);
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,12 +99,28 @@ bool LibretroFrontend::loadGame(const char* romPath) {
|
|||||||
|
|
||||||
LOGI("Loading game: %s", romPath);
|
LOGI("Loading game: %s", romPath);
|
||||||
|
|
||||||
// pcsx_rearmed uses need_fullpath=true, so we just pass the path
|
|
||||||
retro_game_info game_info{};
|
retro_game_info game_info{};
|
||||||
game_info.path = romPath;
|
game_info.path = romPath;
|
||||||
|
game_info.meta = nullptr;
|
||||||
|
|
||||||
|
if (need_fullpath_) {
|
||||||
game_info.data = nullptr;
|
game_info.data = nullptr;
|
||||||
game_info.size = 0;
|
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)) {
|
if (!core_load_game_(&game_info)) {
|
||||||
LOGE("retro_load_game failed");
|
LOGE("retro_load_game failed");
|
||||||
@@ -187,6 +202,8 @@ void LibretroFrontend::stop() {
|
|||||||
game_loaded_.store(false);
|
game_loaded_.store(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
rom_data_.clear();
|
||||||
|
rom_data_.shrink_to_fit();
|
||||||
audio_engine_.stop();
|
audio_engine_.stop();
|
||||||
LOGI("Emulation stopped");
|
LOGI("Emulation stopped");
|
||||||
}
|
}
|
||||||
@@ -207,6 +224,9 @@ void LibretroFrontend::unloadCore() {
|
|||||||
core_deinit_();
|
core_deinit_();
|
||||||
dlclose(core_handle_);
|
dlclose(core_handle_);
|
||||||
core_handle_ = nullptr;
|
core_handle_ = nullptr;
|
||||||
|
// Clear options so they don't leak into the next core
|
||||||
|
core_options_.clear();
|
||||||
|
pending_controller_device_ = -1;
|
||||||
LOGI("Core unloaded");
|
LOGI("Core unloaded");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -641,12 +661,6 @@ void LibretroFrontend::setDisplayMode(int mode) {
|
|||||||
|
|
||||||
void LibretroFrontend::setFrameSkip(int skip) {
|
void LibretroFrontend::setFrameSkip(int skip) {
|
||||||
frame_skip_ = 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);
|
options_updated_.store(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -657,3 +671,19 @@ void LibretroFrontend::setSystemDir(const char* dir) {
|
|||||||
void LibretroFrontend::setSaveDir(const char* dir) {
|
void LibretroFrontend::setSaveDir(const char* dir) {
|
||||||
save_dir_ = 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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
#include <string>
|
#include <string>
|
||||||
#include <thread>
|
#include <thread>
|
||||||
#include <unordered_map>
|
#include <unordered_map>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
enum class DisplayMode : int {
|
enum class DisplayMode : int {
|
||||||
STRETCH = 0,
|
STRETCH = 0,
|
||||||
@@ -43,6 +44,9 @@ public:
|
|||||||
|
|
||||||
void setSystemDir(const char* dir);
|
void setSystemDir(const char* dir);
|
||||||
void setSaveDir(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(); }
|
bool isRunning() const { return running_.load(); }
|
||||||
|
|
||||||
@@ -114,6 +118,14 @@ private:
|
|||||||
std::string system_dir_;
|
std::string system_dir_;
|
||||||
std::string save_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
|
// Frame timing
|
||||||
double target_fps_ = 60.0;
|
double target_fps_ = 60.0;
|
||||||
int frame_skip_ = 0;
|
int frame_skip_ = 0;
|
||||||
|
|||||||
@@ -31,11 +31,13 @@ class CoverArtManager(private val context: Context) {
|
|||||||
|
|
||||||
val dest = cacheFile(gameTitle, consoleType)
|
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 {
|
val namesToTry = buildList {
|
||||||
add(gameTitle)
|
add(gameTitle)
|
||||||
|
val expanded = expandRegions(gameTitle)
|
||||||
|
expanded.filter { it != gameTitle }.forEach { add(it) }
|
||||||
val cleaned = cleanTitle(gameTitle)
|
val cleaned = cleanTitle(gameTitle)
|
||||||
if (cleaned != gameTitle) add(cleaned)
|
if (cleaned != gameTitle && cleaned !in expanded) add(cleaned)
|
||||||
}
|
}
|
||||||
|
|
||||||
for (name in namesToTry) {
|
for (name in namesToTry) {
|
||||||
@@ -87,6 +89,33 @@ class CoverArtManager(private val context: Context) {
|
|||||||
private const val TAG = "CoverArtManager"
|
private const val TAG = "CoverArtManager"
|
||||||
private const val BASE_URL = "https://raw.githubusercontent.com/libretro-thumbnails"
|
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 {
|
fun cleanTitle(title: String): String {
|
||||||
return title
|
return title
|
||||||
// Remove region tags: (USA), (Europe), (Japan), etc.
|
// Remove region tags: (USA), (Europe), (Japan), etc.
|
||||||
|
|||||||
@@ -74,10 +74,21 @@ class PreferencesManager(context: Context) {
|
|||||||
editor.apply()
|
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 {
|
companion object {
|
||||||
private const val KEY_DISPLAY_MODE = "display_mode"
|
private const val KEY_DISPLAY_MODE = "display_mode"
|
||||||
private const val KEY_OVERLAY_VISIBILITY = "overlay_visibility"
|
private const val KEY_OVERLAY_VISIBILITY = "overlay_visibility"
|
||||||
private const val KEY_BUTTON_MAP_PREFIX = "button_map_"
|
private const val KEY_BUTTON_MAP_PREFIX = "button_map_"
|
||||||
private const val KEY_CONTROLLER_REMAP_PREFIX = "controller_remap_"
|
private const val KEY_CONTROLLER_REMAP_PREFIX = "controller_remap_"
|
||||||
|
private const val KEY_FAVORITES = "favorite_roms"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ data class Game(
|
|||||||
val title: String,
|
val title: String,
|
||||||
val romPath: String,
|
val romPath: String,
|
||||||
val consoleType: ConsoleType,
|
val consoleType: ConsoleType,
|
||||||
|
val rawTitle: String = title,
|
||||||
val coverArtPath: String? = null,
|
val coverArtPath: String? = null,
|
||||||
val lastPlayed: Long? = null,
|
val lastPlayed: Long? = null,
|
||||||
val favorite: Boolean = false,
|
val favorite: Boolean = false,
|
||||||
|
|||||||
@@ -5,21 +5,27 @@ import android.net.Uri
|
|||||||
import android.os.Environment
|
import android.os.Environment
|
||||||
import android.provider.DocumentsContract
|
import android.provider.DocumentsContract
|
||||||
import com.lazy.emulate.data.CoverArtManager
|
import com.lazy.emulate.data.CoverArtManager
|
||||||
|
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.ConsoleType
|
import com.lazy.emulate.emulation.ConsoleType
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.awaitAll
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
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
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Semaphore
|
||||||
|
import kotlinx.coroutines.sync.withPermit
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
class GameRepository(private val context: Context) {
|
class GameRepository(private val context: Context) {
|
||||||
|
|
||||||
private val coverArtManager = CoverArtManager(context)
|
private val coverArtManager = CoverArtManager(context)
|
||||||
|
private val preferencesManager = PreferencesManager(context)
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
|
||||||
private val _games = MutableStateFlow<List<Game>>(emptyList())
|
private val _games = MutableStateFlow<List<Game>>(emptyList())
|
||||||
@@ -44,6 +50,7 @@ class GameRepository(private val context: Context) {
|
|||||||
|
|
||||||
val scanned = mutableListOf<Game>()
|
val scanned = mutableListOf<Game>()
|
||||||
val existingPaths = _games.value.map { it.romPath }.toSet()
|
val existingPaths = _games.value.map { it.romPath }.toSet()
|
||||||
|
val favorites = preferencesManager.getFavorites()
|
||||||
|
|
||||||
gamesRoot.listFiles()?.forEach { folder ->
|
gamesRoot.listFiles()?.forEach { folder ->
|
||||||
if (!folder.isDirectory) return@forEach
|
if (!folder.isDirectory) return@forEach
|
||||||
@@ -67,15 +74,18 @@ class GameRepository(private val context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (ext in consoleType.fileExtensions || ext == "zip" || ext == "7z") {
|
if (ext in consoleType.fileExtensions || ext == "zip" || ext == "7z") {
|
||||||
val title = file.nameWithoutExtension
|
val rawTitle = file.nameWithoutExtension
|
||||||
val cachedArt = coverArtManager.getCachedPath(title, consoleType)
|
val displayTitle = CoverArtManager.cleanTitle(rawTitle)
|
||||||
|
val cachedArt = coverArtManager.getCachedPath(rawTitle, consoleType)
|
||||||
scanned.add(
|
scanned.add(
|
||||||
Game(
|
Game(
|
||||||
id = UUID.randomUUID().toString(),
|
id = UUID.randomUUID().toString(),
|
||||||
title = title,
|
title = displayTitle,
|
||||||
|
rawTitle = rawTitle,
|
||||||
romPath = file.absolutePath,
|
romPath = file.absolutePath,
|
||||||
consoleType = consoleType,
|
consoleType = consoleType,
|
||||||
coverArtPath = cachedArt
|
coverArtPath = cachedArt,
|
||||||
|
favorite = file.absolutePath in favorites
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -83,7 +93,7 @@ class GameRepository(private val context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (scanned.isNotEmpty()) {
|
if (scanned.isNotEmpty()) {
|
||||||
_games.value = _games.value + scanned
|
_games.value = (_games.value + scanned).sortedBy { it.title.lowercase() }
|
||||||
fetchMissingCoverArt()
|
fetchMissingCoverArt()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -92,9 +102,12 @@ class GameRepository(private val context: Context) {
|
|||||||
val gamesNeedingArt = _games.value.filter { it.coverArtPath == null }
|
val gamesNeedingArt = _games.value.filter { it.coverArtPath == null }
|
||||||
if (gamesNeedingArt.isEmpty()) return
|
if (gamesNeedingArt.isEmpty()) return
|
||||||
|
|
||||||
|
val semaphore = Semaphore(8)
|
||||||
scope.launch {
|
scope.launch {
|
||||||
for (game in gamesNeedingArt) {
|
gamesNeedingArt.map { game ->
|
||||||
val path = coverArtManager.fetchCoverArt(game.title, game.consoleType)
|
async {
|
||||||
|
semaphore.withPermit {
|
||||||
|
val path = coverArtManager.fetchCoverArt(game.rawTitle, game.consoleType)
|
||||||
if (path != null) {
|
if (path != null) {
|
||||||
_games.value = _games.value.map {
|
_games.value = _games.value.map {
|
||||||
if (it.id == game.id) it.copy(coverArtPath = path) else it
|
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 {
|
fun addGame(title: String, romPath: String, consoleType: ConsoleType): Game {
|
||||||
@@ -127,7 +142,11 @@ class GameRepository(private val context: Context) {
|
|||||||
|
|
||||||
fun toggleFavorite(gameId: String) {
|
fun toggleFavorite(gameId: String) {
|
||||||
_games.value = _games.value.map { game ->
|
_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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ enum class ConsoleType(
|
|||||||
displayName = "Nintendo Entertainment System",
|
displayName = "Nintendo Entertainment System",
|
||||||
manufacturer = "Nintendo",
|
manufacturer = "Nintendo",
|
||||||
fileExtensions = listOf("nes", "unf", "unif"),
|
fileExtensions = listOf("nes", "unf", "unif"),
|
||||||
|
isImplemented = true,
|
||||||
fallbackAspectRatio = 256f / 240f,
|
fallbackAspectRatio = 256f / 240f,
|
||||||
libretroThumbnailRepo = "Nintendo_-_Nintendo_Entertainment_System"
|
libretroThumbnailRepo = "Nintendo_-_Nintendo_Entertainment_System"
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.lazy.emulate.emulation
|
package com.lazy.emulate.emulation
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import com.lazy.emulate.emulation.cores.NesCore
|
||||||
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
|
import com.lazy.emulate.input.ButtonMappingManager
|
||||||
@@ -13,7 +14,7 @@ object EmulationEngine {
|
|||||||
fun getCoreForConsole(context: Context, consoleType: ConsoleType): EmulatorCore {
|
fun getCoreForConsole(context: Context, consoleType: ConsoleType): EmulatorCore {
|
||||||
return when (consoleType) {
|
return when (consoleType) {
|
||||||
ConsoleType.PS1 -> Ps1Core(context, buttonMappingManager)
|
ConsoleType.PS1 -> Ps1Core(context, buttonMappingManager)
|
||||||
ConsoleType.NES -> PlaceholderCore(ConsoleType.NES)
|
ConsoleType.NES -> NesCore(context, buttonMappingManager)
|
||||||
ConsoleType.SNES -> PlaceholderCore(ConsoleType.SNES)
|
ConsoleType.SNES -> PlaceholderCore(ConsoleType.SNES)
|
||||||
ConsoleType.N64 -> PlaceholderCore(ConsoleType.N64)
|
ConsoleType.N64 -> PlaceholderCore(ConsoleType.N64)
|
||||||
ConsoleType.GENESIS -> PlaceholderCore(ConsoleType.GENESIS)
|
ConsoleType.GENESIS -> PlaceholderCore(ConsoleType.GENESIS)
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ interface EmulatorCore {
|
|||||||
|
|
||||||
fun loadState(slot: Int): Boolean
|
fun loadState(slot: Int): Boolean
|
||||||
|
|
||||||
|
fun deleteState(slot: Int): Boolean = false
|
||||||
|
|
||||||
fun getSaveSlotInfo(slot: Int): SaveSlotInfo
|
fun getSaveSlotInfo(slot: Int): SaveSlotInfo
|
||||||
|
|
||||||
fun getScreenshotPath(slot: Int): String? = null
|
fun getScreenshotPath(slot: Int): String? = null
|
||||||
|
|||||||
@@ -26,4 +26,7 @@ object NativeLibretro {
|
|||||||
external fun nativeSetDisplayMode(mode: Int)
|
external fun nativeSetDisplayMode(mode: Int)
|
||||||
external fun nativeSetSystemDir(dir: String)
|
external fun nativeSetSystemDir(dir: String)
|
||||||
external fun nativeSetSaveDir(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)
|
||||||
}
|
}
|
||||||
|
|||||||
233
app/src/main/java/com/lazy/emulate/emulation/cores/NesCore.kt
Normal file
233
app/src/main/java/com/lazy/emulate/emulation/cores/NesCore.kt
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,6 +54,17 @@ class Ps1Core(
|
|||||||
Log.w(TAG, "BIOS not found at ${biosFile.absolutePath} — core will use HLE BIOS")
|
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)) {
|
if (!NativeLibretro.nativeLoadCore(corePath)) {
|
||||||
Log.e(TAG, "Failed to load core: $corePath")
|
Log.e(TAG, "Failed to load core: $corePath")
|
||||||
return false
|
return false
|
||||||
@@ -129,6 +140,14 @@ class Ps1Core(
|
|||||||
return NativeLibretro.nativeLoadState(path)
|
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 {
|
override fun getSaveSlotInfo(slot: Int): SaveSlotInfo {
|
||||||
val file = File(savesDir, "state_$slot.sav")
|
val file = File(savesDir, "state_$slot.sav")
|
||||||
val screenshot = File(savesDir, "state_$slot.jpg")
|
val screenshot = File(savesDir, "state_$slot.jpg")
|
||||||
@@ -172,8 +191,21 @@ class Ps1Core(
|
|||||||
val file = File(coresDir, name)
|
val file = File(coresDir, name)
|
||||||
if (file.exists()) return file.absolutePath
|
if (file.exists()) return file.absolutePath
|
||||||
}
|
}
|
||||||
// Auto-import from /sdcard/Games/cores/ into internal storage
|
// Extract bundled core from assets
|
||||||
// (dlopen requires the .so to be in app-writable storage, not sdcard)
|
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")
|
val sdcardCores = File("/sdcard/Games/cores")
|
||||||
if (sdcardCores.exists()) {
|
if (sdcardCores.exists()) {
|
||||||
for (name in names) {
|
for (name in names) {
|
||||||
|
|||||||
@@ -23,6 +23,16 @@ class ButtonMappingManager(private val preferencesManager: PreferencesManager) {
|
|||||||
GamepadButton.R3 to 15,
|
GamepadButton.R3 to 15,
|
||||||
GamepadButton.SELECT to 2,
|
GamepadButton.SELECT to 2,
|
||||||
GamepadButton.START to 3
|
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
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ 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.AspectRatio
|
||||||
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material.icons.filled.Menu
|
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
|
||||||
@@ -169,13 +170,13 @@ fun GameScreen(
|
|||||||
holder.addCallback(object : SurfaceHolder.Callback {
|
holder.addCallback(object : SurfaceHolder.Callback {
|
||||||
override fun surfaceCreated(holder: SurfaceHolder) {
|
override fun surfaceCreated(holder: SurfaceHolder) {
|
||||||
core?.setSurface(holder.surface)
|
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) {
|
if (pendingLoadSlot >= 0) {
|
||||||
core?.pause()
|
|
||||||
core?.loadState(pendingLoadSlot)
|
core?.loadState(pendingLoadSlot)
|
||||||
core?.resume()
|
|
||||||
pendingLoadSlot = -1
|
pendingLoadSlot = -1
|
||||||
}
|
}
|
||||||
|
core?.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun surfaceChanged(
|
override fun surfaceChanged(
|
||||||
@@ -309,6 +310,11 @@ fun GameScreen(
|
|||||||
if (!isPaused) c.resume()
|
if (!isPaused) c.resume()
|
||||||
showGameMenu = false
|
showGameMenu = false
|
||||||
},
|
},
|
||||||
|
onDelete = { slot ->
|
||||||
|
val c = core ?: return@GameMenu
|
||||||
|
c.deleteState(slot)
|
||||||
|
saveSlots = c.getAllSaveSlots()
|
||||||
|
},
|
||||||
onResume = {
|
onResume = {
|
||||||
if (!isPaused) core?.resume()
|
if (!isPaused) core?.resume()
|
||||||
showGameMenu = false
|
showGameMenu = false
|
||||||
@@ -436,6 +442,7 @@ private fun GameMenu(
|
|||||||
slots: List<SaveSlotInfo>,
|
slots: List<SaveSlotInfo>,
|
||||||
onSave: (slot: Int) -> Unit,
|
onSave: (slot: Int) -> Unit,
|
||||||
onLoad: (slot: Int) -> Unit,
|
onLoad: (slot: Int) -> Unit,
|
||||||
|
onDelete: (slot: Int) -> Unit,
|
||||||
onResume: () -> Unit
|
onResume: () -> Unit
|
||||||
) {
|
) {
|
||||||
val dateFormat = remember { SimpleDateFormat("MMM d, yyyy h:mm a", Locale.getDefault()) }
|
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)
|
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)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,9 +14,13 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.lazy.grid.GridCells
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
import androidx.compose.foundation.lazy.grid.items
|
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.Icons
|
||||||
import androidx.compose.material.icons.filled.Add
|
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.Gamepad
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
import androidx.compose.material.icons.filled.SportsEsports
|
import androidx.compose.material.icons.filled.SportsEsports
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
@@ -25,6 +29,8 @@ 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.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.ScrollableTabRow
|
||||||
|
import androidx.compose.material3.Tab
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
@@ -34,6 +40,7 @@ import androidx.compose.runtime.collectAsState
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
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
|
||||||
@@ -43,8 +50,14 @@ import com.lazy.emulate.data.repository.GameRepository
|
|||||||
import com.lazy.emulate.emulation.ConsoleType
|
import com.lazy.emulate.emulation.ConsoleType
|
||||||
import com.lazy.emulate.ui.adaptive.gridColumns
|
import com.lazy.emulate.ui.adaptive.gridColumns
|
||||||
import com.lazy.emulate.ui.adaptive.toLayoutType
|
import com.lazy.emulate.ui.adaptive.toLayoutType
|
||||||
import com.lazy.emulate.ui.components.ConsoleFilter
|
|
||||||
import com.lazy.emulate.ui.components.GameCard
|
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)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -56,20 +69,28 @@ fun HomeScreen(
|
|||||||
onControllerLayoutClick: (ConsoleType) -> Unit
|
onControllerLayoutClick: (ConsoleType) -> Unit
|
||||||
) {
|
) {
|
||||||
val games by gameRepository.games.collectAsState()
|
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 layoutType = windowSizeClass.toLayoutType()
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
val filteredGames = if (selectedConsole != null) {
|
val tabs = remember {
|
||||||
games.filter { it.consoleType == selectedConsole }
|
buildList {
|
||||||
} else {
|
add(HomeTab.All)
|
||||||
games
|
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(
|
val filePickerLauncher = rememberLauncherForActivityResult(
|
||||||
contract = ActivityResultContracts.OpenDocument()
|
contract = ActivityResultContracts.OpenDocument()
|
||||||
) { uri: Uri? ->
|
) { uri: Uri? ->
|
||||||
uri?.let {
|
uri?.let {
|
||||||
// Default to PS1 for now, could show a picker dialog
|
|
||||||
gameRepository.addGameFromUri(it, ConsoleType.PS1)
|
gameRepository.addGameFromUri(it, ConsoleType.PS1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -77,12 +98,59 @@ fun HomeScreen(
|
|||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
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(
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
containerColor = MaterialTheme.colorScheme.surface
|
containerColor = MaterialTheme.colorScheme.surface
|
||||||
),
|
),
|
||||||
|
navigationIcon = {
|
||||||
|
if (searchActive) {
|
||||||
|
IconButton(onClick = {
|
||||||
|
searchActive = false
|
||||||
|
searchQuery = ""
|
||||||
|
}) {
|
||||||
|
Icon(Icons.Default.Close, contentDescription = "Close search")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
actions = {
|
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")
|
Icon(Icons.Default.Gamepad, contentDescription = "Controller Layout")
|
||||||
}
|
}
|
||||||
IconButton(onClick = onSettingsClick) {
|
IconButton(onClick = onSettingsClick) {
|
||||||
@@ -93,9 +161,7 @@ fun HomeScreen(
|
|||||||
},
|
},
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
FloatingActionButton(
|
FloatingActionButton(
|
||||||
onClick = {
|
onClick = { filePickerLauncher.launch(arrayOf("*/*")) }
|
||||||
filePickerLauncher.launch(arrayOf("*/*"))
|
|
||||||
}
|
|
||||||
) {
|
) {
|
||||||
Icon(Icons.Default.Add, contentDescription = "Add Game")
|
Icon(Icons.Default.Add, contentDescription = "Add Game")
|
||||||
}
|
}
|
||||||
@@ -106,15 +172,40 @@ fun HomeScreen(
|
|||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(padding)
|
.padding(padding)
|
||||||
) {
|
) {
|
||||||
ConsoleFilter(
|
// Tabs
|
||||||
selectedConsole = selectedConsole,
|
ScrollableTabRow(
|
||||||
onConsoleSelected = { selectedConsole = it }
|
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()) {
|
if (tabGames.isEmpty()) {
|
||||||
EmptyLibrary()
|
when (tab) {
|
||||||
|
is HomeTab.Favorites -> EmptyFavorites()
|
||||||
|
else -> EmptyLibrary()
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
LazyVerticalGrid(
|
LazyVerticalGrid(
|
||||||
columns = GridCells.Fixed(layoutType.gridColumns()),
|
columns = GridCells.Fixed(layoutType.gridColumns()),
|
||||||
@@ -122,7 +213,7 @@ fun HomeScreen(
|
|||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
) {
|
) {
|
||||||
items(filteredGames, key = { it.id }) { game ->
|
items(tabGames, key = { it.id }) { game ->
|
||||||
GameCard(
|
GameCard(
|
||||||
game = game,
|
game = game,
|
||||||
onClick = { onGameSelected(game) },
|
onClick = { onGameSelected(game) },
|
||||||
@@ -134,6 +225,7 @@ fun HomeScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun EmptyLibrary() {
|
private fun EmptyLibrary() {
|
||||||
@@ -149,7 +241,7 @@ private fun EmptyLibrary() {
|
|||||||
modifier = Modifier.padding(16.dp)
|
modifier = Modifier.padding(16.dp)
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "No games in library",
|
text = "No games found",
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user