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)); 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 JNIEXPORT void JNICALL
Java_com_lazy_emulate_emulation_NativeLibretro_nativeSetCoreOption(JNIEnv* env, jobject, jstring key, jstring value) { Java_com_lazy_emulate_emulation_NativeLibretro_nativeSetCoreOption(JNIEnv* env, jobject, jstring key, jstring value) {
const char* k = env->GetStringUTFChars(key, nullptr); 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_poll_(inputPollCallback);
core_set_input_state_(inputStateCallback); 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{}; retro_system_info sys_info{};
core_get_system_info_(&sys_info); core_get_system_info_(&sys_info);
need_fullpath_ = sys_info.need_fullpath; 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);
// Clear any previous core options
core_options_.clear();
return true; return true;
} }
@@ -219,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");
} }
} }
@@ -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) { void LibretroFrontend::setCoreOption(const char* key, const char* value) {
core_options_[key] = value; core_options_[key] = value;
options_updated_.store(true); options_updated_.store(true);

View File

@@ -45,6 +45,7 @@ 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 setControllerPortDevice(unsigned port, unsigned device);
void setPendingControllerDevice(int device);
void setCoreOption(const char* key, const char* value); void setCoreOption(const char* key, const char* value);
bool isRunning() const { return running_.load(); } bool isRunning() const { return running_.load(); }
@@ -121,6 +122,10 @@ private:
std::vector<uint8_t> rom_data_; std::vector<uint8_t> rom_data_;
bool need_fullpath_ = true; 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;

View File

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

View File

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

View File

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

View File

@@ -27,5 +27,6 @@ object NativeLibretro {
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 nativeSetControllerPortDevice(port: Int, device: Int)
external fun nativeSetPendingControllerDevice(device: Int)
external fun nativeSetCoreOption(key: String, value: String) 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") Log.w(TAG, "BIOS not found at ${biosFile.absolutePath} — core will use HLE BIOS")
} }
if (!NativeLibretro.nativeLoadCore(corePath)) { // Set core options BEFORE loading core - pcsx_rearmed reads them during retro_init
Log.e(TAG, "Failed to load core: $corePath")
return false
}
// Set core options before loading game
NativeLibretro.nativeSetCoreOption("pcsx_rearmed_drc", "enabled") NativeLibretro.nativeSetCoreOption("pcsx_rearmed_drc", "enabled")
NativeLibretro.nativeSetCoreOption("pcsx_rearmed_pad1type", "analog") NativeLibretro.nativeSetCoreOption("pcsx_rearmed_pad1type", "analog")
NativeLibretro.nativeSetCoreOption("pcsx_rearmed_frameskip_type", "disabled") NativeLibretro.nativeSetCoreOption("pcsx_rearmed_frameskip_type", "disabled")
NativeLibretro.nativeSetCoreOption("pcsx_rearmed_region", "auto") NativeLibretro.nativeSetCoreOption("pcsx_rearmed_region", "auto")
NativeLibretro.nativeSetCoreOption("pcsx_rearmed_memcard2", "disabled") 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)) { if (!NativeLibretro.nativeLoadGame(path)) {
Log.e(TAG, "Failed to load game: $path") Log.e(TAG, "Failed to load game: $path")
return false return false
} }
// Set controller to DualShock analog after game load
NativeLibretro.nativeSetControllerPortDevice(0, 5) // RETRO_DEVICE_ANALOG
Log.i(TAG, "Game loaded successfully: $path") Log.i(TAG, "Game loaded successfully: $path")
return true return true
} }

View File

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

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