Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
49 changes: 49 additions & 0 deletions app/src/main/java/app/gamenative/PrefManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ object PrefManager {
pref.remove(STEAM_USER_NAME)
pref.remove(LAST_PICS_CHANGE_NUMBER)
pref.remove(STEAM_GAMES_COUNT)
pref.remove(PREFERRED_FAMILY_LENDERS_JSON)
}
}
}
Expand Down Expand Up @@ -1406,4 +1407,52 @@ object PrefManager {
setPref(NEXUS_LAST_PLACEMENT_JSON, value)
}
}

/**
* Preferred Steam Families lender per appId (appId string → lender steamId64).
* Empty / missing entry means use the account's own copy when available.
*/
private val PREFERRED_FAMILY_LENDERS_JSON = stringPreferencesKey("preferred_family_lenders_json")

private fun decodePreferredFamilyLenders(value: String): Map<Int, Long> =
runCatching {
Json.decodeFromString<Map<String, Long>>(value)
.mapNotNull { (key, lenderSteamId) ->
key.toIntOrNull()?.let { appId -> appId to lenderSteamId }
}
.toMap()
}.getOrDefault(emptyMap())

var preferredFamilyLenders: Map<Int, Long>
get() = decodePreferredFamilyLenders(getPref(PREFERRED_FAMILY_LENDERS_JSON, "{}"))
set(value) {
if (value.isEmpty()) {
removePref(PREFERRED_FAMILY_LENDERS_JSON)
} else {
setPref(
PREFERRED_FAMILY_LENDERS_JSON,
Json.encodeToString(value.mapKeys { it.key.toString() }),
)
}
}

