Skip to content
Open
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
195 changes: 130 additions & 65 deletions app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import java.io.File
import java.util.EnumSet
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import kotlin.math.max
import kotlin.math.min
Expand All @@ -61,6 +62,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
Expand Down Expand Up @@ -105,6 +107,16 @@ class LibraryViewModel @Inject constructor(
@Volatile private var paginationCurrentPage: Int = 0
@Volatile private var lastPageInCurrentFilter: Int = 0


// Quick cache to check if we need to refresh
@Volatile private var cachedFilteredLibrary: List<LibraryItem>? = null

// in-flight filter job to avoid duplicate operations
private var filterJob: Job? = null

// Check size of cache to avoid duplication
private val steamSizeCache = ConcurrentHashMap<String, Long>()

// Complete and unfiltered app list
private var appList: List<SteamApp> = emptyList()
private var gogGameList: List<GOGGame> = emptyList()
Expand Down Expand Up @@ -181,6 +193,7 @@ class LibraryViewModel @Inject constructor(
// Check if the list has actually changed before triggering a re-filter
if (appList != apps) {
appList = apps
steamSizeCache.clear()
onFilterApps(paginationCurrentPage)
}
}
Expand Down Expand Up @@ -367,7 +380,60 @@ class LibraryViewModel @Inject constructor(
// Amount to change by
var toPage = max(0, paginationCurrentPage + pageIncrement)
toPage = min(toPage, lastPageInCurrentFilter)
onFilterApps(toPage)

// Pagination only reveals more of the already filtered + sorted list, so slice the
// cached result instead of re-running the whole filter pipeline.
val cached = cachedFilteredLibrary
if (cached == null) {
onFilterApps(toPage)
return
}
if (toPage == paginationCurrentPage) {
return
}
paginationCurrentPage = toPage
viewModelScope.launch(Dispatchers.Default) {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
val pagedList = applyPagination(cached, toPage, _state.value)
fetchCompatibilityForPage(pagedList.map { it.name })
_state.update {
it.copy(
appInfoList = pagedList,
currentPaginationPage = toPage + 1, // visual display is not 0 indexed
)
}
}
}

/**
* Slices [combined] up to and including [page], prepending the recommendation item when
* applicable. Shared by the full filter pipeline and the pagination fast path.
*/
private fun applyPagination(combined: List<LibraryItem>, page: Int, currentState: LibraryState): List<LibraryItem> {
val pageSize = PrefManager.itemsPerPage
val endIndex = min((page + 1) * pageSize, combined.size)
var pagedList = combined.take(endIndex)

// Prepend recommendation as first item on ALL tab when enabled and not searching
val rec = cachedRecommendation
if (rec != null
&& PrefManager.showRecommendations
&& currentState.currentTab == LibraryTab.ALL
&& currentState.searchQuery.isEmpty()
) {
val recItem = LibraryItem(
index = -1,
appId = "RECOMMENDED_${rec.id}",
name = rec.name,
heroImageUrl = rec.heroImageUrl,
capsuleImageUrl = rec.capsuleImageUrl,
iconHash = rec.iconUrl ?: rec.capsuleImageUrl,
isRecommended = true,
recommendedGameId = rec.id,
gameSource = GameSource.STEAM,
)
pagedList = listOf(recItem) + pagedList.map { it.copy(index = it.index + 1) }
}
return pagedList
}

fun onRefresh() {
Expand All @@ -378,6 +444,7 @@ class LibraryViewModel @Inject constructor(
GameCompatibilityCache.clear()
DeviceGameStatsCache.clear()
GpuGameStatsCache.clear()
steamSizeCache.clear()

try {
val newApps = SteamService.refreshOwnedGamesFromServer()
Expand Down Expand Up @@ -484,9 +551,12 @@ class LibraryViewModel @Inject constructor(
return true
}

private fun onFilterApps(paginationPage: Int = 0): Job {
private fun onFilterApps(paginationPage: Int = 0): Job = synchronized(this) {
Timber.tag("LibraryViewModel").d("onFilterApps - appList.size: ${appList.size}, isFirstLoad: $isFirstLoad")
return viewModelScope.launch(Dispatchers.IO) {
// Invalidate cache and current running job
cachedFilteredLibrary = null
filterJob?.cancel()
viewModelScope.launch(Dispatchers.IO) {
_state.update { it.copy(isLoading = true) }

val currentState = _state.value
Expand Down Expand Up @@ -549,41 +619,48 @@ class LibraryViewModel @Inject constructor(
}
.toList()

// Filter Steam apps first (no pagination yet)
// Note: Don't sort individual lists - we'll sort the combined list for consistent ordering
// Filter Steam apps first (no pagination yet). Don't sort here - the combined
// list is fully re-sorted below, so a pre-sort is wasted work.
val filteredSteamApps: List<SteamApp> = steamFilteredBeforeCompatibility
.asSequence()
.filter { item -> passesCompatibleFilter(item.name) }
.filter { item -> passesStatsFilters(currentState, GameSource.STEAM, item.name) }
.sortedWith(
compareByDescending<SteamApp> {
downloadDirectorySet.contains(SteamService.getAppDirName(it))
}.thenBy { it.name.lowercase() },
)
.toList()

// Map Steam apps to UI items
data class LibraryEntry(val item: LibraryItem, val isInstalled: Boolean, val lastPlayed: Long = 0L)
data class LibraryEntry(val item: LibraryItem, val isInstalled: Boolean, val lastPlayed: Long = 0L) {
// Precomputed once so sort comparators don't allocate a lowercase copy per comparison
val sortName: String = item.name.lowercase()
}

fun lastPlayedFor(appId: String): Long = playHistoryByAppId[appId] ?: 0L

val licensedDepotMap = SteamService.buildLicensedDepotMap(filteredSteamApps)

// Single query for installed branches instead of one blocking lookup per installed app
val installedBranchByAppId = SteamService.getAllInstalledApps()
.orEmpty()
.associate { it.id to it.branch }

ensureActive()

// Added this to avoid duplicate from custom imported steam game
val steamEntriesAppIds = mutableSetOf<String>()

val steamEntries: List<LibraryEntry> = filteredSteamApps.map { item ->
val isInstalled = downloadDirectorySet.contains(SteamService.getAppDirName(item))
val installedBranch = if (isInstalled) {
SteamService.getInstalledApp(item.id)?.branch ?: "public"
installedBranchByAppId[item.id] ?: "public"
} else {
"public"
}
// base-game size: ownedDlc=emptyMap excludes DLC depots
val licensedDepots = licensedDepotMap[item.id]
val resolved = SteamService.resolveDownloadableDepots(item.depots, "", emptyMap(), licensedDepots)
val totalSizeBytes = resolved.values.sumOf { depot ->
depot.manifests[installedBranch]?.size ?: depot.manifests.values.firstOrNull()?.size ?: 0L
val totalSizeBytes = steamSizeCache.getOrPut("${item.id}:$installedBranch") {
val licensedDepots = licensedDepotMap[item.id]
val resolved = SteamService.resolveDownloadableDepots(item.depots, "", emptyMap(), licensedDepots)
resolved.values.sumOf { depot ->
depot.manifests[installedBranch]?.size ?: depot.manifests.values.firstOrNull()?.size ?: 0L
}
}

// Move appId here
Expand Down Expand Up @@ -774,6 +851,12 @@ class LibraryViewModel @Inject constructor(
// ALL tab uses user preferences, other tabs override with their presets
// Use captured currentState (not _state.value) to avoid TOCTOU race
val currentTab = currentState.currentTab

// Credential checks hit storage; do each once per run instead of per usage below
val hasGOGCredentials = GOGService.hasStoredCredentials(context)
val hasEpicCredentials = EpicService.hasStoredCredentials(context)
val hasAmazonCredentials = AmazonService.hasStoredCredentials(context)

val includeSteam = if (currentTab == app.gamenative.ui.enums.LibraryTab.ALL) {
currentState.showSteamInLibrary
} else {
Expand All @@ -789,59 +872,61 @@ class LibraryViewModel @Inject constructor(
currentState.showGOGInLibrary
} else {
currentTab.showGoG
}) && GOGService.hasStoredCredentials(context)
}) && hasGOGCredentials

val includeEpic = (if (currentTab == app.gamenative.ui.enums.LibraryTab.ALL) {
currentState.showEpicInLibrary
} else {
currentTab.showEpic
}) && EpicService.hasStoredCredentials(context)
}) && hasEpicCredentials

val includeAmazon = (if (currentTab == app.gamenative.ui.enums.LibraryTab.ALL) {
currentState.showAmazonInLibrary
} else {
currentTab.showAmazon
}) && AmazonService.hasStoredCredentials(context)
}) && hasAmazonCredentials

// Combine both lists and apply sort option
val sortComparator: Comparator<LibraryEntry> = when (currentState.currentSortOption) {
SortOption.INSTALLED_FIRST -> compareBy<LibraryEntry> { entry ->
if (entry.isInstalled) 0 else 1
}.thenBy { it.item.name.lowercase() }
}.thenBy { it.sortName }

SortOption.NAME_ASC -> compareBy { it.item.name.lowercase() }
SortOption.NAME_ASC -> compareBy { it.sortName }

SortOption.NAME_DESC -> compareByDescending { it.item.name.lowercase() }
SortOption.NAME_DESC -> compareByDescending { it.sortName }

SortOption.RECENTLY_PLAYED -> LibrarySortUtils.recentlyPlayedComparator(
name = { it.item.name },
name = { it.sortName },
isInstalled = { it.isInstalled },
lastPlayed = { it.lastPlayed },
)

SortOption.SIZE_SMALLEST -> compareBy<LibraryEntry> { it.item.sizeBytes }
.thenBy { it.item.name.lowercase() }
.thenBy { it.sortName }

SortOption.SIZE_LARGEST -> compareByDescending<LibraryEntry> { it.item.sizeBytes }
.thenBy { it.item.name.lowercase() }
.thenBy { it.sortName }

SortOption.FPS_HIGH -> compareByDescending<LibraryEntry> {
currentState.statsFor(it.item)?.fps ?: -1
}.thenBy { it.item.name.lowercase() }
}.thenBy { it.sortName }

SortOption.RUNS_HIGH -> compareByDescending<LibraryEntry> {
currentState.statsFor(it.item)?.runsGpu ?: -1
}.thenBy { it.item.name.lowercase() }
}.thenBy { it.sortName }

SortOption.REVIEWS_HIGH -> compareByDescending<LibraryEntry> {
currentState.statsFor(it.item)?.reviewsDevice ?: -1
}.thenBy { it.item.name.lowercase() }
}.thenBy { it.sortName }

SortOption.REVIEWS_GPU_HIGH -> compareByDescending<LibraryEntry> {
currentState.statsFor(it.item)?.reviewsGpu ?: -1
}.thenBy { it.item.name.lowercase() }
}.thenBy { it.sortName }
}

