From 981ac2eed926d5e6135841507c2b4e4731577f8f Mon Sep 17 00:00:00 2001 From: Matt Hills Date: Fri, 10 Apr 2026 19:33:25 -0400 Subject: [PATCH] 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. --- app/src/main/cpp/jni_bridge.cpp | 5 + app/src/main/cpp/libretro_frontend.cpp | 18 +- app/src/main/cpp/libretro_frontend.h | 5 + .../lazy/emulate/data/PreferencesManager.kt | 11 ++ .../java/com/lazy/emulate/data/model/Game.kt | 1 + .../emulate/data/repository/GameRepository.kt | 43 ++-- .../lazy/emulate/emulation/NativeLibretro.kt | 1 + .../lazy/emulate/emulation/cores/Ps1Core.kt | 19 +- .../emulate/ui/screens/game/GameScreen.kt | 6 +- .../emulate/ui/screens/home/HomeScreen.kt | 186 ++++++++++++++---- 10 files changed, 235 insertions(+), 60 deletions(-) diff --git a/app/src/main/cpp/jni_bridge.cpp b/app/src/main/cpp/jni_bridge.cpp index 579eb4b..e2b2a02 100644 --- a/app/src/main/cpp/jni_bridge.cpp +++ b/app/src/main/cpp/jni_bridge.cpp @@ -126,6 +126,11 @@ Java_com_lazy_emulate_emulation_NativeLibretro_nativeSetControllerPortDevice(JNI LibretroFrontend::instance().setControllerPortDevice(static_cast(port), static_cast(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); diff --git a/app/src/main/cpp/libretro_frontend.cpp b/app/src/main/cpp/libretro_frontend.cpp index 463aae3..3b56290 100644 --- a/app/src/main/cpp/libretro_frontend.cpp +++ b/app/src/main/cpp/libretro_frontend.cpp @@ -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(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); diff --git a/app/src/main/cpp/libretro_frontend.h b/app/src/main/cpp/libretro_frontend.h index 832202b..a9cd1ca 100644 --- a/app/src/main/cpp/libretro_frontend.h +++ b/app/src/main/cpp/libretro_frontend.h @@ -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 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; diff --git a/app/src/main/java/com/lazy/emulate/data/PreferencesManager.kt b/app/src/main/java/com/lazy/emulate/data/PreferencesManager.kt index 3100924..649b4bb 100644 --- a/app/src/main/java/com/lazy/emulate/data/PreferencesManager.kt +++ b/app/src/main/java/com/lazy/emulate/data/PreferencesManager.kt @@ -74,10 +74,21 @@ class PreferencesManager(context: Context) { editor.apply() } + fun getFavorites(): Set { + 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" } } diff --git a/app/src/main/java/com/lazy/emulate/data/model/Game.kt b/app/src/main/java/com/lazy/emulate/data/model/Game.kt index c37c33f..ff61081 100644 --- a/app/src/main/java/com/lazy/emulate/data/model/Game.kt +++ b/app/src/main/java/com/lazy/emulate/data/model/Game.kt @@ -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, diff --git a/app/src/main/java/com/lazy/emulate/data/repository/GameRepository.kt b/app/src/main/java/com/lazy/emulate/data/repository/GameRepository.kt index fe759af..fa4fd03 100644 --- a/app/src/main/java/com/lazy/emulate/data/repository/GameRepository.kt +++ b/app/src/main/java/com/lazy/emulate/data/repository/GameRepository.kt @@ -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>(emptyList()) @@ -44,6 +50,7 @@ class GameRepository(private val context: Context) { val scanned = mutableListOf() 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,15 +102,20 @@ 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) - if (path != null) { - _games.value = _games.value.map { - if (it.id == game.id) it.copy(coverArtPath = path) else it + 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 + } + } } } - } + }.awaitAll() } } @@ -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 } } diff --git a/app/src/main/java/com/lazy/emulate/emulation/NativeLibretro.kt b/app/src/main/java/com/lazy/emulate/emulation/NativeLibretro.kt index b6c76e5..7970464 100644 --- a/app/src/main/java/com/lazy/emulate/emulation/NativeLibretro.kt +++ b/app/src/main/java/com/lazy/emulate/emulation/NativeLibretro.kt @@ -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) } diff --git a/app/src/main/java/com/lazy/emulate/emulation/cores/Ps1Core.kt b/app/src/main/java/com/lazy/emulate/emulation/cores/Ps1Core.kt index 1fa2055..c8fe62a 100644 --- a/app/src/main/java/com/lazy/emulate/emulation/cores/Ps1Core.kt +++ b/app/src/main/java/com/lazy/emulate/emulation/cores/Ps1Core.kt @@ -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 } diff --git a/app/src/main/java/com/lazy/emulate/ui/screens/game/GameScreen.kt b/app/src/main/java/com/lazy/emulate/ui/screens/game/GameScreen.kt index 269e855..447d12f 100644 --- a/app/src/main/java/com/lazy/emulate/ui/screens/game/GameScreen.kt +++ b/app/src/main/java/com/lazy/emulate/ui/screens/game/GameScreen.kt @@ -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( diff --git a/app/src/main/java/com/lazy/emulate/ui/screens/home/HomeScreen.kt b/app/src/main/java/com/lazy/emulate/ui/screens/home/HomeScreen.kt index 77e6025..4e87254 100644 --- a/app/src/main/java/com/lazy/emulate/ui/screens/home/HomeScreen.kt +++ b/app/src/main/java/com/lazy/emulate/ui/screens/home/HomeScreen.kt @@ -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(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,28 +172,54 @@ 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() - } else { - LazyVerticalGrid( - columns = GridCells.Fixed(layoutType.gridColumns()), - contentPadding = PaddingValues(16.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - items(filteredGames, key = { it.id }) { game -> - GameCard( - game = game, - onClick = { onGameSelected(game) }, - onFavoriteToggle = { gameRepository.toggleFavorite(game.id) } - ) + if (tabGames.isEmpty()) { + when (tab) { + is HomeTab.Favorites -> EmptyFavorites() + else -> EmptyLibrary() + } + } else { + LazyVerticalGrid( + columns = GridCells.Fixed(layoutType.gridColumns()), + contentPadding = PaddingValues(16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + items(tabGames, key = { it.id }) { game -> + GameCard( + game = game, + onClick = { onGameSelected(game) }, + onFavoriteToggle = { gameRepository.toggleFavorite(game.id) } + ) + } } } } @@ -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) + ) + } + } +}