Add search, favorites tab, and persistent favorites

Replace static console filter with swipeable tabs (All / Favorites /
per-console) and a collapsible search icon in the app bar. Persist
favorites via SharedPreferences keyed by ROM path. Display titles are
now cleaned (region tags stripped) for better UX while raw filenames
are preserved for cover art lookups. Sort games alphabetically and
fetch cover art in parallel (8 concurrent) for faster initial load.

Also fix PS1 controller setup so it works with both new games and save
states: pcsx_rearmed needs retro_set_controller_port_device called at
a specific point inside loadCore (between callbacks and get_system_info)
which FCEUmm crashes on. Added a "pending controller device" mechanism
that the Kotlin core can pre-set before nativeLoadCore. Save states are
now loaded BEFORE start() so the game's first frame already has the
restored state, avoiding the "controller removed" detection race.
This commit is contained in:
2026-04-10 19:33:25 -04:00
parent d502304d95
commit 981ac2eed9
10 changed files with 235 additions and 60 deletions

View File

@@ -126,6 +126,11 @@ Java_com_lazy_emulate_emulation_NativeLibretro_nativeSetControllerPortDevice(JNI
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);

View File

@@ -74,15 +74,20 @@ bool LibretroFrontend::loadCore(const char* soPath) {
core_set_input_poll_(inputPollCallback);
core_set_input_state_(inputStateCallback);
// 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);
// Clear any previous core options
core_options_.clear();
return true;
}
@@ -219,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");
}
}
@@ -671,6 +679,10 @@ void LibretroFrontend::setControllerPortDevice(unsigned port, unsigned 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

@@ -45,6 +45,7 @@ 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(); }
@@ -121,6 +122,10 @@ private:
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

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

@@ -27,5 +27,6 @@ object NativeLibretro {
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

@@ -54,26 +54,27 @@ class Ps1Core(
Log.w(TAG, "BIOS not found at ${biosFile.absolutePath} — core will use HLE BIOS")
}
if (!NativeLibretro.nativeLoadCore(corePath)) {
Log.e(TAG, "Failed to load core: $corePath")
return false
}
// Set core options before loading game
// 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
}
if (!NativeLibretro.nativeLoadGame(path)) {
Log.e(TAG, "Failed to load game: $path")
return false
}
// Set controller to DualShock analog after game load
NativeLibretro.nativeSetControllerPortDevice(0, 5) // RETRO_DEVICE_ANALOG
Log.i(TAG, "Game loaded successfully: $path")
return true
}

View File

@@ -169,13 +169,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(

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) },
@@ -133,6 +224,7 @@ fun HomeScreen(
}
}
}
}
}
@Composable
@@ -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)
)
}
}
}