fun setPreferredFamilyLender(appId: Int, lenderSteamId: Long?) {
scope.launch {
dataStore.edit { pref ->
val current = decodePreferredFamilyLenders(pref[PREFERRED_FAMILY_LENDERS_JSON] ?: "{}")
val updated = current.toMutableMap()
if (lenderSteamId == null || lenderSteamId == 0L) {
updated.remove(appId)
} else {
updated[appId] = lenderSteamId
}
if (updated.isEmpty()) {
pref.remove(PREFERRED_FAMILY_LENDERS_JSON)
} else {
pref[PREFERRED_FAMILY_LENDERS_JSON] =
Json.encodeToString(updated.mapKeys { it.key.toString() })
}
}
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
13 changes: 13 additions & 0 deletions app/src/main/java/app/gamenative/data/PreferredCopyOption.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package app.gamenative.data

/**
* One selectable Steam Families library copy for an app.
*/
data class PreferredCopyOption(
val lenderSteamId: Long,
val accountId: Int,
val displayName: String,
val isSelf: Boolean,
val packageId: Int?,
val ownedDlcCount: Int? = null,
)
4 changes: 4 additions & 0 deletions app/src/main/java/app/gamenative/db/dao/SteamAppDao.kt
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,10 @@ interface SteamAppDao {
)
suspend fun findHiddenDLCApps(appId: Int): List<SteamApp>?

/** All local DLC rows for a parent app, regardless of license (catalog only). */
@Query("SELECT * FROM steam_app WHERE dlc_for_app_id = :appId")
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
suspend fun findDlcAppsForParent(appId: Int): List<SteamApp>

@Query("DELETE from steam_app")
suspend fun deleteAll()

Expand Down
1 change: 1 addition & 0 deletions app/src/main/java/app/gamenative/events/AndroidEvent.kt
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ interface AndroidEvent<T> : Event<T> {
data class DownloadStatusChanged(val appId: Int, val isDownloading: Boolean) : AndroidEvent<Unit>
data class PostInstallSyncStatusChanged(val appId: Int, val isSyncing: Boolean) : AndroidEvent<Unit>
data class LibraryInstallStatusChanged(val appId: Int, val source: GameSource) : AndroidEvent<Unit>
data class PreferredCopyChanged(val appId: Int) : AndroidEvent<Unit>
data class CustomGameImagesFetched(val appId: String) : AndroidEvent<Unit>
data object RecommendationToggleChanged : AndroidEvent<Unit>
data class GOGAuthCodeReceived(val authCode: String) : AndroidEvent<Unit>
Expand Down
787 changes: 777 additions & 10 deletions app/src/main/java/app/gamenative/service/SteamService.kt

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions app/src/main/java/app/gamenative/ui/data/GameDisplayInfo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,10 @@ data class GameDisplayInfo(
val compatibilityMessage: String? = null, // Compatibility message text (e.g., "Works on your GPU")
val compatibilityColor: ULong? = null, // Compatibility message color (ARGB)
val hltbStats: app.gamenative.utils.HltbService.Stats? = null, // How Long To Beat stats
/** Status line under Play for Steam Families preferred copy, e.g. "Using your copy". */
val preferredCopyStatusText: String? = null,
val showChangePreferredCopy: Boolean = false,
/** True while available Family library copies are being resolved off the main thread. */
val isLoadingPreferredCopy: Boolean = false,
)

Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,5 @@ enum class AppOptionMenuType(@StringRes val title: Int) {
ManageWorkshop(R.string.option_manage_workshop),
ManageMods(R.string.option_manage_mods),
ChangeBranch(R.string.change_branch),
ChangePreferredCopy(R.string.change_preferred_copy),
}
12 changes: 12 additions & 0 deletions app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,16 @@ class LibraryViewModel @Inject constructor(
onFilterApps(paginationCurrentPage)
}

private val onPreferredCopyChanged: (AndroidEvent.PreferredCopyChanged) -> Unit = { event ->
viewModelScope.launch(Dispatchers.IO) {
val updated = steamAppDao.findApp(event.appId) ?: return@launch
if (appList.any { it.id == updated.id }) {
appList = appList.map { if (it.id == updated.id) updated else it }
onFilterApps(paginationCurrentPage)
}
}
}

private val onCustomGameImagesFetched: (AndroidEvent.CustomGameImagesFetched) -> Unit = {
// Increment refresh counter and refresh the library list to pick up newly fetched images
_state.update { it.copy(imageRefreshCounter = it.imageRefreshCounter + 1) }
Expand Down Expand Up @@ -233,6 +243,7 @@ class LibraryViewModel @Inject constructor(
}

PluviaApp.events.on<AndroidEvent.LibraryInstallStatusChanged, Unit>(onInstallStatusChanged)
PluviaApp.events.on<AndroidEvent.PreferredCopyChanged, Unit>(onPreferredCopyChanged)
PluviaApp.events.on<AndroidEvent.CustomGameImagesFetched, Unit>(onCustomGameImagesFetched)
PluviaApp.events.on<AndroidEvent.RecommendationToggleChanged, Unit>(onRecommendationToggleChanged)

Expand All @@ -247,6 +258,7 @@ class LibraryViewModel @Inject constructor(
override fun onCleared() {
searchDebounceJob?.cancel()
PluviaApp.events.off<AndroidEvent.LibraryInstallStatusChanged, Unit>(onInstallStatusChanged)
PluviaApp.events.off<AndroidEvent.PreferredCopyChanged, Unit>(onPreferredCopyChanged)
PluviaApp.events.off<AndroidEvent.CustomGameImagesFetched, Unit>(onCustomGameImagesFetched)
PluviaApp.events.off<AndroidEvent.RecommendationToggleChanged, Unit>(onRecommendationToggleChanged)
super.onCleared()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.ui.semantics.Role
import androidx.compose.foundation.layout.displayCutout
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
Expand Down Expand Up @@ -61,6 +62,7 @@ import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
Expand Down Expand Up @@ -570,6 +572,7 @@ internal fun AppScreenContent(
onUpdateClick: () -> Unit,
onBack: () -> Unit = {},
optionsMenu: List<AppMenuOption>,
onChangePreferredCopy: (() -> Unit)? = null,
) {
val context = LocalContext.current
// reactive — recomposes when network state changes
Expand Down Expand Up @@ -984,6 +987,51 @@ internal fun AppScreenContent(
}
}
}

if (displayInfo.isLoadingPreferredCopy) {
Spacer(modifier = Modifier.height(10.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
CircularProgressIndicator(
modifier = Modifier.size(14.dp),
strokeWidth = 2.dp,
color = Color.White.copy(alpha = 0.8f),
)
Text(
text = stringResource(R.string.loading_preferred_copy),
style = MaterialTheme.typography.bodySmall,
color = Color.White.copy(alpha = 0.8f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
} else if (displayInfo.showChangePreferredCopy && onChangePreferredCopy != null) {
Spacer(modifier = Modifier.height(10.dp))
Column(modifier = Modifier.fillMaxWidth()) {
displayInfo.preferredCopyStatusText?.let { status ->
Text(
text = status,
style = MaterialTheme.typography.bodySmall,
color = Color.White.copy(alpha = 0.8f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(modifier = Modifier.height(2.dp))
}
Text(
text = stringResource(R.string.change_preferred_copy),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.clickable(
role = Role.Button,
onClick = onChangePreferredCopy,
),
)
}
}
}

// Compatibility status (if applicable)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,15 @@ abstract class BaseAppScreen {
return compatibilityMessage to compatibilityColor
}

/**
* Optional handler for Steam Families "Change preferred copy" under Play.
* Return a click lambda when the game has multiple family copies.
*/
protected open fun onChangePreferredCopyClick(
context: Context,
libraryItem: LibraryItem,
): (() -> Unit)? = null

/**
* Get the game display information for rendering the UI.
* This is called to get all the data needed for the common UI layout.
Expand Down Expand Up @@ -1359,6 +1368,7 @@ abstract class BaseAppScreen {
},
onBack = onBack,
optionsMenu = optionsMenu,
onChangePreferredCopy = onChangePreferredCopyClick(context, libraryItem),
)

if (showReadiness && launchActivity != null) {
Expand Down
Loading