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) + ) + } + } +}