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:
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user