ensureActive()

val combined = buildList {
if (includeSteam) addAll(steamEntries)
if (includeOpen) addAll(customEntries)
Expand All @@ -854,43 +939,23 @@ class LibraryViewModel @Inject constructor(

// Total count for the current filter
val totalFound = combined.size

// Determine how many pages and slice the list for incremental loading
val pageSize = PrefManager.itemsPerPage
// Update internal pagination state
paginationCurrentPage = paginationPage
lastPageInCurrentFilter = if (totalFound == 0) 0 else (totalFound - 1) / pageSize
// Calculate how many items to show: (pagesLoaded * pageSize)
val endIndex = min((paginationPage + 1) * pageSize, totalFound)
var pagedList = combined.take(endIndex)

// Prepend recommendation as first item on ALL tab when enabled and not searching
val rec = cachedRecommendation
if (rec != null
&& PrefManager.showRecommendations
&& currentTab == LibraryTab.ALL
&& currentState.searchQuery.isEmpty()
) {
val recItem = LibraryItem(
index = -1,
appId = "RECOMMENDED_${rec.id}",
name = rec.name,
heroImageUrl = rec.heroImageUrl,
capsuleImageUrl = rec.capsuleImageUrl,
iconHash = rec.iconUrl ?: rec.capsuleImageUrl,
isRecommended = true,
recommendedGameId = rec.id,
gameSource = GameSource.STEAM,
)
pagedList = listOf(recItem) + pagedList.map { it.copy(index = it.index + 1) }
}
val pagedList = applyPagination(combined, paginationPage, currentState)

Timber.tag("LibraryViewModel").d("Filtered list size (with Custom Games): $totalFound")

if (isFirstLoad) {
isFirstLoad = false
}

// Don't commit results if a newer filter request has superseded this run
ensureActive()

// Update internal pagination state
paginationCurrentPage = paginationPage
lastPageInCurrentFilter = if (totalFound == 0) 0 else (totalFound - 1) / pageSize
cachedFilteredLibrary = combined

// Fetch compatibility for current page games
fetchCompatibilityForPage(pagedList.map { it.name })

Expand All @@ -905,17 +970,17 @@ class LibraryViewModel @Inject constructor(
// Use user prefs + auth state only (not current tab) so badges stay stable across tab switches
allCount = (if (currentState.showSteamInLibrary) steamEntries.size else 0) +
(if (currentState.showCustomGamesInLibrary) customEntries.size else 0) +
(if (currentState.showGOGInLibrary && GOGService.hasStoredCredentials(context)) gogEntries.size else 0) +
(if (currentState.showEpicInLibrary && EpicService.hasStoredCredentials(context)) epicEntries.size else 0) +
(if (currentState.showAmazonInLibrary && AmazonService.hasStoredCredentials(context)) amazonEntries.size else 0),
(if (currentState.showGOGInLibrary && hasGOGCredentials) gogEntries.size else 0) +
(if (currentState.showEpicInLibrary && hasEpicCredentials) epicEntries.size else 0) +
(if (currentState.showAmazonInLibrary && hasAmazonCredentials) amazonEntries.size else 0),
steamCount = if (currentState.showSteamInLibrary) steamEntries.size else 0,
gogCount = if (currentState.showGOGInLibrary && GOGService.hasStoredCredentials(context)) gogEntries.size else 0,
epicCount = if (currentState.showEpicInLibrary && EpicService.hasStoredCredentials(context)) epicEntries.size else 0,
amazonCount = if (currentState.showAmazonInLibrary && AmazonService.hasStoredCredentials(context)) amazonEntries.size else 0,
gogCount = if (currentState.showGOGInLibrary && hasGOGCredentials) gogEntries.size else 0,
epicCount = if (currentState.showEpicInLibrary && hasEpicCredentials) epicEntries.size else 0,
amazonCount = if (currentState.showAmazonInLibrary && hasAmazonCredentials) amazonEntries.size else 0,
localCount = if (currentState.showCustomGamesInLibrary) customEntries.size else 0,
)
}
}
}.also { filterJob = it }
}

/**
Expand Down
Loading