From 2a695051991e537dec115ecbb664240bf69daa10 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Sun, 25 Oct 2026 08:39:56 -0400 Subject: [PATCH] feat: merge Games and store libraries into one Library and one game page (#1460) --- .../droiddeck/launcher/FrontEndActivity.kt | 4 - .../launcher/session/SessionPrefs.kt | 10 - .../droiddeck/launcher/stores/StoreCatalog.kt | 47 +- .../droiddeck/launcher/stores/StoresState.kt | 18 +- .../launcher/stores/epic/EpicBackend.kt | 4 +- .../launcher/stores/epic/EpicClient.kt | 6 - .../launcher/stores/xbox/XboxBackend.kt | 2 +- .../droiddeck/launcher/ui/CardDetailHost.kt | 136 +++ .../droiddeck/launcher/ui/FrontEndContent.kt | 2 +- .../droiddeck/launcher/ui/FrontEndGames.kt | 504 +---------- .../droiddeck/launcher/ui/FrontEndScreen.kt | 6 +- .../droiddeck/launcher/ui/FrontEndSetup.kt | 58 +- .../com/droiddeck/launcher/ui/GameDetail.kt | 830 ++++++++++++++++++ .../com/droiddeck/launcher/ui/LibraryPage.kt | 690 +++++++++++++++ .../launcher/ui/StoreAchievementsUi.kt | 17 + .../com/droiddeck/launcher/ui/StoresDetail.kt | 329 ------- .../droiddeck/launcher/ui/StoresDownloads.kt | 10 +- .../com/droiddeck/launcher/ui/StoresPage.kt | 781 +++++----------- app/src/main/res/values-ar/strings.xml | 44 +- app/src/main/res/values-b+es+419/strings.xml | 44 +- app/src/main/res/values-bg/strings.xml | 44 +- app/src/main/res/values-cs/strings.xml | 44 +- app/src/main/res/values-da/strings.xml | 44 +- app/src/main/res/values-de/strings.xml | 44 +- app/src/main/res/values-el/strings.xml | 44 +- app/src/main/res/values-es/strings.xml | 44 +- app/src/main/res/values-fi/strings.xml | 44 +- app/src/main/res/values-fr/strings.xml | 44 +- app/src/main/res/values-hi/strings.xml | 44 +- app/src/main/res/values-hu/strings.xml | 44 +- app/src/main/res/values-in/strings.xml | 44 +- app/src/main/res/values-it/strings.xml | 44 +- app/src/main/res/values-ja/strings.xml | 44 +- app/src/main/res/values-ko/strings.xml | 44 +- app/src/main/res/values-ms/strings.xml | 44 +- app/src/main/res/values-nb/strings.xml | 44 +- app/src/main/res/values-nl/strings.xml | 44 +- app/src/main/res/values-pl/strings.xml | 44 +- app/src/main/res/values-pt-rPT/strings.xml | 44 +- app/src/main/res/values-pt/strings.xml | 44 +- app/src/main/res/values-ro/strings.xml | 44 +- app/src/main/res/values-ru/strings.xml | 44 +- app/src/main/res/values-sv/strings.xml | 44 +- app/src/main/res/values-th/strings.xml | 44 +- app/src/main/res/values-tr/strings.xml | 44 +- app/src/main/res/values-uk/strings.xml | 44 +- app/src/main/res/values-vi/strings.xml | 44 +- app/src/main/res/values-zh-rCN/strings.xml | 44 +- app/src/main/res/values-zh-rHK/strings.xml | 44 +- app/src/main/res/values-zh-rTW/strings.xml | 44 +- app/src/main/res/values/strings.xml | 49 +- .../launcher/stores/SortCatalogItemsTest.kt | 7 + .../launcher/stores/epic/EpicClientTest.kt | 11 - .../launcher/ui/GamesClientTagTest.kt | 4 +- .../launcher/ui/LibraryEntriesTest.kt | 51 ++ .../launcher/ui/StoreGridNavigationTest.kt | 5 - .../droiddeck/launcher/ui/StoreTabsTest.kt | 17 - docs/agent-control.md | 7 +- docs/development/added-games.md | 2 +- docs/development/steam-display-scaling.md | 2 +- docs/development/stores.md | 24 +- docs/development/x86-64-steam-client.md | 2 +- 62 files changed, 3240 insertions(+), 1803 deletions(-) create mode 100644 app/src/main/java/com/droiddeck/launcher/ui/CardDetailHost.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/ui/GameDetail.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/ui/LibraryPage.kt delete mode 100644 app/src/main/java/com/droiddeck/launcher/ui/StoresDetail.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/ui/LibraryEntriesTest.kt delete mode 100644 app/src/test/java/com/droiddeck/launcher/ui/StoreTabsTest.kt diff --git a/app/src/main/java/com/droiddeck/launcher/FrontEndActivity.kt b/app/src/main/java/com/droiddeck/launcher/FrontEndActivity.kt index e2ce59eff..ed974c256 100644 --- a/app/src/main/java/com/droiddeck/launcher/FrontEndActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/FrontEndActivity.kt @@ -172,7 +172,6 @@ class FrontEndActivity : ComponentActivity() { private var launcherIcon by mutableStateOf(LauncherIcon.DARK) private var gameStoresEnabled by mutableStateOf(true) private var gameStoresSpeedTier by mutableStateOf("fast") - private var storesOpenTab by mutableStateOf(SessionPrefs.STORES_OPEN_LIBRARY) private var navRequest by mutableStateOf(null) private var storesShowMature by mutableStateOf(false) private var storesSort by mutableStateOf(SessionPrefs.STORES_SORTS.first()) @@ -773,7 +772,6 @@ class FrontEndActivity : ComponentActivity() { launcherIcon = LauncherIcon.current(this) gameStoresEnabled = SessionPrefs.gameStoresEnabled(this) gameStoresSpeedTier = SessionPrefs.gameStoresSpeedTier(this) - storesOpenTab = SessionPrefs.storesOpenTab(this) storesShowMature = SessionPrefs.storesShowMature(this) storesSort = SessionPrefs.storesSort(this) storesView = SessionPrefs.storesView(this) @@ -837,7 +835,6 @@ class FrontEndActivity : ComponentActivity() { launcherIcon = launcherIcon, gameStoresEnabled = gameStoresEnabled, gameStoresSpeedTier = gameStoresSpeedTier, - storesOpenTab = storesOpenTab, navigate = navRequest, addedGamesArt = addedGamesArt, sgdbUserKey = sgdbUserKey, @@ -1012,7 +1009,6 @@ class FrontEndActivity : ComponentActivity() { applyLauncherFullscreen() }, onGameStoresSpeedTier = { tier -> SessionPrefs.setGameStoresSpeedTier(this, tier); gameStoresSpeedTier = tier }, - onStoresOpenTab = { tab -> SessionPrefs.setStoresOpenTab(this, tab); storesOpenTab = tab }, onNavigated = { navRequest = null }, onStoresShowMature = { show -> SessionPrefs.setStoresShowMature(this, show); storesShowMature = show }, onStoresSort = { sort -> SessionPrefs.setStoresSort(this, sort); storesSort = sort }, diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt index e8041fe0b..d77cced0b 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt @@ -91,13 +91,6 @@ object SessionPrefs { */ fun storesEpicMode(context: Context): String = prefs(context).getString("storesEpicMode", "adaptive") ?: "adaptive" - fun storesOpenTab(context: Context): String = - prefs(context).getString("storesOpenTab", STORES_OPEN_LIBRARY)?.takeIf { it == STORES_OPEN_STORE } ?: STORES_OPEN_LIBRARY - - fun setStoresOpenTab(context: Context, tab: String) { - prefs(context).edit().putString("storesOpenTab", if (tab == STORES_OPEN_STORE) STORES_OPEN_STORE else STORES_OPEN_LIBRARY).apply() - } - fun storesShowMature(context: Context): Boolean = prefs(context).getBoolean("storesShowMature", false) fun setStoresShowMature(context: Context, show: Boolean) { @@ -135,9 +128,6 @@ object SessionPrefs { val STORES_SORTS = listOf("lastPlayed", "recentlyInstalled", "titleAsc", "titleDesc", "sizeDesc", "sizeAsc", "releaseDateDesc") val STORES_VIEWS = listOf("cards", "capsules", "list") - const val STORES_OPEN_LIBRARY = "library" - const val STORES_OPEN_STORE = "store" - fun storesInstallTarget(context: Context): String = prefs(context).getString("storesInstallTarget", "") ?: "" fun setStoresInstallTarget(context: Context, path: String) { diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreCatalog.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreCatalog.kt index cfb0ed53d..967e56ca7 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/StoreCatalog.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreCatalog.kt @@ -61,33 +61,38 @@ fun installedCards(store: Store, installed: List, library: L }.distinctBy { it.id }.sortedBy { it.title.lowercase() } } -private fun releaseDateValue(item: CatalogItem): Long = - item.releaseDate.take(10).takeIf { it.length == 10 }?.let { raw -> - runCatching { java.time.LocalDate.parse(raw).toEpochDay() }.getOrDefault(0L) +private fun releaseDateValue(raw: String): Long = + raw.take(10).takeIf { it.length == 10 }?.let { day -> + runCatching { java.time.LocalDate.parse(day).toEpochDay() }.getOrDefault(0L) } ?: 0L -fun sortCatalogItems( - items: List, +fun sortGames( + items: List, sort: String, - lastPlayed: (CatalogItem) -> Long = { 0L }, - installedAt: (CatalogItem) -> Long = { 0L }, - size: (CatalogItem) -> Long = { it.sizeBytes }, -): List = when (sort) { - "lastPlayed" -> items.sortedWith(compareByDescending(lastPlayed).thenBy { it.title.lowercase() }) - "recentlyInstalled" -> items.sortedWith(compareByDescending(installedAt).thenBy { it.title.lowercase() }) - "titleAsc" -> items.sortedBy { it.title.lowercase() } - "titleDesc" -> items.sortedByDescending { it.title.lowercase() } - "sizeDesc" -> items.sortedWith(compareByDescending(size).thenBy { it.title.lowercase() }) - "sizeAsc" -> items.sortedWith(compareBy { size(it).takeIf { b -> b > 0 } ?: Long.MAX_VALUE }.thenBy { it.title.lowercase() }) - "releaseDateDesc" -> items.sortedWith(compareByDescending(::releaseDateValue).thenBy { it.title.lowercase() }) - else -> items + title: (T) -> String, + releaseDate: (T) -> String, + lastPlayed: (T) -> Long = { 0L }, + installedAt: (T) -> Long = { 0L }, + size: (T) -> Long = { 0L }, +): List { + val byTitle = compareBy { title(it).lowercase() } + return when (sort) { + "lastPlayed" -> items.sortedWith(compareByDescending(lastPlayed).then(byTitle)) + "recentlyInstalled" -> items.sortedWith(compareByDescending(installedAt).then(byTitle)) + "titleAsc" -> items.sortedWith(byTitle) + "titleDesc" -> items.sortedWith(byTitle.reversed()) + "sizeDesc" -> items.sortedWith(compareByDescending(size).then(byTitle)) + "sizeAsc" -> items.sortedWith(compareBy { size(it).takeIf { b -> b > 0 } ?: Long.MAX_VALUE }.then(byTitle)) + "releaseDateDesc" -> items.sortedWith(compareByDescending { releaseDateValue(releaseDate(it)) }.then(byTitle)) + else -> items + } } -internal fun retainCatalogOrder(items: List, previous: List): List { +internal fun retainOrder(items: List, previous: List, key: (T) -> String): List { if (items.size != previous.size) return items - val byKey = items.associateBy { it.key } - if (previous.any { it.key !in byKey }) return items - return previous.map { byKey.getValue(it.key) } + val byKey = items.associateBy(key) + if (previous.any { key(it) !in byKey }) return items + return previous.map { byKey.getValue(key(it)) } } /** A detail page opens for search results too; the library wins when it already knows the product. */ diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoresState.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoresState.kt index cf7fc2dfa..1fcbcd481 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/StoresState.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoresState.kt @@ -14,6 +14,7 @@ import com.droiddeck.launcher.stores.download.DownloadEntry object StoresState { private const val TAG = "Stores" private const val SEARCH_DELAY_MS = 400L + private const val LIBRARY_RETRY_MS = 60_000L private val main = Handler(Looper.getMainLooper()) val accounts = mutableStateMapOf() @@ -85,7 +86,7 @@ object StoresState { main.post { androidx.compose.runtime.snapshots.Snapshot.withMutableSnapshot { val restored = found.toMap() - accounts.keys.toList().filter { it !in restored }.forEach { accounts.remove(it) } + accounts.keys.toList().filter { it !in restored }.forEach { accounts.remove(it); libraryAsked.remove(it) } accounts.putAll(restored) installed = onDisk unfinished = partial @@ -139,6 +140,18 @@ object StoresState { if (force || !shelves.containsKey(store)) backend.loadShelves(app, force) } + private val libraryAsked = java.util.concurrent.ConcurrentHashMap() + + /** Once per store, again after a minute when the last sync never produced a library (offline, expired sign-in). */ + fun loadLibrary(context: Context, store: Store) { + if (!isSignedIn(store) || library.containsKey(store)) return + val now = android.os.SystemClock.elapsedRealtime() + val asked = libraryAsked[store] + if (asked != null && now - asked < LIBRARY_RETRY_MS) return + libraryAsked[store] = now + backends[store]?.syncLibrary(context.applicationContext, false) + } + fun preview(context: Context, store: Store) { if (!shelves.containsKey(store)) backends[store]?.loadShelves(context.applicationContext, false) } @@ -165,6 +178,7 @@ object StoresState { fun signIn(context: Context, store: Store) { expired.remove(store) + libraryAsked.remove(store) backends[store]?.signIn(context) ?: logLine("${store.label}: this build has no sign-in for it yet") } @@ -177,7 +191,7 @@ object StoresState { else if (store != Store.XBOX || backend == null) StoreAccounts.clear(app, store) main.post { if (store == Store.XBOX && StoreAccounts.signedInAs(app, store) != null) return@post - accounts.remove(store); library.remove(store); status.remove(store); problems.remove(store) + accounts.remove(store); library.remove(store); status.remove(store); problems.remove(store); libraryAsked.remove(store) logLine("${store.label}: signed out") } } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicBackend.kt b/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicBackend.kt index 316cabb65..ce5afa91b 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicBackend.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicBackend.kt @@ -47,6 +47,8 @@ object EpicBackend : StoreBackend, EpicLaunchSupport { if (!quiet) StoresState.status[store] = app.getString(R.string.stores_sync_library) } val result = EpicLibrary.sync(app, force) { line -> if (!quiet) StoresState.post { StoresState.status[store] = line } } + // Throttled means the cache is fresh, possibly written by another sync after this one read it. + val fresh = if (result == EpicLibrary.SyncResult.Throttled) EpicLibrary.cached(app).map { EpicLibrary.toCatalogItem(app, it) } else cached StoresState.post { StoresState.status.remove(store) when (result) { @@ -56,7 +58,7 @@ object EpicBackend : StoreBackend, EpicLaunchSupport { } is EpicLibrary.SyncResult.Failed -> StoresState.problems[store] = result.message EpicLibrary.SyncResult.NotLoggedIn -> StoresState.problems[store] = StoresState.notSignedInLine(store) - EpicLibrary.SyncResult.Throttled -> if (!StoresState.library.containsKey(store)) StoresState.library[store] = cached + EpicLibrary.SyncResult.Throttled -> if (fresh.isNotEmpty()) StoresState.library[store] = mergeItems(StoresState.library[store], fresh) EpicLibrary.SyncResult.Busy -> {} } } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicClient.kt b/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicClient.kt index 7f0e86b14..8da3a96ad 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicClient.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicClient.kt @@ -227,12 +227,6 @@ object EpicClient { ) } - fun withLauncher(games: List, launcher: Library.SteamGame?): List { - if (launcher == null) return games - val at = games.indexOfFirst { it.source == Store.EPIC.id }.takeIf { it >= 0 } ?: games.size - return games.subList(0, at) + launcher + games.subList(at, games.size) - } - private fun steamSession(): Boolean = SessionState.running && !SessionState.stopRequested && SessionState.mode == SessionService.MODE_STEAM diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxBackend.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxBackend.kt index a50b56612..d678a2f41 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxBackend.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxBackend.kt @@ -51,7 +51,7 @@ object XboxBackend : StoreBackend { if (current == generation) { if (cached != null) StoresState.library[store] = mergeItems(StoresState.library[store], cached.games) StoresState.problems.remove(store) - if (cached == null || force) StoresState.status[store] = "Syncing Xbox library…" + if (cached == null || force) StoresState.status[store] = app.getString(com.droiddeck.launcher.R.string.stores_sync_library) else StoresState.status.remove(store) } } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/CardDetailHost.kt b/app/src/main/java/com/droiddeck/launcher/ui/CardDetailHost.kt new file mode 100644 index 000000000..d94ee25a7 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/CardDetailHost.kt @@ -0,0 +1,136 @@ +package com.droiddeck.launcher.ui + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay +import androidx.compose.runtime.setValue +import androidx.compose.runtime.getValue + +/** A grid of cards with a game page that floods out of the opened card and drains back into it. */ +@OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class) +@Composable +internal fun CardDetailHost( + open: String?, + detailScroll: ScrollState, + gridScrolled: () -> Boolean, + onDetailFocus: (Boolean) -> Unit, + modifier: Modifier = Modifier, + wrap: @Composable (key: String, content: @Composable () -> Unit) -> Unit = { _, content -> content() }, + grid: @Composable () -> Unit, + detail: @Composable (key: String) -> Unit, +) { + val ctx = LocalContext.current + val ff = LocalFrontFocus.current + val density = LocalDensity.current + var shown by remember { mutableStateOf(null) } + var leaving by remember { mutableStateOf(false) } + var from by remember { mutableStateOf(null) } + var art by remember { mutableStateOf(null) } + var viewportAt by remember { mutableStateOf(Offset.Zero) } + LaunchedEffect(open) { + if (open != null) { + val mark = StoresMotion.takeCard(open) + from = mark?.let { Origin(it.bounds.translate(-viewportAt), it.corner) } + art = mark?.art + StoresMotion.heroLands = mark != null + leaving = false + shown = open + } else if (shown != null) { + if (from != null) { + leaving = true + delay(Motion.ms(PAGE_RETURN_MS).toLong()) + } + shown = null + leaving = false + from = null + } + } + LaunchedEffect(shown) { detailScroll.scrollTo(0) } + val covering = shown != null && !leaving + val coveringNow = rememberUpdatedState(covering) + val gridScale by animateFloatAsState(if (covering) 0.95f else 1f, if (covering) Motion.tw(480) else Motion.tw(460, 40), label = "gridSink") + val gridAlpha by animateFloatAsState(if (covering) 0f else 1f, if (covering) Motion.tw(320, 80) else Motion.tw(300, 100), label = "gridFade") + val fade = with(density) { 14.dp.toPx() } + Box( + modifier.clipToBounds() + .onGloballyPositioned { viewportAt = it.positionInRoot() } + .graphicsLayer { compositingStrategy = androidx.compose.ui.graphics.CompositingStrategy.Offscreen } + .drawWithContent { + drawContent() + val scrolled = if (shown != null) detailScroll.value > 0 else gridScrolled() + if (scrolled && size.height > fade) drawRect( + Brush.verticalGradient(0f to Color.Transparent, fade / size.height to Color.Black), + blendMode = androidx.compose.ui.graphics.BlendMode.DstIn, + ) + }, + ) { + CompositionLocalProvider(LocalFrontFocus provides if (covering) null else ff) { + Column( + modifier = Modifier.fillMaxSize() + .graphicsLayer { scaleX = gridScale; scaleY = gridScale; alpha = gridAlpha } + .focusProperties { enter = { if (coveringNow.value) FocusRequester.Cancel else FocusRequester.Default } } + .focusGroup(), + ) { grid() } + } + shown?.let { key -> + wrap(key) { + val body: @Composable () -> Unit = { + Column( + Modifier.fillMaxSize().pointerInput(Unit) { detectTapGestures { } } + .verticalScroll(detailScroll).padding(top = 4.dp, bottom = 16.dp), + ) { + Column( + Modifier.fillMaxWidth() + .onFocusChanged { onDetailFocus(it.hasFocus) } + .focusProperties { exit = { if (coveringNow.value && Steps.step == null && StoresMotion.step == null) FocusRequester.Cancel else FocusRequester.Default } } + .focusGroup(), + ) { detail(key) } + } + } + val origin = from + if (origin == null) body() + else { + val painter = art?.let { url -> + coil.compose.rememberAsyncImagePainter( + remember(url) { + coil.request.ImageRequest.Builder(ctx).data(url).size(1280, 720) + .placeholderMemoryCacheKey(url).build() + }, + ) + } + PageFlood(origin, leaving, painter, onto = { StoresMotion.heroAt }, ontoCorner = 16.dp) { body() } + } + } + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt index db6850226..b70f9bb5e 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt @@ -254,7 +254,7 @@ private fun Content( return } if (selected == "games" || selected.startsWith("app:")) { - GamesPage(s, a, selected, onSelect, modifier) + LibraryPage(s, a, selected, onSelect, modifier) return } if (selected == "desktop") { diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt index 67d228397..efc00297c 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt @@ -5,7 +5,6 @@ import com.droiddeck.launcher.R import androidx.compose.ui.res.stringResource import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.hoverable import androidx.compose.foundation.interaction.MutableInteractionSource @@ -13,160 +12,39 @@ import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.runtime.key -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.BoxScope -import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Delete -import androidx.compose.material.icons.outlined.Edit import androidx.compose.ui.semantics.semantics import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import kotlinx.coroutines.flow.first -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.blur import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import coil.compose.AsyncImage import com.droiddeck.launcher.frontend.Library -import com.droiddeck.launcher.stores.epic.EpicClient import kotlinx.coroutines.launch +import androidx.compose.runtime.setValue +import androidx.compose.runtime.getValue @Composable -internal fun GamesPage(s: FrontEndState, a: FrontEndActions, selected: String, onSelect: (String) -> Unit, modifier: Modifier) { - val pageContext = androidx.compose.ui.platform.LocalContext.current - LaunchedEffect(Unit) { EpicClient.refresh(pageContext) } - val launcherName = stringResource(R.string.epic_client_title) - val epic = EpicClient.status - val launcherArt by androidx.compose.runtime.produceState(null, epic.version) { - value = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { EpicClient.icon(pageContext, epic.version) } - } - LaunchedEffect(Unit) { com.droiddeck.launcher.stores.xbox.XboxCloudGames.load(pageContext) } - val cloudGames = com.droiddeck.launcher.stores.xbox.XboxCloudGames.entries.toList() - val games = remember(s.steamGames, s.shortcutPicker, epic, launcherName, launcherArt, cloudGames) { - val sorted = s.steamGames.sortedByDescending { it.lastPlayed } - if (s.shortcutPicker) sorted else { - val installedXbox = s.steamGames.filter { it.source == com.droiddeck.launcher.stores.Store.XBOX.id }.mapNotNull { it.storeId }.toSet() - EpicClient.withLauncher(sorted, EpicClient.launcherEntry(pageContext, launcherName, epic, launcherArt)) + - com.droiddeck.launcher.stores.xbox.XboxCloudGames.rows(pageContext, installedXbox) - } - } - val gameCount = games.count { it.library != Library.EPIC_LAUNCHER } - val narrow = LocalNarrowPane.current - val current = games.firstOrNull { "app:${it.appId}" == selected } ?: games.firstOrNull() - GamesDialogs(a, s.steamGames) - val onAdd: (() -> Unit)? = if (s.shortcutPicker) null else ({ GamesDialogState.adding = true }) - val launch: (Library.SteamGame) -> Unit = { g -> - when (g.library) { - Library.EPIC_LAUNCHER -> a.onEpicLauncher() - Library.XBOX_CLOUD -> com.droiddeck.launcher.stores.xbox.XboxCloudGames.stream(pageContext, g.storeId.orEmpty(), g.name) - else -> a.onSteamGame(g) - } - } - val onEdit: (Library.SteamGame) -> Unit = { g -> - g.gameFiles?.let { GamesDialogState.editing = EditRequest(it.path, openTarget = false, fromSummary = false, preview = EditPreview.of(pageContext, g, it.path)) } - } - if (current == null || gameCount == 0) { - Column(modifier = modifier.padding(horizontal = if (narrow) 16.dp else 22.dp, vertical = if (narrow) 12.dp else 18.dp)) { - Rise(0) { - PageHeader(stringResource(R.string.content_games)) { - if (s.steamGamesX64) ClientTag() - onAdd?.let { AddGameButton(it) } - } - } - Rise(1) { Note(stringResource(if (s.shortcutPicker && s.shortcutLibraryScanning) R.string.game_shortcut_scanning else R.string.games_empty)) } - if (!s.shortcutPicker) Rise(2) { - Actions { - PrimaryButton(stringResource(R.string.games_play_steam), enabled = !s.busy, main = true, icon = Icons.Filled.PlayArrow, modifier = Modifier.padding(top = 12.dp).testTag("play-steam"), onClick = a.onPlay) - games.firstOrNull { it.library == Library.EPIC_LAUNCHER }?.let { SecondaryButton(it.name, enabled = !s.busy, modifier = Modifier.padding(top = 12.dp)) { a.onEpicLauncher() } } - } - } - GameFileFolderActions(s, a) - } - return - } - val host = rememberMenuHost() - if (games.size == 1) { - Column(modifier = modifier.verticalScroll(rememberScrollState()).padding(horizontal = if (narrow) 16.dp else 22.dp, vertical = if (narrow) 12.dp else 18.dp)) { - if (onAdd != null) Rise(0) { GamesHeader(gameCount, s.steamGamesX64, onAdd, Modifier.padding(bottom = 10.dp)) } - Rise(0) { - Row(verticalAlignment = Alignment.Bottom) { - GameHero(current, Modifier.weight(1f).heightIn(min = if (narrow) 190.dp else 250.dp), corner = { GameRemoveButton(current, s, a) }) { - GameHeroCopy(current, if (narrow) 28.sp else 38.sp) - GameActions(current, s, a, onEdit, launch) - } - if (!narrow) Poster(current.art, current.name, Modifier.width(168.dp)) - } - } - if (current.library != Library.XBOX_CLOUD) { - Rise(1) { SectionTitle(stringResource(R.string.games_launch_settings), null) } - Rise(2) { LaunchSettings(s, a, host, current) } - } - Rise(3) { GameFileFolderActions(s, a) } - } - return - } - val pal = LocalPalette.current - Row(modifier = modifier) { - GameList(games, gameCount, s.steamGamesX64, current, onSelect = { onSelect("app:${it.appId}") }, onLaunch = launch, onAdd = onAdd, - modifier = Modifier.width(if (narrow) 168.dp else 250.dp).fillMaxHeight()) - Box(Modifier.width(1.dp).fillMaxHeight().background(pal.line)) - Column( - modifier = Modifier.weight(1f).fillMaxHeight().verticalScroll(rememberScrollState()) - .padding(horizontal = if (narrow) 14.dp else 20.dp, vertical = 16.dp), - ) { - GameHero(current, Modifier.fillMaxWidth().heightIn(min = if (narrow) 170.dp else 200.dp), corner = { GameRemoveButton(current, s, a) }) { - GameHeroCopy(current, if (narrow) 24.sp else 32.sp) - GameActions(current, s, a, onEdit, launch) - } - if (current.library != Library.XBOX_CLOUD) { - SectionTitle(stringResource(R.string.games_launch_settings), null) - LaunchSettings(s, a, host, current) - } - GameFileFolderActions(s, a) - } - } -} - -@Composable -private fun GameFileFolderActions(s: FrontEndState, a: FrontEndActions) { +internal fun GameFileFolderActions(s: FrontEndState, a: FrontEndActions) { if (s.shortcutPicker) return var open by remember { androidx.compose.runtime.mutableStateOf(false) } - Box(Modifier.padding(top = 12.dp)) { + Box { SecondaryButton(stringResource(R.string.game_frontend_files), compact = true) { open = !open } AnchoredMenu(open, onDismiss = { open = false }, title = s.gameSyncFolder ?: stringResource(R.string.game_frontend_files)) { first -> MenuItem(stringResource(R.string.game_file_sync), checked = false, focusRequester = first) { @@ -181,48 +59,7 @@ private fun GameFileFolderActions(s: FrontEndState, a: FrontEndActions) { @Composable -private fun GameActions(g: Library.SteamGame, s: FrontEndState, a: FrontEndActions, onEdit: (Library.SteamGame) -> Unit, launch: (Library.SteamGame) -> Unit) { - val context = androidx.compose.ui.platform.LocalContext.current - if (g.library == Library.XBOX_CLOUD) { - androidx.compose.runtime.LaunchedEffect(g.gameId) { com.droiddeck.launcher.stores.xbox.XboxCloudGames.prefetch(context) } - Actions { - PrimaryButton("Stream", enabled = !s.busy, main = true, icon = Icons.Filled.PlayArrow) { launch(g) } - SecondaryButton("Download") { com.droiddeck.launcher.stores.StoresState.openGameRequest = "${com.droiddeck.launcher.stores.Store.XBOX.id}:${g.storeId}" } - SecondaryButton("Remove from Games", compact = true) { com.droiddeck.launcher.stores.xbox.XboxCloudGames.remove(context, g.storeId.orEmpty()) } - BusyChip(s) - } - return - } - val xboxStream by androidx.compose.runtime.produceState(false, g.gameId) { - value = g.source == com.droiddeck.launcher.stores.Store.XBOX.id && !g.storeId.isNullOrEmpty() && !s.shortcutPicker && - kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { com.droiddeck.launcher.stores.xbox.XboxCloudGames.streams(context, g.storeId!!) } - } - androidx.compose.runtime.LaunchedEffect(xboxStream) { if (xboxStream) com.droiddeck.launcher.stores.xbox.XboxCloudGames.prefetch(context) } - Actions { - PrimaryButton(stringResource(if (s.shortcutPicker) R.string.game_shortcut_choose else R.string.games_launch), enabled = !s.busy, main = true, icon = Icons.Filled.PlayArrow) { launch(g) } - if (xboxStream) SecondaryButton("Stream") { com.droiddeck.launcher.stores.xbox.XboxCloudGames.stream(context, g.storeId.orEmpty(), g.name) } - if (g.library == Library.EPIC_LAUNCHER) { - if (!s.shortcutPicker) UninstallEpicLauncher(s, a) - BusyChip(s) - return@Actions - } - g.gameFiles?.takeIf { it.isDirectory }?.let { dir -> - SecondaryButton(stringResource(R.string.games_files), compact = true) { a.onBrowseFiles(dir) } - } - g.protonPrefix?.takeIf { it.isDirectory }?.let { dir -> - SecondaryButton(stringResource(R.string.games_prefix), compact = true) { a.onBrowseFiles(dir) } - if (g.library == Library.ADDED) ManageSaves(g, dir, a) - } - if (!s.shortcutPicker) GameShortcutMenu(g, a) - if (!s.shortcutPicker && g.library == Library.ADDED && g.source == Library.ADDED && g.gameFiles != null) EditGameButton { onEdit(g) } - else if (!s.shortcutPicker && g.library != Library.ADDED && g.source == Library.SOURCE_STEAM) EditGameButton { GamesDialogState.steamArt = g } - else if (!s.shortcutPicker && g.library == Library.ADDED && g.source != Library.ADDED && g.gameFiles != null) EditGameButton { GamesDialogState.storeArt = g } - BusyChip(s) - } -} - -@Composable -private fun UninstallEpicLauncher(s: FrontEndState, a: FrontEndActions) { +internal fun UninstallEpicLauncher(s: FrontEndState, a: FrontEndActions) { var asking by remember { androidx.compose.runtime.mutableStateOf(false) } SecondaryButton(stringResource(R.string.common_uninstall), enabled = !s.busy, compact = true) { asking = true } if (asking) { @@ -237,71 +74,6 @@ private fun UninstallEpicLauncher(s: FrontEndState, a: FrontEndActions) { } } -@Composable -private fun GameShortcutMenu(g: Library.SteamGame, a: FrontEndActions) { - var open by remember(g.gameId) { androidx.compose.runtime.mutableStateOf(false) } - Box { - SecondaryButton(stringResource(R.string.game_shortcut), compact = true) { open = !open } - AnchoredMenu(open, onDismiss = { open = false }, title = stringResource(R.string.game_shortcut)) { first -> - MenuItem(stringResource(R.string.game_shortcut_add), checked = false, focusRequester = first) { - open = false; a.onGameShortcut(g) - } - MenuItem(stringResource(R.string.game_file_export), checked = false) { - open = false; a.onExportGameFile(g) - } - MenuItem(stringResource(R.string.game_link_copy), checked = false) { - open = false; a.onCopyGameLink(g) - } - } - } -} - -@Composable -private fun ManageSaves(g: Library.SteamGame, prefix: java.io.File, a: FrontEndActions) { - var open by remember(g.gameId) { androidx.compose.runtime.mutableStateOf(false) } - val saves by androidx.compose.runtime.produceState?>(null, g.gameId, open) { - if (open) value = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { - runCatching { com.droiddeck.launcher.session.GameSaves.locate(prefix, g) }.getOrDefault(emptyList()) - } - } - val found = saves - val summary = when { - found == null -> stringResource(R.string.games_saves_looking) - found.isEmpty() -> stringResource(R.string.games_saves_none) - else -> { - val mb = found.sumOf { it.bytes } / 1048576.0 - stringResource( - R.string.games_saves_summary, - if (found.size == 1) found[0].relPath else stringResource(R.string.games_saves_folders, found.size), - found.sumOf { it.files }, - if (mb < 1) stringResource(R.string.games_kb, (mb * 1024).toInt()) else stringResource(R.string.common_size_mb, mb), - ) - } - } - Box { - val cloudGame = com.droiddeck.launcher.stores.Store.byId(g.source).let { it == com.droiddeck.launcher.stores.Store.GOG || it == com.droiddeck.launcher.stores.Store.EPIC } - val label = stringResource(if (cloudGame) R.string.store_cloud_saves else R.string.games_manage_saves) - SecondaryButton(label, compact = true) { open = !open } - AnchoredMenu(open, onDismiss = { open = false }, title = if (cloudGame) label else stringResource(R.string.games_saves), note = summary) { first -> - if (cloudGame) CloudRows(g, open, first) - MenuItem(stringResource(R.string.games_import_saves), checked = false, focusRequester = if (cloudGame) null else first) { - open = false; a.onSaveImport(g) - } - MenuItem(stringResource(R.string.games_export_gamehub), checked = false) { - open = false; a.onSaveExport(g, com.droiddeck.launcher.session.GameSaves.Layout.GAMEHUB) - } - MenuItem(stringResource(R.string.games_export_winlator), checked = false) { - open = false; a.onSaveExport(g, com.droiddeck.launcher.session.GameSaves.Layout.WINLATOR) - } - found?.firstOrNull()?.let { d -> - MenuItem(stringResource(R.string.games_open_save_folder), checked = false, detail = d.relPath) { - open = false; a.onBrowseFiles(java.io.File(prefix, "drive_c/users/steamuser/" + d.relPath)) - } - } - } - } -} - /** A skipped sync is not a success: "0 files" used to show for a signed-out store or cloud saves turned off. */ internal fun cloudMessage(context: android.content.Context, r: com.droiddeck.launcher.stores.CloudSaves.Result): String = when (r.result) { "ok" -> when { @@ -318,125 +90,7 @@ internal fun cloudMessage(context: android.content.Context, r: com.droiddeck.lau } @Composable -private fun CloudRows(g: Library.SteamGame, open: Boolean, first: androidx.compose.ui.focus.FocusRequester) { - val store = com.droiddeck.launcher.stores.Store.byId(g.source)?.takeIf { it != com.droiddeck.launcher.stores.Store.AMAZON } ?: return - val id = g.storeId ?: return - val context = androidx.compose.ui.platform.LocalContext.current.applicationContext - var busy by remember(g.gameId) { androidx.compose.runtime.mutableStateOf(null) } - var tick by remember(g.gameId) { androidx.compose.runtime.mutableStateOf(0) } - val status by androidx.compose.runtime.produceState(null, g.gameId, open, tick) { - if (open) value = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { - runCatching { com.droiddeck.launcher.stores.CloudSaves.status(context, store, id) }.getOrNull() - } - } - val folder = g.gameFiles - var enabled by remember(g.gameId, open) { androidx.compose.runtime.mutableStateOf(folder?.let { com.droiddeck.launcher.stores.StoreGameSidecar.read(it)?.cloud } ?: true) } - val st = status - val supported = st?.supported ?: true - val conflict = st != null && st.conflicts.isNotEmpty() - fun sync(up: Boolean, force: Boolean = false) { - if (busy != null) return - busy = if (up) "up" else "down" - Thread({ - val r = if (up) com.droiddeck.launcher.stores.CloudSaves.upload(context, store, id, force) else com.droiddeck.launcher.stores.CloudSaves.download(context, store, id, force) - com.droiddeck.launcher.stores.StoresState.post { busy = null; tick++; android.widget.Toast.makeText(context, cloudMessage(context, r), android.widget.Toast.LENGTH_SHORT).show() } - }, "cloud-manual").start() - } - MenuItem(stringResource(R.string.store_cloud_saves), checked = enabled && supported, enabled = supported, focusRequester = first, - detail = if (!supported) stringResource(R.string.cloud_none) else null) { - val f = folder ?: return@MenuItem - val next = !enabled - Thread({ - val written = runCatching { com.droiddeck.launcher.stores.StoreGameSidecar.updateCloud(f, next) }.getOrNull() - com.droiddeck.launcher.stores.StoresState.post { enabled = written?.cloud ?: enabled } - }, "cloud-switch").start() - } - val ready = st != null && supported && enabled && busy == null - val pending = stringResource(R.string.cloud_pending) - val summary = when { - st == null || busy != null -> pending - !supported -> stringResource(R.string.cloud_none) - conflict -> stringResource(R.string.cloud_conflict_files, st.conflicts.size) - st.lastSync > 0 -> stringResource(R.string.cloud_last_sync, android.text.format.DateUtils.getRelativeTimeSpanString(st.lastSync).toString()) - else -> stringResource(R.string.cloud_never) - } - MenuItem(stringResource(if (conflict) R.string.cloud_keep_local else R.string.cloud_upload), checked = false, enabled = ready, detail = summary) { sync(up = true, force = conflict) } - MenuItem(stringResource(if (conflict) R.string.cloud_keep_cloud else R.string.cloud_download), checked = false, enabled = ready, detail = if (st == null || busy != null) pending else null) { sync(up = false, force = conflict) } -} - -@Composable -private fun ColumnScope.GameHeroCopy(g: Library.SteamGame, titleSize: androidx.compose.ui.unit.TextUnit) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - SourceChip(g.source) - val eyebrow = lastPlayedText(g.lastPlayed) ?: libraryLabel(g) - if (eyebrow.isNotEmpty()) Text( - eyebrow.uppercase(), - fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.2.sp, color = LocalPalette.current.signal, - maxLines = 1, overflow = TextOverflow.Ellipsis, - ) - } - GameTitle(g, titleSize) - Spacer(Modifier.height(8.dp)) -} - -@Composable -private fun GameTitle(g: Library.SteamGame, titleSize: androidx.compose.ui.unit.TextUnit) { - val logo = g.logo - var failed by remember(logo) { androidx.compose.runtime.mutableStateOf(false) } - if (logo != null && !failed) { - AsyncImage( - model = logo, contentDescription = g.name, contentScale = ContentScale.Fit, alignment = Alignment.CenterStart, - onError = { failed = true }, - modifier = Modifier.padding(vertical = 4.dp).height((titleSize.value * 2.2f).dp).widthIn(max = (titleSize.value * 9f).dp).testTag("game-logo"), - ) - } else Text( - g.name, fontSize = titleSize, lineHeight = titleSize * 1.15f, fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onBackground, maxLines = 2, overflow = TextOverflow.Ellipsis, - ) -} - -@Composable -private fun GameList( - games: List, count: Int, x64: Boolean, current: Library.SteamGame, - onSelect: (Library.SteamGame) -> Unit, onLaunch: (Library.SteamGame) -> Unit, onAdd: (() -> Unit)?, modifier: Modifier, -) { - val colors = MaterialTheme.colorScheme - Column( - verticalArrangement = Arrangement.spacedBy(2.dp), - modifier = modifier.verticalScroll(rememberScrollState()).padding(start = 12.dp, end = 10.dp, top = 16.dp, bottom = 16.dp), - ) { - GamesHeader(count, x64, onAdd, Modifier.padding(start = 6.dp, bottom = 10.dp)) - val seen = remember { HashSet() } - var settled by remember { androidx.compose.runtime.mutableStateOf(false) } - for (g in games) key(g.appId) { - Box(Modifier.arriveIn(settled && g.appId !in seen)) { - GameRow(g, g.appId == current.appId, onSelect = { onSelect(g) }, onLaunch = { onLaunch(g) }) - } - } - androidx.compose.runtime.SideEffect { - seen.addAll(games.map { it.appId }) - if (games.isNotEmpty()) settled = true - } - } -} - -@Composable -private fun GamesHeader(count: Int, x64: Boolean, onAdd: (() -> Unit)?, modifier: Modifier) { - val colors = MaterialTheme.colorScheme - val narrow = LocalNarrowPane.current - Column(modifier) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Text(stringResource(R.string.content_games), fontSize = 22.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, maxLines = 1) - Text(count.toString(), fontSize = 13.sp, color = colors.onSurfaceVariant) - if (x64 && !narrow) ClientTag() - if (onAdd != null) AddGameButton(onAdd) - } - if (x64 && narrow) Box(Modifier.padding(top = 4.dp)) { ClientTag() } - } -} - -@Composable -private fun ClientTag() { +internal fun ClientTag() { Box(Modifier.testTag("games-client-x64")) { StateChip(stringResource(R.string.mode_client_arch_x64), ChipTone.PLAIN) } } @@ -451,7 +105,7 @@ internal object GamesDialogState { } @Composable -private fun GamesDialogs(a: FrontEndActions, games: List) { +internal fun GamesDialogs(a: FrontEndActions, games: List) { val ctx = androidx.compose.ui.platform.LocalContext.current val scope = androidx.compose.runtime.rememberCoroutineScope() val st = GamesDialogState @@ -505,68 +159,7 @@ private fun GamesDialogs(a: FrontEndActions, games: List) { } @Composable -private fun GameRemoveButton(g: Library.SteamGame, s: FrontEndState, a: FrontEndActions) { - val kind = com.droiddeck.launcher.frontend.GameRemoval.kind(g) - if (s.shortcutPicker || kind == com.droiddeck.launcher.frontend.GameRemoval.Kind.LAUNCHER) return - if (g.gameFiles?.isDirectory != true) return - val colors = MaterialTheme.colorScheme - val pal = LocalPalette.current - val src = remember { MutableInteractionSource() } - val hot = rememberHot(src) - var asking by remember(g.gameId) { androidx.compose.runtime.mutableStateOf(false) } - val shortcut = kind == com.droiddeck.launcher.frontend.GameRemoval.Kind.SHORTCUT - val label = stringResource(if (shortcut) R.string.edit_remove else R.string.common_uninstall) - val title = if (shortcut) stringResource(R.string.games_remove_title, g.name) else stringResource(R.string.stores_uninstall_title, g.name) - val body = when { - shortcut -> stringResource(R.string.games_remove_body) - g.library == Library.ADDED -> stringResource(R.string.stores_uninstall_body) - else -> stringResource(R.string.games_uninstall_body) - } - Box( - contentAlignment = Alignment.Center, - modifier = Modifier.paneItem("game:remove").downToFirstTile().size(44.dp).clip(Shape12) - .background(if (hot) colors.error.copy(alpha = 0.22f) else Color.Black.copy(alpha = 0.34f)) - .glideBorder(hot, Shape12, colors.error, pal.line2) - .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = androidx.compose.ui.semantics.Role.Button) { asking = true } - .controllerConfirm { asking = true } - .semantics { contentDescription = label }, - ) { - androidx.compose.material3.Icon( - Icons.Outlined.Delete, contentDescription = null, - tint = if (hot) colors.error else Color.White, modifier = Modifier.size(18.dp), - ) - } - if (asking) ConfirmDialog( - title = title, - text = body, - confirm = label, - onConfirm = { a.onGameRemoved(g) }, - onDismiss = { asking = false }, - ) -} - -@Composable -private fun EditGameButton(onClick: () -> Unit) { - val colors = MaterialTheme.colorScheme - val pal = LocalPalette.current - val src = remember { MutableInteractionSource() } - val hot = rememberHot(src) - val label = stringResource(R.string.common_edit) - Box( - contentAlignment = Alignment.Center, - modifier = Modifier.paneItem("game:edit").downToFirstTile().size(44.dp).clip(Shape12) - .background(if (hot) pal.signal.copy(alpha = 0.14f) else Color.White.copy(alpha = 0.03f)) - .glideBorder(hot, Shape12, pal.signal, pal.line2) - .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = androidx.compose.ui.semantics.Role.Button, onClick = onClick) - .controllerConfirm(onClick = onClick) - .semantics { contentDescription = label }, - ) { - androidx.compose.material3.Icon(Icons.Outlined.Edit, contentDescription = null, tint = colors.onBackground, modifier = Modifier.size(18.dp)) - } -} - -@Composable -private fun AddGameButton(onClick: () -> Unit) { +internal fun AddGameButton(onClick: () -> Unit) { val colors = MaterialTheme.colorScheme val pal = LocalPalette.current val src = remember { MutableInteractionSource() } @@ -586,48 +179,6 @@ private fun AddGameButton(onClick: () -> Unit) { } } -@Composable -internal fun Modifier.arriveIn(arriving: Boolean): Modifier { - val t = remember { androidx.compose.animation.core.Animatable(if (arriving) 0f else 1f) } - LaunchedEffect(Unit) { if (t.value < 1f) t.animateTo(1f, Motion.tw(280)) } - return graphicsLayer { alpha = t.value; translationY = (1f - t.value) * 12.dp.toPx() } -} - -@Composable -private fun GameRow(g: Library.SteamGame, selected: Boolean, onSelect: () -> Unit, onLaunch: () -> Unit) { - val colors = MaterialTheme.colorScheme - val pal = LocalPalette.current - val src = remember { MutableInteractionSource() } - val focused by src.collectIsFocusedAsState() - val hovered by src.collectIsHoveredAsState() - LaunchedEffect(focused) { if (focused && !selected) onSelect() } - Row( - verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.fillMaxWidth().paneItem("game:${g.appId}") - .clip(Shape12) - .background(if (selected) pal.signal.copy(alpha = 0.14f) else if (hovered) Color.White.copy(alpha = 0.05f) else Color.Transparent) - .glideBorder(focused, Shape12, pal.signal) - .hoverable(src).clickable(role = androidx.compose.ui.semantics.Role.Button, interactionSource = src, indication = LocalIndication.current, onClick = onSelect) - .controllerConfirm(onClick = onLaunch) - .padding(horizontal = 10.dp, vertical = 6.dp), - ) { - Box(Modifier.width(30.dp).height(45.dp).clip(RoundedCornerShape(5.dp)).background(artBrush(hueOf(g.name)))) { - if (g.art != null) AsyncImage(model = g.art, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.matchParentSize()) - } - Column(modifier = Modifier.weight(1f)) { - Text(g.name, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis) - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { - SourceChip(g.source, small = true) - Text( - if (g.library == Library.EPIC_LAUNCHER) libraryLabel(g) - else playedSpan(g.lastPlayed)?.replaceFirstChar { it.uppercase() } ?: stringResource(R.string.games_never_played), - fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, - ) - } - } - } -} - @Composable internal fun PageHeader(title: String, trailing: @Composable RowScope.() -> Unit = {}) { Row( @@ -656,11 +207,7 @@ internal fun RuntimeChip(s: FrontEndState) = when { else -> Chip(stringResource(R.string.games_runtime_ready), ok = true) } -@Composable -private fun lastPlayedText(lastPlayed: Long): String? = - playedSpan(lastPlayed)?.let { stringResource(R.string.games_last_played, it.replaceFirstChar { c -> c.lowercase() }) } - -private fun playedSpan(lastPlayed: Long): String? { +internal fun playedSpan(lastPlayed: Long): String? { if (lastPlayed <= 0L) return null return android.text.format.DateUtils.getRelativeTimeSpanString( lastPlayed * 1000L, System.currentTimeMillis(), android.text.format.DateUtils.MINUTE_IN_MILLIS, @@ -668,38 +215,9 @@ private fun playedSpan(lastPlayed: Long): String? { } @Composable -private fun libraryLabel(g: Library.SteamGame): String = when { - g.library == Library.EPIC_LAUNCHER -> EpicClient.status.version?.substringBefore('+')?.let { stringResource(R.string.epic_client_version, it) } ?: "" - g.library == Library.XBOX_CLOUD -> "Xbox Cloud Gaming" +internal fun libraryLabel(g: Library.SteamGame): String = when { + g.library == Library.XBOX_CLOUD -> stringResource(R.string.xbox_cloud_gaming) g.library != Library.ADDED -> if (g.library == "internal") stringResource(R.string.games_internal) else g.library g.source != Library.ADDED -> stringResource(R.string.games_store_storage) else -> "" } - -@Composable -private fun GameHero(g: Library.SteamGame, modifier: Modifier, corner: @Composable BoxScope.() -> Unit = {}, content: @Composable ColumnScope.() -> Unit) { - val colors = MaterialTheme.colorScheme - Box(modifier = modifier.clip(Shape16).background(artBrush(hueOf(g.name)))) { - val image = g.hero ?: g.art.takeIf { g.library != Library.EPIC_LAUNCHER } - if (image != null) AsyncImage( - model = image, contentDescription = null, contentScale = ContentScale.Crop, - modifier = Modifier.matchParentSize() - .then(if (g.hero == null) Modifier.blur(24.dp).graphicsLayer { scaleX = 1.3f; scaleY = 1.3f } else Modifier), - ) - Spacer( - Modifier.matchParentSize().background( - Brush.horizontalGradient( - 0f to colors.background.copy(alpha = 0.92f), - 0.55f to colors.background.copy(alpha = 0.6f), - 1f to Color.Transparent, - ), - ), - ) - Column( - verticalArrangement = Arrangement.spacedBy(4.dp), - modifier = Modifier.align(Alignment.BottomStart).padding(horizontal = 22.dp, vertical = 18.dp), - content = content, - ) - Box(Modifier.align(Alignment.TopEnd).padding(12.dp), content = corner) - } -} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt index 1294afa9f..b8ac10ba1 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt @@ -141,7 +141,6 @@ class FrontEndState( val launcherIcon: String = com.droiddeck.launcher.LauncherIcon.DARK, val gameStoresEnabled: Boolean = true, val gameStoresSpeedTier: String = "fast", - val storesOpenTab: String = SessionPrefs.STORES_OPEN_LIBRARY, val navigate: String? = null, val addedGamesArt: Boolean = true, val sgdbUserKey: Boolean = false, @@ -217,7 +216,6 @@ class FrontEndActions( val onDownloadPrefix: (String) -> Unit = {}, val onGameStoresEnabled: (Boolean) -> Unit = {}, val onGameStoresSpeedTier: (String) -> Unit = {}, - val onStoresOpenTab: (String) -> Unit = {}, val onNavigated: () -> Unit = {}, val onStoresShowMature: (Boolean) -> Unit = {}, val onStoresSort: (String) -> Unit = {}, @@ -440,7 +438,7 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com } LaunchedEffect(s.isHomeApp) { if (!s.isHomeApp && selected == "android-apps") selected = "steam" } LaunchedEffect(s.steamGames.isEmpty()) { - if (s.steamGames.isEmpty() && selected.startsWith("app:")) selected = "games" + if (s.steamGames.isEmpty() && selected.startsWith("app:") && GameRef.parse(selected).appId != null) selected = "games" } remember { Motion.refresh(ctx); true } @@ -553,6 +551,8 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com } FlightsLayer(screenAt) + GamesDialogs(a, s.steamGames) + com.droiddeck.launcher.stores.StoresState.pendingInstall?.let { item -> StoreTheme(item.store) { InstallWhere(item) } } StepsLayer(screenAt) appToChooseDisplay?.let { app -> diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt index c2582837f..8f92dbbc6 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt @@ -508,7 +508,7 @@ private fun FrameGenMenu(s: FrontEndState, a: FrontEndActions, host: MenuHost) { } @Composable -internal fun LaunchSettings(s: FrontEndState, a: FrontEndActions, host: MenuHost, game: com.droiddeck.launcher.frontend.Library.SteamGame? = null) { +internal fun LaunchSettings(s: FrontEndState, a: FrontEndActions, host: MenuHost, game: com.droiddeck.launcher.frontend.Library.SteamGame? = null, hopRail: String = "games") { val columns = if (LocalNarrowPane.current) 2 else 3 val controller = a.controller val context = androidx.compose.ui.platform.LocalContext.current @@ -527,7 +527,7 @@ internal fun LaunchSettings(s: FrontEndState, a: FrontEndActions, host: MenuHost val at = Hops.sources["card:components"] val open = { if (game == null) a.onComponents(true) else a.onGameComponents(game) } if (at == null) open() - else Hops.go(HopOrigin("games", HOP_SURFACE_GAMES, null, "card:components", at, back, dest = "components", page = "components"), open) + else Hops.go(HopOrigin(hopRail, HOP_SURFACE_GAMES, null, "card:components", at, back, dest = "components", page = "components"), open) } } add { m -> @@ -570,18 +570,11 @@ internal fun LaunchSettings(s: FrontEndState, a: FrontEndActions, host: MenuHost } } -@Composable -private fun GameSettingsCard(host: MenuHost, game: com.droiddeck.launcher.frontend.Library.SteamGame, scope: String) { - val appContext = androidx.compose.ui.platform.LocalContext.current.applicationContext - val colors = MaterialTheme.colorScheme - var config by remember(scope) { mutableStateOf(runCatching { com.droiddeck.launcher.session.GameEnvironmentStore.read(appContext) }.getOrNull()) } - var saving by remember(scope) { mutableStateOf(false) } - val current = config - val noSteam = current != null && com.droiddeck.launcher.core.GameEnvironment.gameNoSteam(current, scope) - val fex = current?.let { runCatching { com.droiddeck.launcher.core.GameEnvironment.gameFexPreset(it, scope) }.getOrNull() } - val sync = current?.let { runCatching { com.droiddeck.launcher.core.GameEnvironment.gameSync(it, scope) }.getOrNull() } - val ready = current != null && !saving - val launcher = game.library == com.droiddeck.launcher.frontend.Library.EPIC_LAUNCHER +internal class GameSettingsState(private val appContext: android.content.Context, val scope: String) { + var config by mutableStateOf(runCatching { com.droiddeck.launcher.session.GameEnvironmentStore.read(appContext) }.getOrNull()) + var saving by mutableStateOf(false) + val noSteam: Boolean get() = config?.let { com.droiddeck.launcher.core.GameEnvironment.gameNoSteam(it, scope) } == true + fun save(change: (android.content.Context) -> com.droiddeck.launcher.core.GameEnvironment.Config?) { if (saving) return saving = true @@ -595,7 +588,37 @@ private fun GameSettingsCard(host: MenuHost, game: com.droiddeck.launcher.fronte } }, "game-settings").start() } - val key = "game-settings" +} + +@Composable +internal fun rememberGameSettings(scope: String): GameSettingsState { + val appContext = androidx.compose.ui.platform.LocalContext.current.applicationContext + return remember(scope) { GameSettingsState(appContext, scope) } +} + +@Composable +private fun GameSettingsCard(host: MenuHost, game: com.droiddeck.launcher.frontend.Library.SteamGame, scope: String) { + val state = rememberGameSettings(scope) + SettingCard(stringResource(R.string.game_settings_card), stringResource(if (state.noSteam) R.string.game_steam_without else R.string.game_steam_with), "card:$GAME_SETTINGS_MENU", Modifier.fillMaxSize()) { + host.open = if (host.open == GAME_SETTINGS_MENU) null else GAME_SETTINGS_MENU + } + GameSettingsMenus(host, game, state) +} + +/** The per-game settings menus, opened by setting [MenuHost.open] to [GAME_SETTINGS_MENU]. */ +@Composable +internal fun GameSettingsMenus(host: MenuHost, game: com.droiddeck.launcher.frontend.Library.SteamGame, state: GameSettingsState) { + val appContext = androidx.compose.ui.platform.LocalContext.current.applicationContext + val colors = MaterialTheme.colorScheme + val scope = state.scope + val current = state.config + val noSteam = state.noSteam + val fex = current?.let { runCatching { com.droiddeck.launcher.core.GameEnvironment.gameFexPreset(it, scope) }.getOrNull() } + val sync = current?.let { runCatching { com.droiddeck.launcher.core.GameEnvironment.gameSync(it, scope) }.getOrNull() } + val ready = current != null && !state.saving + val launcher = game.library == com.droiddeck.launcher.frontend.Library.EPIC_LAUNCHER + fun save(change: (android.content.Context) -> com.droiddeck.launcher.core.GameEnvironment.Config?) = state.save(change) + val key = GAME_SETTINGS_MENU val fexKey = "game-settings-fex" val syncKey = "game-settings-sync" val syncLabels = mapOf( @@ -609,9 +632,6 @@ private fun GameSettingsCard(host: MenuHost, game: com.droiddeck.launcher.fronte val custom = stringResource(R.string.comp_game_fex_custom) val customSync = stringResource(R.string.game_sync_custom) val resetDone = stringResource(R.string.comp_game_reset_done, game.name) - SettingCard(stringResource(R.string.game_settings_card), stringResource(if (noSteam) R.string.game_steam_without else R.string.game_steam_with), "card:$key", Modifier.fillMaxSize()) { - host.open = if (host.open == key) null else key - } AnchoredMenu(host.open == key, onDismiss = { if (host.open == key) host.open = null }, title = stringResource(R.string.game_settings_menu_title, game.name)) { first -> MenuItem(stringResource(R.string.game_no_steam_title), checked = noSteam, enabled = ready, detail = stringResource(R.string.game_no_steam_detail), focusRequester = first) { @@ -686,6 +706,8 @@ private fun GameSettingsCard(host: MenuHost, game: com.droiddeck.launcher.fronte } } +internal const val GAME_SETTINGS_MENU = "game-settings" + private const val EPIC_RESOLVE_URL = "https://www.epicgames.com/id/login?redirectUrl=https%3A%2F%2Fwww.epicgames.com%2Faccount%2Fpersonal" @Composable diff --git a/app/src/main/java/com/droiddeck/launcher/ui/GameDetail.kt b/app/src/main/java/com/droiddeck/launcher/ui/GameDetail.kt new file mode 100644 index 000000000..50b85c7e3 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/GameDetail.kt @@ -0,0 +1,830 @@ +package com.droiddeck.launcher.ui + +import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.gestures.animateScrollBy +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.Edit +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil.compose.AsyncImage +import com.droiddeck.launcher.R +import com.droiddeck.launcher.frontend.GameRemoval +import com.droiddeck.launcher.frontend.Library +import com.droiddeck.launcher.session.GameSaves +import com.droiddeck.launcher.stores.CatalogItem +import com.droiddeck.launcher.stores.CloudSaves +import com.droiddeck.launcher.stores.InstalledStoreGame +import com.droiddeck.launcher.stores.Lookup +import com.droiddeck.launcher.stores.Store +import com.droiddeck.launcher.stores.StoreAchievements +import com.droiddeck.launcher.stores.StoreGameSidecar +import com.droiddeck.launcher.stores.StoreInstallRoot +import com.droiddeck.launcher.stores.StoreLanguage +import com.droiddeck.launcher.stores.StoreSizes +import com.droiddeck.launcher.stores.StoresState +import com.droiddeck.launcher.stores.download.DownloadQueue +import com.droiddeck.launcher.stores.download.DownloadState +import com.droiddeck.launcher.stores.formatBytes +import com.droiddeck.launcher.stores.xbox.XboxBackend +import com.droiddeck.launcher.stores.xbox.XboxCloudGames +import com.droiddeck.launcher.stores.xbox.XboxContent +import com.droiddeck.launcher.stores.xbox.xboxCloud +import com.droiddeck.launcher.stores.xbox.xboxPc +import com.droiddeck.launcher.stores.xbox.XboxRuntime +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File +import androidx.compose.runtime.setValue +import androidx.compose.runtime.getValue + +/** What a game page shows: `app:` for a game in the library, `[app:]:` for a store product. */ +internal class GameRef(val appId: Int?, val store: Store?, val storeId: String?) { + fun game(games: List): Library.SteamGame? = when { + appId != null -> games.firstOrNull { it.appId == appId } + store != null && storeId != null -> games.firstOrNull { it.source == store.id && it.storeId == storeId } + else -> null + } + + companion object { + fun parse(key: String): GameRef { + val rest = key.removePrefix("app:") + rest.toIntOrNull()?.let { return GameRef(it, null, null) } + val store = Store.byId(rest.substringBefore(':')) + return GameRef(null, store, rest.substringAfter(':', "").takeIf { store != null && it.isNotEmpty() }) + } + + fun key(game: Library.SteamGame) = "app:${game.appId}" + fun key(item: CatalogItem) = "app:${item.key}" + } +} + +internal enum class GameTab { ABOUT, ACHIEVEMENTS, LAUNCH, SAVES, INSTALL } + +@Composable +internal fun GameDetail( + key: String, s: FrontEndState, a: FrontEndActions, lead: FocusRequester, backLabel: String, hopRail: String?, onBack: () -> Unit, +) { + val narrow = LocalNarrowPane.current + val stacked = narrow || LocalConfiguration.current.screenWidthDp < 700 + val ref = remember(key) { GameRef.parse(key) } + val games = s.steamGames + rememberXboxCloudRows(s) + val found = ref.game(games) + val known = remember(key) { arrayOfNulls>(1) } + val store = ref.store ?: Store.byId(found?.source) ?: known[0]?.first + val id = ref.storeId ?: found?.storeId ?: known[0]?.second + SideEffect { if (store != null && id != null) known[0] = store to id } + val game = found ?: if (store != null && id != null) s.steamGames.firstOrNull { it.source == store.id && it.storeId == id } else null + val item = if (store != null && id != null) StoresState.detailItem(store, id) else null + val installed = if (store != null && id != null) StoresState.installedGame(store, id) else null + val rawDownload = if (store != null && id != null) StoresState.download("${store.id}:$id") else null + val download = rawDownload?.takeIf { it.isActive } + val failed = rawDownload?.takeIf { it.state == DownloadState.FAILED } + val nothing = game == null && store == null + LaunchedEffect(nothing) { if (nothing) onBack() } + if (nothing) return + val source = game?.source ?: store?.id ?: Library.SOURCE_STEAM + val title = game?.name ?: item?.title ?: installed?.sidecar?.title ?: id.orEmpty() + val scores = if (source != Library.ADDED) rememberGameScores(title.takeIf { it.isNotBlank() && it != id }) else null + val achievements = remember(store, id, item?.accessible, installed) { + when { + store == null || StoreAchievements.forSource(store.id) == null -> null + item?.accessible == true -> StoreAchievements.Game.of(item) + installed != null -> StoreAchievements.Game.of(installed.sidecar) + else -> null + } + } + val achievementState = rememberAchievements(achievements) + val listed = if (store == null) null else (StoresState.found[store].orEmpty() + StoresState.shelves[store]?.all.orEmpty()).firstOrNull { it.id == id } + val developer = item?.developer?.ifBlank { null } ?: listed?.developer.orEmpty() + val publisher = item?.publisher?.ifBlank { null } ?: listed?.publisher.orEmpty() + val releaseDate = item?.releaseDate?.ifBlank { null } ?: listed?.releaseDate.orEmpty() + val description = item?.let { com.droiddeck.launcher.stores.cleanStoreText(it.description) }.orEmpty() + .takeUnless { it.trim().equals(title.trim(), ignoreCase = true) }.orEmpty() + val scored = scores != null && (scores.metacritic != Lookup.None || scores.hltb != Lookup.None) + val prefix = game?.protonPrefix?.takeIf { it.isDirectory } + val tabs = buildList { + if (description.isNotBlank() || developer.isNotBlank() || publisher.isNotBlank() || releaseDate.isNotBlank() || (scored && stacked)) add(GameTab.ABOUT) + if (achievementState != null) add(GameTab.ACHIEVEMENTS) + val cloud = game?.library == Library.XBOX_CLOUD + if (game != null && !cloud) add(GameTab.LAUNCH) + if (game != null && game.library == Library.ADDED && prefix != null) add(GameTab.SAVES) + if ((game != null && !cloud) || installed != null) add(GameTab.INSTALL) + } + var picked by remember(key) { mutableStateOf(null) } + val tab = picked?.takeIf { it in tabs } ?: tabs.firstOrNull() + val tabFocus = remember(key) { GameTab.entries.associateWith { FocusRequester() } } + var inPanel by remember(key) { mutableStateOf(false) } + var inPage by remember(key) { mutableStateOf(false) } + var onStrip by remember(key) { mutableStateOf(false) } + var dropped by remember(key) { mutableStateOf(0) } + // A tab's content is swapped or refreshed under the focused row; focus goes back to the tabs, not to the rail. + LaunchedEffect(dropped) { + if (dropped == 0) return@LaunchedEffect + androidx.compose.runtime.withFrameNanos { } + val t = tab ?: return@LaunchedEffect + if (!inPage) focusWithinFrames({ onStrip }) { tabFocus.getValue(t) } + } + fun cycle(step: Int) { + if (tabs.size < 2) return + val at = tabs.indexOf(tab).coerceAtLeast(0) + picked = tabs[(at + step + tabs.size) % tabs.size] + } + Column(Modifier.fillMaxWidth().onFocusChanged { inPage = it.hasFocus }.bumpers(onPrevious = { cycle(-1) }, onNext = { cycle(1) })) { + Rise(0) { + Box(Modifier.padding(bottom = 12.dp)) { BackLink(backLabel, compact = true, onClick = onBack) } + } + val hero: @Composable () -> Unit = { + DisposableEffect(Unit) { onDispose { StoresMotion.heroAt = null } } + GameHero( + title, item, installed, game, + Modifier.fillMaxWidth().heightIn(min = if (narrow) 170.dp else 210.dp).onGloballyPositioned { StoresMotion.heroAt = it.boundsInRoot() }, + corner = { if (!s.shortcutPicker) RemoveButton(title, game, installed, a) }, + scores = scores.takeIf { !stacked }, + ) { + HeroEyebrow(source, game, item, installed, download?.location ?: failed?.location) + GameTitle(title, game?.logo ?: item?.logoUrl, if (narrow) 26.sp else 34.sp) + if (!failed?.error.isNullOrBlank()) Text( + failed!!.error!!, fontSize = 13.sp, lineHeight = 17.sp, fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.error, maxLines = 3, overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.height(4.dp)) + GameActions(store, id, game, item, installed, s, a, Modifier.focusRequester(lead)) + } + } + if (StoresMotion.heroLands) hero() else Rise(1) { hero() } + if (tab != null) Rise(2) { + Column(Modifier.fillMaxWidth().padding(top = 14.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + val labels = tabs.map { t -> + when (t) { + GameTab.ABOUT -> stringResource(R.string.game_tab_about) + GameTab.ACHIEVEMENTS -> achievementState?.list?.takeIf { it.isNotEmpty() }?.let { list -> + stringResource(R.string.game_tab_achievements_count, stringResource(R.string.achievements_title), achievementState.done, list.size) + } ?: stringResource(R.string.achievements_title) + GameTab.LAUNCH -> stringResource(R.string.game_tab_launch) + GameTab.SAVES -> stringResource(if (Store.byId(source).let { it == Store.GOG || it == Store.EPIC }) R.string.game_tab_saves_cloud else R.string.games_saves) + GameTab.INSTALL -> stringResource(R.string.game_tab_install) + } + } + TabStrip( + labels, tabs.indexOf(tab), { picked = tabs[it] }, Modifier.onFocusChanged { onStrip = it.hasFocus }, + focusRequesters = tabs.map { tabFocus.getValue(it) }, bumpers = tabs.size > 1, compact = narrow, + ) + Box(Modifier.fillMaxWidth().onFocusChanged { if (inPanel && !it.hasFocus) dropped++; inPanel = it.hasFocus }.testTag("game-tab-${tab.name.lowercase()}")) { + when (tab) { + GameTab.ABOUT -> AboutTab(developer, publisher, releaseDate, description, scores, stacked) + GameTab.ACHIEVEMENTS -> achievementState?.let { st -> AchievementsTab(StoreAchievements.forSource(store?.id)?.label.orEmpty(), st) } + GameTab.LAUNCH -> if (game != null) LaunchTab(game, s, a, hopRail) + GameTab.SAVES -> if (game != null && prefix != null) SavesTab(game, prefix, a) + GameTab.INSTALL -> InstallTab(game, installed, item, store) + } + } + } + } + } +} + +@Composable +private fun GameHero( + title: String, item: CatalogItem?, installed: InstalledStoreGame?, game: Library.SteamGame?, modifier: Modifier, + corner: @Composable BoxScope.() -> Unit, scores: GameScores?, content: @Composable ColumnScope.() -> Unit, +) { + val colors = MaterialTheme.colorScheme + Box(modifier.clip(Shape16).background(artBrush(hueOf(title)))) { + val wide: Any? = game?.hero ?: item?.imageUrl ?: installed?.sidecar?.hero ?: installed?.sidecar?.cover + if (wide != null) { + // The card it came from keeps its own bitmap under the hero until the wide art is decoded. + val request = if (wide is String) cardArtRequest(wide, item?.tallImageUrl) else wide + AsyncImage(model = request, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.matchParentSize()) + } else game?.art?.let { art -> + AsyncImage( + model = art, contentDescription = null, contentScale = ContentScale.Crop, + modifier = Modifier.matchParentSize().blur(24.dp).graphicsLayer { scaleX = 1.3f; scaleY = 1.3f }, + ) + } + Spacer(Modifier.matchParentSize().background(Brush.horizontalGradient(0f to colors.background.copy(alpha = 0.94f), 0.55f to colors.background.copy(alpha = 0.58f), 1f to Color.Transparent))) + if (scores != null) { + Spacer(Modifier.matchParentSize().background(Brush.horizontalGradient(0.45f to Color.Transparent, 1f to colors.background.copy(alpha = 0.75f)))) + HeroScores(scores, Modifier.align(Alignment.BottomEnd).padding(end = 16.dp, bottom = 18.dp)) + } + Column( + verticalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.align(Alignment.BottomStart).padding(horizontal = 22.dp, vertical = 18.dp), + content = content, + ) + Box(Modifier.align(Alignment.TopEnd).padding(12.dp), content = corner) + } +} + +@Composable +private fun HeroEyebrow(source: String, game: Library.SteamGame?, item: CatalogItem?, installed: InstalledStoreGame?, downloadAt: String?) { + val ctx = LocalContext.current + val pal = LocalPalette.current + val folder = installed?.folder ?: game?.gameFiles + val passName = item?.includedWith.orEmpty() + val state = when { + installed != null -> listOf(stringResource(R.string.stores_eyebrow_installed), StoreInstallRoot.labelFor(ctx, installed.folder)) + game?.library == Library.XBOX_CLOUD -> listOf(libraryLabel(game)) + game != null -> listOf(stringResource(R.string.stores_eyebrow_installed), libraryLabel(game)) + !downloadAt.isNullOrBlank() -> listOf(stringResource(R.string.stores_eyebrow_library), downloadAt) + item?.owned == true -> listOf(stringResource(R.string.stores_eyebrow_library)) + item?.subscription == true -> listOf(passName.ifBlank { stringResource(R.string.stores_game_pass) }) + passName.isNotBlank() -> listOf(stringResource(R.string.stores_eyebrow_pass, passName)) + item?.accessible == true -> listOf(stringResource(R.string.stores_eyebrow_library)) + else -> listOf(stringResource(R.string.stores_eyebrow_store)) + } + val size = if (folder != null && (installed != null || game != null)) diskSize(installed, folder) else (item?.sizeBytes ?: 0L).takeIf { it > 0 } + val line = (state + listOfNotNull(size?.let { formatBytes(it) })).filter { it.isNotBlank() }.joinToString(" · ") + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + SourceChip(source) + Text(line.uppercase(), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.2.sp, color = pal.signal, maxLines = 1, overflow = TextOverflow.Ellipsis) + } +} + +@Composable +private fun GameTitle(title: String, logo: Any?, titleSize: TextUnit) { + var failed by remember(logo) { mutableStateOf(false) } + if (logo != null && !failed) AsyncImage( + model = logo, contentDescription = title, contentScale = ContentScale.Fit, alignment = Alignment.CenterStart, + onError = { failed = true }, + modifier = Modifier.padding(vertical = 4.dp).height((titleSize.value * 2.2f).dp).widthIn(max = (titleSize.value * 9f).dp).testTag("game-logo"), + ) else Text( + title, fontSize = titleSize, lineHeight = titleSize * 1.12f, fontWeight = FontWeight.Black, + color = MaterialTheme.colorScheme.onBackground, maxLines = 2, overflow = TextOverflow.Ellipsis, + ) +} + +@Composable +private fun GameActions( + store: Store?, id: String?, game: Library.SteamGame?, item: CatalogItem?, installed: InstalledStoreGame?, + s: FrontEndState, a: FrontEndActions, leadOn: Modifier, +) { + val ctx = LocalContext.current + val storeKey = if (store != null && id != null) "${store.id}:$id" else null + val rawDownload = storeKey?.let { StoresState.download(it) } + val download = rawDownload?.takeIf { it.isActive } + val failed = rawDownload?.takeIf { it.state == DownloadState.FAILED } + Actions { + when { + s.shortcutPicker && game != null -> { + PrimaryButton(stringResource(R.string.game_shortcut_choose), enabled = !s.busy, main = true, icon = Icons.Filled.PlayArrow, modifier = leadOn) { a.onSteamGame(game) } + FileButtons(game, a) + } + game?.library == Library.XBOX_CLOUD -> { + val cloudId = game.storeId.orEmpty() + XboxStreamButton(cloudId, game.name, main = true, modifier = leadOn) + if (download != null) ActionChip(downloadLabel(download), ok = false) + else if (item?.installable == true) SecondaryButton( + if (item.sizeBytes > 0) stringResource(R.string.stores_install_size, formatBytes(item.sizeBytes)) else stringResource(R.string.stores_install), + ) { StoresState.requestInstall(ctx, item) } + XboxLibraryButton(cloudId, item) + } + game != null || installed != null -> { + val gone = storeKey?.let { StoresState.uninstalling[it] } + val xboxBusy = store == Store.XBOX && id != null && XboxContent.busy(id) + PrimaryButton( + stringResource(R.string.stores_play), enabled = !s.busy && gone == null && download == null && !xboxBusy, + main = true, icon = Icons.Filled.PlayArrow, modifier = leadOn, + ) { + if (game != null) a.onSteamGame(game) else if (store != null && id != null) launchStoreGame(ctx, store, id, s, a) + } + if ((game?.source ?: store?.id) != Library.SOURCE_STEAM) ActionChip(stringResource(R.string.stores_in_steam), ok = true) + if (game != null) { + FileButtons(game, a) + GameShortcutMenu(game, a) + EditArtButton(game) + } + if (store == Store.XBOX && id != null) XboxInstalledStream(id, game?.name ?: item?.title.orEmpty(), item) + if (store == Store.XBOX && installed != null && id != null) { + SecondaryButton(stringResource(R.string.stores_xbox_update), enabled = download == null && !XboxRuntime.isActive(id)) { + runCatching { XboxBackend.update(ctx, installed) } + .onFailure { StoresState.problems[Store.XBOX] = it.message ?: ctx.getString(R.string.stores_xbox_close_first) } + } + SecondaryButton(stringResource(R.string.stores_xbox_manage), enabled = download == null) { XboxContent.manage(ctx, installed) } + } + } + download != null -> { + ActionChip(downloadLabel(download), ok = false) + if (download.state != DownloadState.PAUSED) SecondaryButton(stringResource(R.string.stores_dl_pause), modifier = leadOn) { DownloadQueue.pause(download.key) } + else PrimaryButton(stringResource(R.string.stores_dl_resume), main = true, modifier = leadOn) { DownloadQueue.resume(ctx, download.key) } + ConfirmButton(stringResource(R.string.stores_dl_cancel), stringResource(R.string.stores_dl_cancel_confirm)) { DownloadQueue.cancel(ctx, download.key) } + } + failed != null -> { + ActionChip(stringResource(R.string.stores_dl_failed), ok = false) + PrimaryButton(stringResource(R.string.stores_dl_resume), main = true, modifier = leadOn) { + if (item != null) StoresState.requestInstall(ctx, item) else DownloadQueue.retry(ctx, failed.key) + } + ConfirmButton(stringResource(R.string.stores_dl_clear), stringResource(R.string.stores_dl_cancel_confirm), compact = true) { DownloadQueue.clear(ctx, failed.key) } + } + store == Store.XBOX && item != null && !item.xboxPc && item.xboxCloud -> { + XboxStreamButton(item.id, item.title, main = true, modifier = leadOn) + XboxLibraryButton(item.id, item) + } + item?.installable == true && StoresState.isUnfinished(item) -> { + InstallButton(stringResource(R.string.stores_resume_install), leadOn) { StoresState.requestInstall(ctx, item) } + ConfirmButton(stringResource(R.string.stores_dl_clear), stringResource(R.string.stores_dl_cancel_confirm), compact = true) { StoresState.clearUnfinished(ctx, item) } + } + item?.installable == true -> { + InstallButton( + if (item.sizeBytes > 0) stringResource(R.string.stores_install_size, formatBytes(item.sizeBytes)) else stringResource(R.string.stores_install), + leadOn, + ) { StoresState.requestInstall(ctx, item) } + if (store == Store.XBOX && item.xboxCloud) { + XboxStreamButton(item.id, item.title) + XboxLibraryButton(item.id, item) + } + if (!item.accessible) SecondaryButton(buyLabel(item)) { openStoreUrl(ctx, item) } + } + item != null && item.isFree -> PrimaryButton(stringResource(R.string.stores_get_free), main = true, modifier = leadOn) { openStoreUrl(ctx, item) } + item != null -> PrimaryButton(buyLabel(item), main = true, modifier = leadOn) { openStoreUrl(ctx, item) } + } + BusyChip(s) + } +} + +/** Xbox Cloud Gaming rows the user added to the Library, for games not installed here. */ +@Composable +internal fun rememberXboxCloudRows(s: FrontEndState): List { + val ctx = LocalContext.current + LaunchedEffect(Unit) { XboxCloudGames.load(ctx) } + if (s.shortcutPicker) return emptyList() + val added = XboxCloudGames.entries.toList() + return remember(added, s.steamGames) { + val installedXbox = s.steamGames.filter { it.source == Store.XBOX.id }.mapNotNull { it.storeId }.toSet() + XboxCloudGames.rows(ctx, installedXbox) + } +} + +@Composable +private fun XboxStreamButton(id: String, title: String, main: Boolean = false, modifier: Modifier = Modifier) { + val ctx = LocalContext.current + LaunchedEffect(Unit) { XboxCloudGames.prefetch(ctx) } + val label = stringResource(R.string.game_stream) + if (main) PrimaryButton(label, main = true, icon = Icons.Filled.PlayArrow, modifier = modifier) { XboxCloudGames.stream(ctx, id, title) } + else SecondaryButton(label, modifier = modifier) { XboxCloudGames.stream(ctx, id, title) } +} + +@Composable +private fun XboxInstalledStream(id: String, title: String, item: CatalogItem?) { + val ctx = LocalContext.current + val streams by produceState(item?.xboxCloud == true, id) { + if (!value) value = withContext(Dispatchers.IO) { XboxCloudGames.streams(ctx, id) } + } + if (streams) XboxStreamButton(id, title) +} + +@Composable +private fun XboxLibraryButton(id: String, item: CatalogItem?) { + val ctx = LocalContext.current + LaunchedEffect(Unit) { XboxCloudGames.load(ctx) } + // Add and Remove replace each other; the controller focus moves to the new button instead of getting lost. + val slot = remember { FocusRequester() } + val inputMode = androidx.compose.ui.platform.LocalInputModeManager.current + val scope = rememberCoroutineScope() + fun refocus() { if (inputMode.inputMode == androidx.compose.ui.input.InputMode.Keyboard) scope.launch { focusSoon { slot } } } + if (XboxCloudGames.isAdded(id)) { + ActionChip(stringResource(R.string.library_in), ok = true) + SecondaryButton(stringResource(R.string.library_remove), compact = true, modifier = Modifier.focusRequester(slot)) { XboxCloudGames.remove(ctx, id); refocus() } + } else if (item != null) SecondaryButton(stringResource(R.string.library_add), modifier = Modifier.focusRequester(slot)) { XboxCloudGames.add(ctx, item); refocus() } +} + +@Composable +private fun buyLabel(item: CatalogItem): String = when { + item.isFree -> stringResource(R.string.stores_get_free) + item.hasPrice && item.finalPrice.isNotBlank() -> stringResource(R.string.stores_buy_price, item.finalPrice) + else -> stringResource(R.string.stores_view_on, item.store.shortLabel) +} + +@Composable +private fun FileButtons(game: Library.SteamGame, a: FrontEndActions) { + game.gameFiles?.takeIf { it.isDirectory }?.let { dir -> SecondaryButton(stringResource(R.string.games_files), compact = true) { a.onBrowseFiles(dir) } } + game.protonPrefix?.takeIf { it.isDirectory }?.let { dir -> SecondaryButton(stringResource(R.string.games_prefix), compact = true) { a.onBrowseFiles(dir) } } +} + +@Composable +private fun GameShortcutMenu(g: Library.SteamGame, a: FrontEndActions) { + var open by remember(g.gameId) { mutableStateOf(false) } + Box { + SecondaryButton(stringResource(R.string.game_shortcut), compact = true) { open = !open } + AnchoredMenu(open, onDismiss = { open = false }, title = stringResource(R.string.game_shortcut)) { first -> + MenuItem(stringResource(R.string.game_shortcut_add), checked = false, focusRequester = first) { open = false; a.onGameShortcut(g) } + MenuItem(stringResource(R.string.game_file_export), checked = false) { open = false; a.onExportGameFile(g) } + MenuItem(stringResource(R.string.game_link_copy), checked = false) { open = false; a.onCopyGameLink(g) } + } + } +} + +/** The pencil opens the editor each kind of game always had: the shortcut editor for added games, art for the rest. */ +@Composable +private fun EditArtButton(g: Library.SteamGame) { + val ctx = LocalContext.current + val folder = g.gameFiles + val open: (() -> Unit)? = when { + g.library == Library.ADDED && g.source == Library.ADDED && folder != null -> { + { GamesDialogState.editing = EditRequest(folder.path, openTarget = false, fromSummary = false, preview = EditPreview.of(ctx, g, folder.path)) } + } + g.library != Library.ADDED && g.source == Library.SOURCE_STEAM -> { { GamesDialogState.steamArt = g } } + g.library == Library.ADDED && g.source != Library.ADDED && folder != null -> { { GamesDialogState.storeArt = g } } + else -> null + } + if (open != null) IconAction(Icons.Outlined.Edit, stringResource(R.string.common_edit), "game:edit", onClick = open) +} + +@Composable +private fun IconAction(icon: androidx.compose.ui.graphics.vector.ImageVector, label: String, id: String, danger: Boolean = false, enabled: Boolean = true, onClick: () -> Unit) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val src = remember { MutableInteractionSource() } + val hot = rememberHot(src) && enabled + val accent = if (danger) colors.error else pal.signal + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.paneItem(id).downToFirstTile().size(44.dp).clip(Shape12) + .background(if (hot) accent.copy(alpha = if (danger) 0.22f else 0.14f) else if (danger) Color.Black.copy(alpha = 0.34f) else Color.White.copy(alpha = 0.03f)) + .glideBorder(hot, Shape12, accent, pal.line2) + .alpha(if (enabled) 1f else 0.45f) + .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = androidx.compose.ui.semantics.Role.Button, enabled = enabled, onClick = onClick) + .controllerConfirm(enabled = enabled, onClick = onClick) + .semantics { contentDescription = label }, + ) { + Icon(icon, contentDescription = null, tint = if (danger && hot) colors.error else if (danger) Color.White else colors.onBackground, modifier = Modifier.size(18.dp)) + } +} + +@Composable +private fun RemoveButton(title: String, game: Library.SteamGame?, installed: InstalledStoreGame?, a: FrontEndActions) { + val ctx = LocalContext.current + val colors = MaterialTheme.colorScheme + if (installed != null) { + val key = installed.key + val gone = StoresState.uninstalling[key] + if (gone != null) { + val uninstalling = stringResource(R.string.stores_uninstalling) + Box(contentAlignment = Alignment.Center, modifier = Modifier.size(44.dp).clip(Shape12).background(Color.Black.copy(alpha = 0.34f)).semantics { contentDescription = uninstalling }) { + if (gone > 0f) CircularProgressIndicator(progress = { gone }, strokeWidth = 2.dp, color = colors.error, trackColor = colors.error.copy(alpha = 0.22f), modifier = Modifier.size(20.dp)) + else CircularProgressIndicator(strokeWidth = 2.dp, color = colors.error, modifier = Modifier.size(20.dp)) + } + return + } + val heading = stringResource(R.string.stores_uninstall_title, title) + val size = StoreSizes.onDisk[key] + val body = if (size != null && size > 0) stringResource(R.string.stores_uninstall_body_size, formatBytes(size)) else stringResource(R.string.stores_uninstall_body) + val keep = stringResource(R.string.stores_notification_keep) + val remove = stringResource(R.string.stores_uninstall) + val busy = StoresState.download(key)?.isActive == true || (installed.sidecar.store == Store.XBOX && XboxContent.busy(installed.sidecar.id)) + val placed = remember { arrayOfNulls(1) } + Box(Modifier.onGloballyPositioned { placed[0] = it }) { + IconAction(Icons.Outlined.Delete, remove, "game:remove", danger = true, enabled = !busy) { + val at = placed[0]?.takeIf { it.isAttached }?.boundsInRoot() ?: return@IconAction + var confirmed = false + Steps.ask(StepAsk(anchor = at, side = StepSide.Left, accent = colors.error, items = 3, pillCorner = 12.dp, maxWidth = 340.dp, + onDismiss = { if (confirmed) StoresState.uninstall(ctx, installed) { StoresState.refresh(ctx); a.onLibraryChanged() } }, + handle = { Icon(Icons.Outlined.Delete, null, tint = colors.error, modifier = Modifier.size(18.dp)) }) { + StepTitle(heading) + Text(body, fontSize = 13.sp, lineHeight = 18.sp, color = colors.onSurfaceVariant, modifier = Modifier.stepItem(1).padding(start = 4.dp, bottom = 12.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), modifier = Modifier.fillMaxWidth().stepItem(2)) { + StepChoice(keep, enabled = open, modifier = Modifier.focusRequester(first)) { Steps.fold() } + StepChoice(remove, danger = true, enabled = open) { confirmed = true; Steps.fold() } + } + }) + } + } + return + } + val g = game ?: return + val kind = GameRemoval.kind(g) + if (kind == GameRemoval.Kind.LAUNCHER || g.gameFiles?.isDirectory != true) return + var asking by remember(g.gameId) { mutableStateOf(false) } + val shortcut = kind == GameRemoval.Kind.SHORTCUT + val label = stringResource(if (shortcut) R.string.edit_remove else R.string.common_uninstall) + IconAction(Icons.Outlined.Delete, label, "game:remove", danger = true) { asking = true } + if (asking) ConfirmDialog( + title = if (shortcut) stringResource(R.string.games_remove_title, g.name) else stringResource(R.string.stores_uninstall_title, g.name), + text = when { + shortcut -> stringResource(R.string.games_remove_body) + g.library == Library.ADDED -> stringResource(R.string.stores_uninstall_body) + else -> stringResource(R.string.games_uninstall_body) + }, + confirm = label, + onConfirm = { a.onGameRemoved(g) }, + onDismiss = { asking = false }, + ) +} + +@Composable +private fun InstallButton(label: String, modifier: Modifier = Modifier, onInstall: () -> Unit) { + val placed = remember { arrayOfNulls(1) } + Box(Modifier.onGloballyPositioned { placed[0] = it }) { + PrimaryButton(label, main = true, modifier = modifier) { + StoresMotion.markInstall(placed[0]?.takeIf { it.isAttached }?.boundsInRoot(), label) + onInstall() + } + } +} + +@Composable +private fun AboutTab(developer: String, publisher: String, releaseDate: String, description: String, scores: GameScores?, stacked: Boolean) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().tabPanel()) { + if (scores != null) Box(Modifier.readable()) { StoreGameFacts(developer, publisher, releaseDate, scores, scoresInHero = !stacked) } + else if (developer.isNotBlank() || publisher.isNotBlank() || releaseDate.isNotBlank()) { + val parts = listOf(developer, publisher, com.droiddeck.launcher.stores.GameFacts.releaseLabel(releaseDate).orEmpty()).filter { it.isNotBlank() }.distinct() + Text(parts.joinToString(" · "), fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.readable()) + } + if (description.isNotBlank()) StoreAbout(description, AboutHeight, Modifier.fillMaxWidth()) + } +} + +private val AboutHeight = 260.dp + +@Composable +internal fun Modifier.tabPanel(): Modifier { + val shape = RoundedCornerShape(16.dp) + return clip(shape).background(MaterialTheme.colorScheme.surface).border(1.dp, LocalPalette.current.line, shape).padding(horizontal = 16.dp, vertical = 14.dp) +} + +@Composable +private fun StoreAbout(text: String, height: Dp, modifier: Modifier = Modifier) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val scroll = rememberScrollState() + val scope = rememberCoroutineScope() + val src = remember { MutableInteractionSource() } + val hot = rememberHot(src) + val shape = RoundedCornerShape(12.dp) + Box( + modifier.heightIn(max = height).clip(shape) + .glideBorder(hot, shape, pal.signal, restColor = Color.Transparent) + .paneItem("about") + .focusable(interactionSource = src) + .onPreviewKeyEvent { e -> + val step = when (e.nativeKeyEvent.keyCode) { + android.view.KeyEvent.KEYCODE_DPAD_DOWN -> if (scroll.value < scroll.maxValue) 120f else return@onPreviewKeyEvent false + android.view.KeyEvent.KEYCODE_DPAD_UP -> if (scroll.value > 0) -120f else return@onPreviewKeyEvent false + else -> return@onPreviewKeyEvent false + } + if (e.nativeKeyEvent.action == android.view.KeyEvent.ACTION_DOWN) scope.launch { scroll.animateScrollBy(step) } + true + }, + ) { + Text(text, fontSize = 14.sp, lineHeight = 21.sp, color = colors.onSurfaceVariant, modifier = Modifier.verticalScroll(scroll).padding(horizontal = 4.dp, vertical = 6.dp)) + if (scroll.value < scroll.maxValue) Box( + Modifier.align(Alignment.BottomCenter).fillMaxWidth().height(28.dp) + .background(Brush.verticalGradient(listOf(Color.Transparent, colors.surface))), + ) + } +} + +@Composable +private fun LaunchTab(game: Library.SteamGame, s: FrontEndState, a: FrontEndActions, hopRail: String?) { + val host = rememberMenuHost() + Column(verticalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) { + LaunchSettings(s, a, host, game, hopRail ?: GameRef.key(game)) + val folder = game.gameFiles + if (game.library == Library.ADDED && game.source == Library.ADDED && !s.shortcutPicker && folder != null) ShortcutFields(game, folder) + } +} + +@Composable +private fun ShortcutFields(game: Library.SteamGame, folder: File) { + val ctx = LocalContext.current + val preview = remember(game.gameId, game.name, GamesDialogState.editing) { EditPreview.of(ctx, game, folder.path) } + fun edit(target: Boolean) { GamesDialogState.editing = EditRequest(folder.path, openTarget = target, fromSummary = false, preview = EditPreview.of(ctx, game, folder.path)) } + Column(Modifier.fillMaxWidth().tabPanel(), verticalArrangement = Arrangement.spacedBy(2.dp)) { + SectionLabel(stringResource(R.string.game_launch_shortcut)) + MenuItem(stringResource(R.string.edit_name), checked = false, detail = preview.name) { edit(false) } + MenuItem(stringResource(R.string.edit_target), checked = false, detail = preview.exeName ?: "—") { edit(true) } + MenuItem(stringResource(R.string.edit_start_in), checked = false, detail = preview.startIn ?: "—") { edit(false) } + MenuItem(stringResource(R.string.edit_launch_options), checked = false, detail = preview.launchOptions.ifEmpty { "—" }) { edit(false) } + } +} + +@Composable +private fun SavesTab(g: Library.SteamGame, prefix: File, a: FrontEndActions) { + val saves by produceState?>(null, g.gameId) { + value = withContext(Dispatchers.IO) { runCatching { GameSaves.locate(prefix, g) }.getOrDefault(emptyList()) } + } + val found = saves + val summary = when { + found == null -> stringResource(R.string.games_saves_looking) + found.isEmpty() -> stringResource(R.string.games_saves_none) + else -> { + val mb = found.sumOf { it.bytes } / 1048576.0 + stringResource( + R.string.games_saves_summary, + if (found.size == 1) found[0].relPath else stringResource(R.string.games_saves_folders, found.size), + found.sumOf { it.files }, + if (mb < 1) stringResource(R.string.games_kb, (mb * 1024).toInt()) else stringResource(R.string.common_size_mb, mb), + ) + } + } + Column(Modifier.fillMaxWidth().tabPanel(), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(summary, fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(bottom = 6.dp).readable().padding(4.dp)) + if (Store.byId(g.source).let { it == Store.GOG || it == Store.EPIC }) { + SectionLabel(stringResource(R.string.store_cloud_saves)) + CloudRows(g) + SectionLabel(stringResource(R.string.games_saves)) + } + MenuItem(stringResource(R.string.games_import_saves), checked = false) { a.onSaveImport(g) } + MenuItem(stringResource(R.string.games_export_gamehub), checked = false) { a.onSaveExport(g, GameSaves.Layout.GAMEHUB) } + MenuItem(stringResource(R.string.games_export_winlator), checked = false) { a.onSaveExport(g, GameSaves.Layout.WINLATOR) } + found?.firstOrNull()?.let { d -> + MenuItem(stringResource(R.string.games_open_save_folder), checked = false, detail = d.relPath) { + a.onBrowseFiles(File(prefix, "drive_c/users/steamuser/" + d.relPath)) + } + } + } +} + +@Composable +private fun CloudRows(g: Library.SteamGame) { + val store = Store.byId(g.source)?.takeIf { it != Store.AMAZON } ?: return + val id = g.storeId ?: return + val context = LocalContext.current.applicationContext + var busy by remember(g.gameId) { mutableStateOf(null) } + var tick by remember(g.gameId) { mutableStateOf(0) } + val status by produceState(null, g.gameId, tick) { + value = withContext(Dispatchers.IO) { runCatching { CloudSaves.status(context, store, id) }.getOrNull() } + } + val folder = g.gameFiles + var enabled by remember(g.gameId) { mutableStateOf(folder?.let { StoreGameSidecar.read(it)?.cloud } ?: true) } + val st = status + val supported = st?.supported ?: true + val conflict = st != null && st.conflicts.isNotEmpty() + fun sync(up: Boolean, force: Boolean = false) { + if (busy != null) return + busy = if (up) "up" else "down" + Thread({ + val r = if (up) CloudSaves.upload(context, store, id, force) else CloudSaves.download(context, store, id, force) + StoresState.post { busy = null; tick++; android.widget.Toast.makeText(context, cloudMessage(context, r), android.widget.Toast.LENGTH_SHORT).show() } + }, "cloud-manual").start() + } + MenuItem(stringResource(R.string.store_cloud_saves), checked = enabled && supported, enabled = supported, + detail = if (!supported) stringResource(R.string.cloud_none) else null) { + val f = folder ?: return@MenuItem + val next = !enabled + Thread({ + val written = runCatching { StoreGameSidecar.updateCloud(f, next) }.getOrNull() + StoresState.post { enabled = written?.cloud ?: enabled } + }, "cloud-switch").start() + } + val ready = st != null && supported && enabled && busy == null + val pending = stringResource(R.string.cloud_pending) + val summary = when { + st == null || busy != null -> pending + !supported -> stringResource(R.string.cloud_none) + conflict -> stringResource(R.string.cloud_conflict_files, st.conflicts.size) + st.lastSync > 0 -> stringResource(R.string.cloud_last_sync, android.text.format.DateUtils.getRelativeTimeSpanString(st.lastSync).toString()) + else -> stringResource(R.string.cloud_never) + } + MenuItem(stringResource(if (conflict) R.string.cloud_keep_local else R.string.cloud_upload), checked = false, enabled = ready, detail = summary) { sync(up = true, force = conflict) } + MenuItem(stringResource(if (conflict) R.string.cloud_keep_cloud else R.string.cloud_download), checked = false, enabled = ready, detail = if (st == null || busy != null) pending else null) { sync(up = false, force = conflict) } +} + +@Composable +private fun InstallTab(game: Library.SteamGame?, installed: InstalledStoreGame?, item: CatalogItem?, store: Store?) { + val ctx = LocalContext.current + val folder = installed?.folder ?: game?.gameFiles + val sidecar = installed?.sidecar + val size = folder?.let { diskSize(installed, it) } + val rows = buildList { + add(stringResource(R.string.game_install_location) to when { + installed != null -> StoreInstallRoot.labelFor(ctx, installed.folder) + game != null -> libraryLabel(game).ifBlank { stringResource(R.string.games_internal) } + else -> "" + }) + if (folder != null) { + add(stringResource(R.string.game_install_folder) to folder.path) + add(stringResource(R.string.game_install_size) to (size?.let { formatBytes(it) } ?: stringResource(R.string.cloud_pending))) + } + sidecar?.extra?.get(StoreLanguage.EXTRA)?.takeIf { it.isNotBlank() }?.let { add(stringResource(R.string.stores_install_language) to StoreLanguage.displayName(it)) } + sidecar?.installVersion?.takeIf { it.isNotBlank() }?.let { add(stringResource(R.string.game_install_version) to it) } + sidecar?.installedAt?.takeIf { it > 0 }?.let { + add(stringResource(R.string.game_install_date) to java.text.DateFormat.getDateInstance(java.text.DateFormat.MEDIUM).format(java.util.Date(it))) + } + if (game != null) add(stringResource(R.string.game_install_played) to (playedSpan(game.lastPlayed)?.replaceFirstChar { it.uppercase() } ?: stringResource(R.string.games_never_played))) + if (store == Store.XBOX) { + val access = sidecar?.extra?.get("access") ?: if (item?.subscription == true) "gamepass" else if (item?.owned == true) "owned" else null + if (access != null) add(stringResource(R.string.game_install_access) to stringResource(if (access == "gamepass") R.string.stores_game_pass else R.string.game_install_access_owned)) + } + }.filter { it.second.isNotBlank() } + Column(Modifier.fillMaxWidth().tabPanel(), verticalArrangement = Arrangement.spacedBy(2.dp)) { + for ((label, value) in rows) FactRow(label, value) + } +} + +/** Text a controller has to land on to bring it into view: focusable, with the focus ring. */ +@Composable +internal fun Modifier.readable(): Modifier { + val pal = LocalPalette.current + val src = remember { MutableInteractionSource() } + val hot = rememberHot(src) + val shape = RoundedCornerShape(8.dp) + return clip(shape).glideBorder(hot, shape, pal.signal, restColor = Color.Transparent).focusable(interactionSource = src) +} + +@Composable +private fun FactRow(label: String, value: String) { + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.fillMaxWidth().readable().padding(horizontal = 8.dp, vertical = 7.dp), + ) { + Text(label, fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.width(150.dp)) + Text(value, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onBackground, modifier = Modifier.weight(1f), maxLines = 2, overflow = TextOverflow.Ellipsis) + } +} + +/** Store installs are measured by [StoreSizes]; Steam and added games by walking their folder once. */ +@Composable +internal fun diskSize(installed: InstalledStoreGame?, folder: File): Long? { + val ctx = LocalContext.current + if (installed != null) { + LaunchedEffect(installed.key) { StoreSizes.requestDisk(ctx, installed.key, installed) } + return StoreSizes.onDisk[installed.key] + } + LaunchedEffect(folder.path) { FolderSizes.request(folder) } + return FolderSizes.known[folder.path] +} + +internal object FolderSizes { + val known = androidx.compose.runtime.mutableStateMapOf() + private val asked = java.util.Collections.synchronizedSet(HashSet()) + + fun request(folder: File) { + if (!asked.add(folder.path)) return + Thread({ + var total = 0L + val walked = runCatching { + java.nio.file.Files.walkFileTree(folder.toPath(), object : java.nio.file.SimpleFileVisitor() { + override fun visitFile(file: java.nio.file.Path, attrs: java.nio.file.attribute.BasicFileAttributes): java.nio.file.FileVisitResult { + if (attrs.isRegularFile) total += attrs.size() + return java.nio.file.FileVisitResult.CONTINUE + } + override fun visitFileFailed(file: java.nio.file.Path, exc: java.io.IOException): java.nio.file.FileVisitResult = java.nio.file.FileVisitResult.CONTINUE + }) + }.isSuccess + if (walked) StoresState.post { known[folder.path] = total } else asked.remove(folder.path) + }, "folder-size").start() + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/LibraryPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/LibraryPage.kt new file mode 100644 index 000000000..d4dd41fe8 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/LibraryPage.kt @@ -0,0 +1,690 @@ +package com.droiddeck.launcher.ui + +import android.view.KeyEvent +import androidx.activity.compose.BackHandler +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyGridScope +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.FilterList +import androidx.compose.material.icons.filled.GridView +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Sort +import androidx.compose.material.icons.filled.ViewList +import androidx.compose.material.icons.filled.ViewModule +import androidx.compose.material.icons.outlined.Download +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil.compose.AsyncImage +import com.droiddeck.launcher.R +import com.droiddeck.launcher.frontend.Library +import com.droiddeck.launcher.session.SessionPrefs +import com.droiddeck.launcher.stores.CatalogItem +import com.droiddeck.launcher.stores.Store +import com.droiddeck.launcher.stores.StoreSizes +import com.droiddeck.launcher.stores.StoresState +import com.droiddeck.launcher.stores.download.DownloadEntry +import com.droiddeck.launcher.stores.formatBytes +import com.droiddeck.launcher.stores.retainOrder +import com.droiddeck.launcher.stores.xbox.XboxLibraryFilter +import com.droiddeck.launcher.stores.sortGames +import kotlinx.coroutines.launch +import androidx.compose.runtime.setValue + +/** One owned game: installed ones carry their [game], store products not installed yet only their [item]. */ +internal class LibraryEntry( + val key: String, val source: String, val title: String, + val game: Library.SteamGame?, val item: CatalogItem?, val installed: Boolean, +) { + val storeKey: String? get() = item?.key ?: game?.storeId?.let { "${game.source}:$it" } +} + +/** Installed games first claim their store product, so a game owned and installed shows once. */ +internal fun libraryEntries( + games: List, owned: Map>, installedStore: Set, +): List { + val claimed = HashSet() + val out = ArrayList() + for (g in games) { + if (g.library == Library.EPIC_LAUNCHER) continue + val storeKey = g.storeId?.let { "${g.source}:$it" } + val item = storeKey?.let { k -> Store.byId(g.source)?.let { st -> owned[st]?.firstOrNull { it.key == k } } } + if (storeKey != null) claimed += storeKey + out += LibraryEntry(GameRef.key(g), g.source, g.name, g, item, installed = true) + } + for (store in Store.entries) for (item in owned[store].orEmpty()) { + if (!claimed.add(item.key)) continue + out += LibraryEntry(GameRef.key(item), store.id, item.title, null, item, installed = item.key in installedStore) + } + return out.distinctBy { it.key } +} + +internal fun installedFirst(entries: List): List = entries.partition { it.installed }.let { (on, off) -> on + off } + +internal const val LIBRARY_ALL = "all" +internal val LIBRARY_SOURCES = listOf(Library.SOURCE_STEAM) + Store.entries.map { it.id } + Library.ADDED + +/** Survives leaving the page, so Stores can open the Library on one store's owned games. */ +internal object LibraryView { + var source by mutableStateOf(LIBRARY_ALL) + var query by mutableStateOf("") + var focusFirst = false + + fun showOwned(store: Store) { + source = store.id + query = "" + focusFirst = true + } +} + +@Composable +internal fun LibraryPage(s: FrontEndState, a: FrontEndActions, selected: String, onSelect: (String) -> Unit, modifier: Modifier) { + val ctx = LocalContext.current + val narrow = LocalNarrowPane.current + val ff = LocalFrontFocus.current + val focusManager = androidx.compose.ui.platform.LocalFocusManager.current + val inputMode = androidx.compose.ui.platform.LocalInputModeManager.current + LaunchedEffect(Unit) { StoresState.refresh(ctx) } + val stores = s.gameStoresEnabled && !s.shortcutPicker + val signedIn = Store.entries.filter { StoresState.isSignedIn(it) } + LaunchedEffect(stores, signedIn) { if (stores) signedIn.forEach { StoresState.loadLibrary(ctx, it) } } + val owned = if (stores) signedIn.associateWith { StoresState.library[it].orEmpty() } else emptyMap() + val installedStore = StoresState.installed.map { it.key }.toSet() + val syncing = if (stores) signedIn.mapNotNull { st -> StoresState.status[st]?.let { st to it } } else emptyList() + val cloudRows = rememberXboxCloudRows(s) + val all = remember(s.steamGames, cloudRows, owned, installedStore) { libraryEntries(s.steamGames + cloudRows, owned, installedStore) } + LaunchedEffect(Unit) { XboxLibraryFilter.load(ctx) } + val sources = LIBRARY_SOURCES.filter { src -> all.any { it.source == src } } + val source = LibraryView.source.takeIf { it == LIBRARY_ALL || it in sources } ?: LIBRARY_ALL + val q = LibraryView.query.trim().lowercase() + val xboxFilter = if (source == Store.XBOX.id) XboxLibraryFilter.current else XboxLibraryFilter.ALL + val candidates = all.filter { + (source == LIBRARY_ALL || it.source == source) && (q.isEmpty() || it.title.lowercase().contains(q)) && keepsXbox(xboxFilter, it) + } + val sort = s.storesSort + val sizeSort = sort == "sizeAsc" || sort == "sizeDesc" + val installedAt = StoresState.installed.associate { it.key to it.sidecar.installedAt } + val lastPlayed = s.steamGames.filter { it.storeId != null }.associate { "${it.source}:${it.storeId}" to it.lastPlayed } + fun sizeOf(e: LibraryEntry): Long? { + val key = e.storeKey + return when { + key != null && key in installedStore -> StoreSizes.onDisk[key] + e.game?.gameFiles != null -> FolderSizes.known[e.game.gameFiles.path] + e.item != null -> StoreSizes.size(e.item).takeIf { it > 0 } + else -> 0L + } + } + LaunchedEffect(sizeSort, candidates.map { it.key }) { + if (!sizeSort) return@LaunchedEffect + for (e in candidates) { + val key = e.storeKey + val inst = key?.let { k -> StoresState.installed.firstOrNull { it.key == k } } + when { + inst != null -> StoreSizes.requestDisk(ctx, inst.key, inst) + e.game?.gameFiles != null -> FolderSizes.request(e.game.gameFiles) + e.item != null && e.item.accessible -> StoreSizes.request(ctx, e.item) + } + } + } + var sizesWaited by remember(sizeSort) { mutableStateOf(false) } + LaunchedEffect(sizeSort) { if (sizeSort) { kotlinx.coroutines.delay(8_000); sizesWaited = true } } + val sizesPending = sizeSort && !sizesWaited && candidates.any { sizeOf(it) == null } + val sorted = sortGames( + candidates, sort, title = { it.title }, releaseDate = { it.item?.releaseDate.orEmpty() }, + lastPlayed = { e -> e.game?.lastPlayed ?: e.storeKey?.let { lastPlayed[it] } ?: 0L }, + installedAt = { e -> e.storeKey?.let { installedAt[it] } ?: 0L }, + size = { e -> sizeOf(e) ?: 0L }, + ) + var stable by remember { mutableStateOf(emptyList()) } + val ordered = if (sizesPending) retainOrder(candidates, stable) { it.key } else sorted + SideEffect { if (!sizesPending) stable = sorted } + val visible = installedFirst(ordered) + val split = visible.count { it.installed }.takeIf { it in 1 until visible.size } ?: 0 + + val open = selected.takeIf { it.startsWith("app:") } + val back = { onSelect("games") } + BackHandler(enabled = open != null) { back() } + val lead = remember { FocusRequester() } + var detailFocused by remember { mutableStateOf(false) } + var lastOpened by remember { mutableStateOf(null) } + val wasOpen = remember { booleanArrayOf(false) } + val backLabel = stringResource(R.string.rail_games) + LaunchedEffect(open) { + val closing = open == null && wasOpen[0] + wasOpen[0] = open != null + if (open != null) lastOpened = open + else if (!closing) return@LaunchedEffect + if (inputMode.inputMode != androidx.compose.ui.input.InputMode.Keyboard || ff == null) return@LaunchedEffect + repeat(60) { if (!HeldKeys.confirm) return@repeat; androidx.compose.runtime.withFrameNanos { } } + if (open != null) { + val backId = "back:$backLabel" + repeat(45) { n -> + androidx.compose.runtime.withFrameNanos { } + if (detailFocused) return@LaunchedEffect + val req = if (n < 20) lead else ff.items[backId]?.takeIf { (ff.attached[backId] ?: 0) > 0 } ?: lead + runCatching { req.requestFocus() } + } + return@LaunchedEffect + } + val id = lastOpened?.let { "card:$it" } + repeat(45) { + androidx.compose.runtime.withFrameNanos { } + if (id != null && ff.last == id) return@LaunchedEffect + val card = id?.let { ff.items[it] }?.takeIf { (ff.attached[id] ?: 0) > 0 } + if (card != null) runCatching { card.requestFocus() } + else if (ff.firstTileAttached > 0 && runCatching { ff.firstTile.requestFocus() }.isSuccess) return@LaunchedEffect + } + } + LaunchedEffect(Unit) { + if (!LibraryView.focusFirst || ff == null) return@LaunchedEffect + LibraryView.focusFirst = false + if (inputMode.inputMode != androidx.compose.ui.input.InputMode.Keyboard) return@LaunchedEffect + kotlinx.coroutines.delay(Motion.ms(240).toLong()) + focusWithinFrames({ ff.last?.startsWith("card:") == true }) { if (ff.firstTileAttached > 0) ff.firstTile else FocusRequester.Default } + } + if (all.isEmpty() && open == null) { + LibraryEmpty(s, a, modifier) + return + } + val grid = remember { LazyGridState() } + val detailScroll = rememberScrollState() + var cycled by remember { mutableIntStateOf(0) } + var gridFocused by remember { mutableStateOf(false) } + LaunchedEffect(source, sort) { grid.scrollToItem(0) } + LaunchedEffect(cycled) { + if (cycled == 0 || ff == null || inputMode.inputMode != androidx.compose.ui.input.InputMode.Keyboard) return@LaunchedEffect + focusWithinFrames({ gridFocused }) { + if (ff.firstTileAttached > 0) ff.firstTile else ff.items["libchip:$source"] ?: FocusRequester.Default + } + } + CardDetailHost( + open, detailScroll, + gridScrolled = { grid.firstVisibleItemIndex > 0 || grid.firstVisibleItemScrollOffset > 0 }, + onDetailFocus = { detailFocused = it }, + modifier = modifier.padding(horizontal = if (narrow) 14.dp else 22.dp, vertical = if (narrow) 10.dp else 14.dp), + wrap = { key, content -> StoreTheme(GameRef.parse(key).let { r -> r.store ?: r.game(s.steamGames)?.source?.let { Store.byId(it) } }) { content() } }, + grid = { + LibraryGrid( + s, a, grid, visible, split, all.size, sources, source, sizesPending, syncing, + onSource = { LibraryView.source = it }, + onCycle = { step -> + val ids = listOf(LIBRARY_ALL) + sources + LibraryView.source = ids[(ids.indexOf(source).coerceAtLeast(0) + step + ids.size) % ids.size] + cycled++ + }, + onOpen = { focusManager.clearFocus(force = true); onSelect(it) }, + searchCleared = open != null, + onFocus = { gridFocused = it }, + ) + }, + ) { key -> GameDetail(key, s, a, lead, backLabel, hopRail = "games", onBack = back) } +} + +@Composable +private fun LibraryEmpty(s: FrontEndState, a: FrontEndActions, modifier: Modifier) { + val narrow = LocalNarrowPane.current + Column(modifier = modifier.padding(horizontal = if (narrow) 16.dp else 22.dp, vertical = if (narrow) 12.dp else 18.dp)) { + Rise(0) { + PageHeader(stringResource(R.string.rail_games)) { + if (s.steamGamesX64) ClientTag() + if (!s.shortcutPicker) AddGameButton { GamesDialogState.adding = true } + } + } + Rise(1) { Note(stringResource(if (s.shortcutPicker && s.shortcutLibraryScanning) R.string.game_shortcut_scanning else R.string.games_empty)) } + if (!s.shortcutPicker) Rise(2) { + Actions { + PrimaryButton(stringResource(R.string.games_play_steam), enabled = !s.busy, main = true, icon = Icons.Filled.PlayArrow, modifier = Modifier.padding(top = 12.dp).testTag("play-steam"), onClick = a.onPlay) + } + } + Box(Modifier.padding(top = 12.dp)) { GameFileFolderActions(s, a) } + } +} + +@Composable +private fun LibraryGrid( + s: FrontEndState, a: FrontEndActions, grid: LazyGridState, visible: List, split: Int, total: Int, + sources: List, source: String, sizesPending: Boolean, syncing: List>, + onSource: (String) -> Unit, onCycle: (Int) -> Unit, onOpen: (String) -> Unit, onFocus: (Boolean) -> Unit, searchCleared: Boolean, +) { + val listMode = s.storesView == "list" + val capsules = s.storesView == "capsules" + val tileWidth = if (capsules) CapsuleWidth else CardWidth + val focus = LocalFrontFocus.current + val scope = rememberCoroutineScope() + val density = androidx.compose.ui.platform.LocalDensity.current + var columns by remember { mutableIntStateOf(1) } + val headerItems = 2 + (if (sizesPending) 1 else 0) + syncing.size + val tile: @Composable (LibraryEntry) -> Unit = { e -> + val first = e.key == visible.firstOrNull()?.key + val navigation = if (focus == null) Modifier else Modifier.onPreviewKeyEvent { event -> + val key = event.nativeKeyEvent + val direction = when (key.keyCode) { + KeyEvent.KEYCODE_DPAD_DOWN -> 1 + KeyEvent.KEYCODE_DPAD_UP -> -1 + else -> 0 + } + val index = visible.indexOfFirst { it.key == e.key } + val next = storeGridNeighbor(index, visible.size, if (listMode) 1 else columns, direction, split) + when { + direction == 0 || index < 0 -> false + next == null -> direction > 0 + else -> { + if (key.action == KeyEvent.ACTION_DOWN) { + val target = "card:${visible[next].key}" + val targetIndex = headerItems + next + if (split == 0) 0 else if (next < split) 1 else 2 + val shown = grid.layoutInfo.visibleItemsInfo.any { it.index == targetIndex } + if (!shown || (focus.attached[target] ?: 0) == 0 || + runCatching { focus.items.getValue(target).requestFocus() }.isFailure) scope.launch { + grid.scrollToItem(targetIndex) + focusWithinFrames({ focus.last == target }) { focus.items[target] ?: FocusRequester.Default } + } + } + true + } + } + } + val picker = s.shortcutPicker && e.game != null + val choose = if (picker) ({ a.onSteamGame(e.game!!) }) else null + if (listMode) LibraryRow(e, first, navigation, choose, onOpen) else LibraryTile(e, capsules, first, navigation, choose, onOpen) + } + LazyVerticalGrid( + columns = if (listMode) GridCells.Fixed(1) else GridCells.Adaptive(tileWidth), state = grid, + modifier = Modifier.fillMaxSize().onFocusChanged { onFocus(it.hasFocus) }.focusGroup().bumpers(onPrevious = { onCycle(-1) }, onNext = { onCycle(1) }).onSizeChanged { + val gap = with(density) { 10.dp.roundToPx() } + val minimum = with(density) { (if (listMode) CardWidth * 2 else tileWidth).roundToPx() } + columns = ((it.width + gap) / (minimum + gap)).coerceAtLeast(1) + }, + horizontalArrangement = Arrangement.spacedBy(10.dp), verticalArrangement = Arrangement.spacedBy(10.dp), + contentPadding = PaddingValues(top = 4.dp, bottom = 16.dp), + ) { + item(key = "header", span = { GridItemSpan(maxLineSpan) }) { Rise(0) { LibraryHeader(s, a, total, searchCleared, xbox = source == Store.XBOX.id) } } + item(key = "filters", span = { GridItemSpan(maxLineSpan) }) { + Rise(1) { LibraryFilters(sources, source, onSource) } + } + for ((store, line) in syncing) item(key = "sync-${store.id}", span = { GridItemSpan(maxLineSpan) }) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.fillMaxWidth()) { + Text(store.shortLabel + " · " + line, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) + if (syncCount(line) != null) SyncBar(line) + else androidx.compose.material3.LinearProgressIndicator(Modifier.fillMaxWidth().padding(bottom = 8.dp).height(3.dp)) + } + } + if (sizesPending) item(key = "size-sort-progress", span = { GridItemSpan(maxLineSpan) }) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp)) { + androidx.compose.material3.LinearProgressIndicator(Modifier.weight(1f).height(3.dp)) + Text(stringResource(R.string.store_section_loading), fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + val empty = R.string.stores_nothing_matches + if (split == 0) libraryGrid(visible, empty, tile) + else { + item(key = "installed-label", span = { GridItemSpan(maxLineSpan) }) { SectionLabel(stringResource(R.string.stores_section_installed)) } + libraryGrid(visible.take(split), empty, tile) + item(key = "not-installed-label", span = { GridItemSpan(maxLineSpan) }) { SectionLabel(stringResource(R.string.stores_section_not_installed)) } + libraryGrid(visible.drop(split), empty, tile) + } + } +} + +private fun LazyGridScope.libraryGrid(entries: List, empty: Int, tile: @Composable (LibraryEntry) -> Unit) { + if (entries.isEmpty()) item(key = "empty", span = { GridItemSpan(maxLineSpan) }) { + Box(Modifier.padding(vertical = 32.dp).fillMaxWidth(), contentAlignment = Alignment.Center) { + Text(stringResource(empty), fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + else items(entries, key = { it.key }, contentType = { "game" }) { tile(it) } +} + +@Composable +private fun LibraryHeader(s: FrontEndState, a: FrontEndActions, total: Int, searchCleared: Boolean, xbox: Boolean) { + val colors = MaterialTheme.colorScheme + val narrow = LocalNarrowPane.current + ModeHeader( + stringResource(R.string.rail_games), LibraryView.query, { LibraryView.query = it }, + placeholder = stringResource(R.string.stores_search_library), + clearSearchFocus = searchCleared, + lead = { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(start = 2.dp, end = 4.dp)) { + Text(stringResource(R.string.rail_games), fontSize = if (narrow) 20.sp else 24.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, maxLines = 1) + Text(pluralStringResource(R.plurals.mode_added_count, total, total), fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1) + if (s.steamGamesX64 && !narrow) ClientTag() + } + }, + ) { + if (xbox) XboxFilterButton() + SortButton(s, a) + ViewButton(s, a) + GameFileFolderActions(s, a) + if (!s.shortcutPicker) AddGameButton { GamesDialogState.adding = true } + } + if (s.steamGamesX64 && narrow) Box(Modifier.padding(top = 4.dp)) { ClientTag() } +} + +@Composable +private fun LibraryFilters(sources: List, source: String, onSource: (String) -> Unit) { + if (sources.size < 2) return + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(top = 4.dp, bottom = 2.dp), + ) { + SourceFilterChip(LIBRARY_ALL, stringResource(R.string.stores_tab_all), source == LIBRARY_ALL, dot = null) { onSource(LIBRARY_ALL) } + for (src in sources) { + val store = Store.byId(src) + val dot = when { + store != null -> sourceColours(src).dot.copy(alpha = if (StoresState.isSignedIn(store) && StoresState.expired[store] != true) 1f else 0.35f) + src == Library.SOURCE_STEAM -> sourceColours(src).dot + else -> null + } + SourceFilterChip(src, if (src == Library.ADDED) stringResource(R.string.library_filter_added) else store?.shortLabel ?: sourceLabel(src), source == src, dot) { onSource(src) } + } + } +} + +@Composable +private fun SourceFilterChip(key: String, label: String, on: Boolean, dot: Color?, onClick: () -> Unit) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val src = remember { MutableInteractionSource() } + val hot = rememberHot(src) + val shape = RoundedCornerShape(99.dp) + Row( + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(7.dp), + modifier = Modifier.paneItem("libchip:$key").testTag("library-source-$key").heightIn(min = 40.dp).clip(shape) + .background(if (on) pal.signal.copy(alpha = 0.16f) else colors.surface) + .glideBorder(hot, shape, pal.signal, if (on) pal.signal else pal.line) + .hoverable(src).selectable(selected = on, interactionSource = src, indication = LocalIndication.current, role = Role.Tab, onClick = onClick) + .controllerConfirm(onClick = onClick) + .padding(horizontal = 14.dp, vertical = 8.dp), + ) { + if (dot != null) Box(Modifier.size(9.dp).background(dot, CircleShape)) + Text(label, fontSize = 13.sp, fontWeight = FontWeight.Bold, color = if (on || hot) colors.onBackground else colors.onSurfaceVariant, maxLines = 1) + } +} + +private fun entryArt(e: LibraryEntry, tall: Boolean): Any? { + val inst = e.storeKey?.let { k -> StoresState.installed.firstOrNull { it.key == k } } + val wide: Any? = e.game?.hero ?: e.item?.imageUrl ?: inst?.sidecar?.hero ?: inst?.sidecar?.cover + val portrait: Any? = e.game?.art ?: e.item?.tallImageUrl ?: inst?.sidecar?.cover + return if (tall) portrait ?: wide else wide ?: portrait +} + +@Composable +private fun LibraryTile(e: LibraryEntry, capsule: Boolean, first: Boolean, navigation: Modifier, choose: (() -> Unit)?, onOpen: (String) -> Unit) { + val pal = LocalPalette.current + val src = remember { MutableInteractionSource() } + val hot = rememberHot(src) + val pressed by src.collectIsPressedAsState() + val scale by animateFloatAsState(if (pressed) 0.985f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "tileScale") + val download = e.storeKey?.let { StoresState.download(it) }?.takeIf { it.isActive } + val placed = remember { arrayOfNulls(1) } + val corner = with(androidx.compose.ui.platform.LocalDensity.current) { 10.dp.toPx() } + val wide = entryArt(e, tall = false) + val tall = entryArt(e, tall = true) + RequestSize(e) + val open = { + val art = if (capsule) tall else wide + placed[0]?.takeIf { it.isAttached }?.let { StoresMotion.markCard(CardMark(e.key, it.boundsInRoot(), corner, (art as? java.io.File)?.path ?: art as? String)) } + onOpen(e.key) + } + Column( + modifier = Modifier.fillMaxWidth().onGloballyPositioned { placed[0] = it } + .then(navigation).paneItem("card:${e.key}").then(if (first) Modifier.firstTile().paneEntry() else Modifier).graphicsLayer { scaleX = scale; scaleY = scale } + .clip(RoundedCornerShape(10.dp)).glideBorder(hot, RoundedCornerShape(10.dp), pal.signal, pal.line) + .hoverable(src).clickable(role = Role.Button, interactionSource = src, indication = LocalIndication.current, onClick = open) + .controllerConfirm(onClick = choose ?: open), + ) { + CardFace(e.title, wide, tall, download, hot, capsule, dim = !e.installed && download == null, + badge = { SourceChip(e.source, small = true, modifier = Modifier.align(Alignment.TopStart).padding(6.dp)) }) { + LibraryMarker(e, download) + } + } +} + +@Composable +private fun RequestSize(e: LibraryEntry) { + val ctx = LocalContext.current + val item = e.item?.takeIf { !e.installed && it.accessible && it.sizeBytes <= 0 } ?: return + LaunchedEffect(item.key) { StoreSizes.request(ctx, item) } +} + +@Composable +private fun LibraryMarker(e: LibraryEntry, download: DownloadEntry?) { + val pal = LocalPalette.current + when { + download != null -> Text("${download.percent}%", fontSize = 10.sp, fontWeight = FontWeight.Bold, color = pal.signal, maxLines = 1) + e.installed -> Unit + e.item != null && StoresState.isUnfinished(e.item) -> Text(stringResource(R.string.stores_resume), fontSize = 10.sp, fontWeight = FontWeight.Bold, color = pal.signal, maxLines = 1) + else -> Icon(Icons.Outlined.Download, stringResource(R.string.stores_section_not_installed), tint = Color.White, modifier = Modifier.size(12.dp)) + } +} + +@Composable +private fun LibraryRow(e: LibraryEntry, first: Boolean, navigation: Modifier, choose: (() -> Unit)?, onOpen: (String) -> Unit) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val src = remember { MutableInteractionSource() } + val hot = rememberHot(src) + val download = e.storeKey?.let { StoresState.download(it) }?.takeIf { it.isActive } + val placed = remember { arrayOfNulls(1) } + val corner = with(androidx.compose.ui.platform.LocalDensity.current) { 10.dp.toPx() } + val wide = entryArt(e, tall = false) + val open = { + placed[0]?.takeIf { it.isAttached }?.let { StoresMotion.markCard(CardMark(e.key, it.boundsInRoot(), corner, (wide as? java.io.File)?.path ?: wide as? String)) } + onOpen(e.key) + } + RequestSize(e) + val inst = e.storeKey?.let { k -> StoresState.installed.firstOrNull { it.key == k } } + val folder = inst?.folder ?: e.game?.gameFiles + val size = if (e.installed && folder != null) diskSize(inst, folder) else e.item?.let { StoreSizes.size(it) }?.takeIf { it > 0 } + val status = when { + download != null -> downloadLabel(download) + !e.installed -> stringResource(R.string.stores_section_not_installed) + e.game != null -> playedSpan(e.game.lastPlayed)?.replaceFirstChar { it.uppercase() } ?: stringResource(R.string.games_never_played) + else -> stringResource(R.string.stores_installed_chip) + } + val meta = listOfNotNull(status, size?.let { formatBytes(it) }?.takeIf { it.isNotEmpty() }) + Row( + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxWidth().heightIn(min = 64.dp).onGloballyPositioned { placed[0] = it }.then(navigation).paneItem("card:${e.key}") + .then(if (first) Modifier.firstTile().paneEntry() else Modifier) + .clip(RoundedCornerShape(10.dp)).glideBorder(hot, RoundedCornerShape(10.dp), pal.signal, pal.line) + .hoverable(src).clickable(role = Role.Button, interactionSource = src, indication = LocalIndication.current, onClick = open) + .controllerConfirm(onClick = choose ?: open) + .padding(horizontal = 10.dp, vertical = 8.dp), + ) { + Box(Modifier.width(96.dp).height(54.dp).clip(RoundedCornerShape(8.dp)).background(artBrush(hueOf(e.title))).graphicsLayer { alpha = if (e.installed || download != null) 1f else 0.55f }) { + if (wide != null) AsyncImage(model = wide, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.matchParentSize()) + } + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text(e.title, fontSize = 14.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + SourceChip(e.source, small = true) + Text(meta.joinToString(" · "), fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + if (download != null) Text("${download.percent}%", fontSize = 11.sp, fontWeight = FontWeight.Bold, color = pal.signal, maxLines = 1) + else if (!e.installed) Icon(Icons.Outlined.Download, null, tint = colors.onSurfaceVariant, modifier = Modifier.size(16.dp)) + } +} + +@Composable +private fun HeaderIconButton(description: String, icon: androidx.compose.ui.graphics.vector.ImageVector, key: String, onClick: () -> Unit) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val src = remember { MutableInteractionSource() } + val hot = rememberHot(src) + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.paneItem("btn:$key").size(32.dp).clip(CircleShape).background(if (hot) pal.signal.copy(alpha = 0.14f) else Color.Transparent) + .glideBorder(hot, CircleShape, pal.signal) + .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick) + .controllerConfirm(onClick = onClick) + .semantics { contentDescription = description }, + ) { Icon(icon, null, tint = if (hot) pal.signal else colors.onSurfaceVariant, modifier = Modifier.size(18.dp)) } +} + +/** Choosing Library in the rail lands on the first game, as Play Steam does on the Steam page. */ +@Composable +private fun Modifier.paneEntry(): Modifier { + val ff = LocalFrontFocus.current ?: return this + androidx.compose.runtime.DisposableEffect(ff) { + ff.primaryAttached++ + onDispose { ff.primaryAttached-- } + } + return focusRequester(ff.primary) +} + +internal fun keepsXbox(filter: String, e: LibraryEntry): Boolean = when { + filter == XboxLibraryFilter.ALL || e.source != Store.XBOX.id -> true + e.game?.library == Library.XBOX_CLOUD -> filter == XboxLibraryFilter.CLOUD + else -> e.item?.let { XboxLibraryFilter.keeps(filter, it) } == true +} + +@Composable +private fun xboxFilterLabel(filter: String): String = when (filter) { + XboxLibraryFilter.OWNED -> stringResource(R.string.game_install_access_owned) + XboxLibraryFilter.GAME_PASS -> stringResource(R.string.xbox_filter_game_pass) + XboxLibraryFilter.CLOUD -> stringResource(R.string.xbox_filter_cloud) + else -> stringResource(R.string.stores_tab_all) +} + +@Composable +private fun XboxFilterButton() { + val pal = LocalPalette.current + val ctx = LocalContext.current + val title = stringResource(R.string.xbox_filter_show) + var at by remember { mutableStateOf(null) } + Box(Modifier.onGloballyPositioned { at = it.boundsInRoot() }) { + HeaderIconButton(title + " · " + xboxFilterLabel(XboxLibraryFilter.current), Icons.Filled.FilterList, "xbox-filter") { + val from = at ?: return@HeaderIconButton + Steps.ask(StepAsk( + anchor = from, side = StepSide.Left, accent = pal.signal, pillCorner = 8.dp, items = XboxLibraryFilter.ALL_FILTERS.size, + handle = { Text(title, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = pal.signal, maxLines = 1, overflow = TextOverflow.Ellipsis) }, + ) { + StepTitle(title) + StepList { + XboxLibraryFilter.ALL_FILTERS.forEachIndexed { i, filter -> + StepChoice( + xboxFilterLabel(filter), selected = filter == XboxLibraryFilter.current, enabled = open, + modifier = Modifier.fillMaxWidth().stepItem(i).then(if (filter == XboxLibraryFilter.current) Modifier.focusRequester(first) else Modifier), + ) { + XboxLibraryFilter.set(ctx, filter) + Steps.fold() + } + } + } + }) + } + } +} + +@Composable +private fun SortButton(s: FrontEndState, a: FrontEndActions) { + val pal = LocalPalette.current + val title = stringResource(R.string.stores_sort) + var at by remember { mutableStateOf(null) } + Box(Modifier.onGloballyPositioned { at = it.boundsInRoot() }) { + HeaderIconButton(title, Icons.Filled.Sort, "sort") { + val from = at ?: return@HeaderIconButton + Steps.ask(StepAsk( + anchor = from, side = StepSide.Left, accent = pal.signal, pillCorner = 8.dp, items = SessionPrefs.STORES_SORTS.size, + handle = { Text(title, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = pal.signal, maxLines = 1, overflow = TextOverflow.Ellipsis) }, + ) { + StepTitle(title) + StepList { + SessionPrefs.STORES_SORTS.forEachIndexed { i, sort -> + val label = when (sort) { + "lastPlayed" -> stringResource(R.string.stores_sort_last_played) + "recentlyInstalled" -> stringResource(R.string.stores_sort_recently_installed) + "titleAsc" -> stringResource(R.string.stores_sort_title_asc) + "titleDesc" -> stringResource(R.string.stores_sort_title_desc) + "sizeDesc" -> stringResource(R.string.stores_sort_size_desc) + "sizeAsc" -> stringResource(R.string.stores_sort_size_asc) + else -> stringResource(R.string.stores_sort_release_date) + } + StepChoice( + label, selected = sort == s.storesSort, enabled = open, + modifier = Modifier.fillMaxWidth().stepItem(i).then(if (sort == s.storesSort) Modifier.focusRequester(first) else Modifier), + ) { + a.onStoresSort(sort) + Steps.fold() + } + } + } + }) + } + } +} + +@Composable +private fun ViewButton(s: FrontEndState, a: FrontEndActions) { + val views = SessionPrefs.STORES_VIEWS + val next = views[(views.indexOf(s.storesView) + 1) % views.size] + HeaderIconButton( + stringResource(when (next) { "capsules" -> R.string.stores_view_capsules; "list" -> R.string.stores_view_list; else -> R.string.stores_view_cards }), + when (next) { "capsules" -> Icons.Filled.ViewModule; "list" -> Icons.Filled.ViewList; else -> Icons.Filled.GridView }, + "view", + ) { a.onStoresView(next) } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoreAchievementsUi.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoreAchievementsUi.kt index 1492362f1..e2ab2a77f 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StoreAchievementsUi.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StoreAchievementsUi.kt @@ -270,6 +270,23 @@ internal fun StoreAchievementsCardContent(label: String, state: AchievementsStat } } +@Composable +internal fun AchievementsTab(label: String, state: AchievementsState, modifier: Modifier = Modifier) { + val colors = MaterialTheme.colorScheme + val list = state.list + Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = modifier.fillMaxWidth().tabPanel()) { + if (list.isNullOrEmpty()) { + Text(achievementsNote(state, label).orEmpty(), fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.readable().padding(4.dp)) + return@Column + } + Box(Modifier.fillMaxWidth().readable().padding(4.dp)) { AchievementsProgress(state) } + for ((group, items) in groups(list)) { + AchievementGroupLabel(stringResource(group, items.size)) + for (a in items) androidx.compose.runtime.key(a.id) { AchievementRow(a, compact = true) } + } + } +} + private fun LazyListScope.achievementItems(list: List, compact: Boolean, first: FocusRequester, labels: Boolean = true) { var index = 0 for ((label, group) in groups(list)) { diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoresDetail.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoresDetail.kt deleted file mode 100644 index 37dbd3cf7..000000000 --- a/app/src/main/java/com/droiddeck/launcher/ui/StoresDetail.kt +++ /dev/null @@ -1,329 +0,0 @@ -package com.droiddeck.launcher.ui - -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.layout.boundsInRoot -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.foundation.background -import androidx.compose.foundation.focusable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.gestures.animateScrollBy -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.input.key.onPreviewKeyEvent -import kotlinx.coroutines.launch -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import coil.compose.AsyncImage -import com.droiddeck.launcher.R -import com.droiddeck.launcher.stores.xbox.xboxCloud -import com.droiddeck.launcher.stores.xbox.xboxPc -import com.droiddeck.launcher.stores.Store -import com.droiddeck.launcher.stores.StoreAchievements -import com.droiddeck.launcher.stores.StoresState -import com.droiddeck.launcher.stores.download.DownloadState -import com.droiddeck.launcher.stores.formatBytes - - -@Composable -internal fun StoreGameDetail(store: Store, key: String, s: FrontEndState, a: FrontEndActions, lead: androidx.compose.ui.focus.FocusRequester, onBack: () -> Unit) { - val leadOn = Modifier.focusRequester(lead) - val ctx = LocalContext.current - val colors = MaterialTheme.colorScheme - val pal = LocalPalette.current - val narrow = LocalNarrowPane.current - val stacked = narrow || LocalConfiguration.current.screenWidthDp < 700 - val id = key.substringAfter(':') - val item = StoresState.detailItem(store, id) - val installed = StoresState.installedGame(store, id) - val rawDownload = StoresState.download("${store.id}:$id") - val download = rawDownload?.takeIf { it.isActive } - val failedDownload = rawDownload?.takeIf { it.state == DownloadState.FAILED } - val title = item?.title ?: installed?.sidecar?.title ?: id - Rise(0) { - Box(Modifier.padding(bottom = 12.dp)) { BackLink(stringResource(R.string.common_back), compact = true, onClick = onBack) } - } - val scores = if (store == Store.GOG || store == Store.EPIC) rememberGameScores(title.takeIf { it != id }) else null - val hero: @Composable () -> Unit = { - androidx.compose.runtime.DisposableEffect(Unit) { onDispose { StoresMotion.heroAt = null } } - Box( - Modifier.fillMaxWidth().heightIn(min = if (narrow) 150.dp else 180.dp) - .onGloballyPositioned { StoresMotion.heroAt = it.boundsInRoot() } - .clip(Shape16).background(artBrush(hueOf(title))), - ) { - val art = item?.imageUrl ?: installed?.sidecar?.hero ?: installed?.sidecar?.cover - // The card it came from keeps its own bitmap under the hero until the wide art is decoded. - val heroArt = cardArtRequest(art, item?.tallImageUrl) - if (heroArt != null) AsyncImage(model = heroArt, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.matchParentSize()) - Spacer(Modifier.matchParentSize().background(Brush.horizontalGradient(0f to colors.background.copy(alpha = 0.94f), 0.5f to colors.background.copy(alpha = 0.55f), 1f to Color.Transparent))) - if (scores != null && !stacked) { - Spacer(Modifier.matchParentSize().background(Brush.horizontalGradient(0.45f to Color.Transparent, 1f to colors.background.copy(alpha = 0.75f)))) - HeroScores(scores, Modifier.align(Alignment.BottomEnd).padding(end = 16.dp, bottom = 18.dp)) - } - Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.align(Alignment.BottomStart).padding(horizontal = 22.dp, vertical = 18.dp)) { - val passName = item?.includedWith.orEmpty() - val state = when { - installed != null -> stringResource(R.string.stores_eyebrow_installed) + " · " + com.droiddeck.launcher.stores.StoreInstallRoot.labelFor(ctx, installed.folder) - download != null && download.location.isNotBlank() -> stringResource(R.string.stores_eyebrow_library) + " · " + download.location - failedDownload != null && failedDownload.location.isNotBlank() -> stringResource(R.string.stores_eyebrow_library) + " · " + failedDownload.location - item?.owned == true -> stringResource(R.string.stores_eyebrow_library) - item?.subscription == true -> passName.ifBlank { "PC Game Pass" } - passName.isNotBlank() -> "Available with $passName" - item?.accessible == true -> stringResource(R.string.stores_eyebrow_library) - else -> stringResource(R.string.stores_eyebrow_store) - } - val size = (item?.sizeBytes ?: 0L).takeIf { it > 0 }?.let { " · " + formatBytes(it) } ?: "" - Text((state + size).uppercase(), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.2.sp, color = pal.signal) - Text(title, fontSize = if (narrow) 24.sp else 30.sp, lineHeight = if (narrow) 27.sp else 33.sp, fontWeight = FontWeight.Black, color = colors.onBackground, maxLines = 2, overflow = TextOverflow.Ellipsis) - if (!failedDownload?.error.isNullOrBlank()) { - Text( - failedDownload!!.error!!, - fontSize = 13.sp, - lineHeight = 17.sp, - fontWeight = FontWeight.Medium, - color = colors.error, - maxLines = 3, - overflow = TextOverflow.Ellipsis - ) - } - Actions { - when { - installed != null -> { - val gone = StoresState.uninstalling["${store.id}:$id"] - PrimaryButton(stringResource(R.string.stores_play), enabled = !s.busy && gone == null && download == null && (store != Store.XBOX || !com.droiddeck.launcher.stores.xbox.XboxContent.busy(id)), main = true, icon = Icons.Filled.PlayArrow, modifier = leadOn) { - launchStoreGame(ctx, store, id, s, a) - } - ActionChip(stringResource(R.string.stores_in_steam), ok = true) - if (store == Store.XBOX && item?.xboxCloud == true) XboxStreamButton(item) - if (store == Store.XBOX) { - SecondaryButton("Update / verify", enabled = download == null && !com.droiddeck.launcher.stores.xbox.XboxRuntime.isActive(id)) { - runCatching { com.droiddeck.launcher.stores.xbox.XboxBackend.update(ctx, installed) } - .onFailure { StoresState.problems[Store.XBOX] = it.message ?: "Close this Xbox game before updating it." } - } - SecondaryButton("Manage content", enabled = download == null) { - com.droiddeck.launcher.stores.xbox.XboxContent.manage(ctx, installed) - } - } - if (gone != null) SecondaryButton(stringResource(R.string.stores_uninstalling), enabled = false, progress = gone) {} - else UninstallButton(title, installed, enabled = download == null && (store != Store.XBOX || !com.droiddeck.launcher.stores.xbox.XboxContent.busy(id))) { - StoresState.uninstall(ctx, installed) { StoresState.refresh(ctx); a.onLibraryChanged() } - } - } - download != null -> { - ActionChip(downloadLabel(download), ok = false) - if (download.state != DownloadState.PAUSED) SecondaryButton(stringResource(R.string.stores_dl_pause), modifier = leadOn) { com.droiddeck.launcher.stores.download.DownloadQueue.pause(download.key) } - else PrimaryButton(stringResource(R.string.stores_dl_resume), main = true, modifier = leadOn) { com.droiddeck.launcher.stores.download.DownloadQueue.resume(ctx, download.key) } - ConfirmButton(stringResource(R.string.stores_dl_cancel), stringResource(R.string.stores_dl_cancel_confirm)) { com.droiddeck.launcher.stores.download.DownloadQueue.cancel(ctx, download.key) } - } - failedDownload != null -> { - ActionChip(stringResource(R.string.stores_dl_failed), ok = false) - PrimaryButton(stringResource(R.string.stores_dl_resume), main = true, modifier = leadOn) { - if (item != null) StoresState.requestInstall(ctx, item) - else com.droiddeck.launcher.stores.download.DownloadQueue.retry(ctx, failedDownload.key) - } - ConfirmButton(stringResource(R.string.stores_dl_clear), stringResource(R.string.stores_dl_cancel_confirm), compact = true) { - com.droiddeck.launcher.stores.download.DownloadQueue.clear(ctx, failedDownload.key) - } - } - store == Store.XBOX && item != null && !item.xboxPc && item.xboxCloud -> { - XboxStreamButton(item, main = true, modifier = leadOn) - XboxAddToGamesButton(item) - } - item?.installable == true && StoresState.isUnfinished(item) -> { - InstallButton(stringResource(R.string.stores_resume_install), leadOn) { StoresState.requestInstall(ctx, item) } - ConfirmButton(stringResource(R.string.stores_dl_clear), stringResource(R.string.stores_dl_cancel_confirm), compact = true) { StoresState.clearUnfinished(ctx, item) } - } - item?.installable == true -> { - InstallButton( - if (item.sizeBytes > 0) stringResource(R.string.stores_install_size, formatBytes(item.sizeBytes)) else stringResource(R.string.stores_install), - leadOn, - ) { StoresState.requestInstall(ctx, item) } - if (store == Store.XBOX && item.xboxCloud) { - XboxStreamButton(item) - XboxAddToGamesButton(item) - } - if (!item.accessible) { - SecondaryButton( - when { - item.isFree -> stringResource(R.string.stores_get_free) - item.hasPrice && item.finalPrice.isNotBlank() -> stringResource(R.string.stores_buy_price, item.finalPrice) - else -> stringResource(R.string.stores_view_on, store.shortLabel) - }, - ) { openStoreUrl(ctx, item) } - } - } - item != null && item.isFree -> PrimaryButton(stringResource(R.string.stores_get_free), main = true, modifier = leadOn) { openStoreUrl(ctx, item) } - item != null -> PrimaryButton(if (item.hasPrice && item.finalPrice.isNotBlank()) stringResource(R.string.stores_buy_price, item.finalPrice) else stringResource(R.string.stores_view_on, store.shortLabel), main = true, modifier = leadOn) { openStoreUrl(ctx, item) } - } - BusyChip(s) - } - } - } - } - if (StoresMotion.heroLands) hero() else Rise(1) { hero() } - if (scores != null) Rise(2) { - val listed = (StoresState.found[store].orEmpty() + StoresState.shelves[store]?.all.orEmpty()).firstOrNull { it.id == id } - val developer = item?.developer?.ifBlank { null } ?: listed?.developer.orEmpty() - val publisher = item?.publisher?.ifBlank { null } ?: listed?.publisher.orEmpty() - val releaseDate = item?.releaseDate?.ifBlank { null } ?: listed?.releaseDate.orEmpty() - StoreGameFacts(developer, publisher, releaseDate, scores, scoresInHero = !stacked) - } - val description = item?.let { com.droiddeck.launcher.stores.cleanStoreText(it.description) }.orEmpty() - .takeUnless { it.trim().equals(title.trim(), ignoreCase = true) }.orEmpty() - val achievements = remember(store, id, item?.accessible, installed) { - when { - StoreAchievements.forSource(store.id) == null -> null - item?.accessible == true -> StoreAchievements.Game.of(item) - installed != null -> StoreAchievements.Game.of(installed.sidecar) - else -> null - } - } - if (description.isNotBlank() || achievements != null) Rise(3) { - val about: @Composable (Modifier, Dp) -> Unit = { m, h -> if (description.isNotBlank()) StoreAbout(description, h, m) } - val card: @Composable (Modifier) -> Unit = { m -> if (achievements != null) StoreAchievementsCard(achievements, title, m) } - if (stacked) Column(verticalArrangement = Arrangement.spacedBy(14.dp), modifier = Modifier.fillMaxWidth().padding(top = 14.dp)) { - about(Modifier.fillMaxWidth(), AboutHeight) - card(Modifier.fillMaxWidth()) - } else Row(horizontalArrangement = Arrangement.spacedBy(24.dp), modifier = Modifier.fillMaxWidth().padding(top = 14.dp)) { - val density = LocalDensity.current - var cardHeight by remember { mutableStateOf(null) } - about(Modifier.weight(1f), cardHeight ?: AboutHeight) - card(Modifier.width(340.dp).onSizeChanged { cardHeight = with(density) { it.height.toDp() } }) - } - } -} - -private val AboutHeight = 150.dp - -@Composable -private fun StoreAbout(text: String, height: Dp, modifier: Modifier = Modifier) { - val colors = MaterialTheme.colorScheme - val pal = LocalPalette.current - val scroll = rememberScrollState() - val scope = rememberCoroutineScope() - val src = remember { MutableInteractionSource() } - val more = scroll.maxValue > 0 - val hot = more && rememberHot(src) - val shape = RoundedCornerShape(12.dp) - Box( - modifier.heightIn(max = height).clip(shape) - .glideBorder(hot, shape, pal.signal, restColor = Color.Transparent) - .then(if (more) Modifier.paneItem("about") else Modifier) - .focusable(enabled = more, interactionSource = src) - .onPreviewKeyEvent { e -> - val step = when (e.nativeKeyEvent.keyCode) { - android.view.KeyEvent.KEYCODE_DPAD_DOWN -> if (scroll.value < scroll.maxValue) 120f else return@onPreviewKeyEvent false - android.view.KeyEvent.KEYCODE_DPAD_UP -> if (scroll.value > 0) -120f else return@onPreviewKeyEvent false - else -> return@onPreviewKeyEvent false - } - if (e.nativeKeyEvent.action == android.view.KeyEvent.ACTION_DOWN) scope.launch { scroll.animateScrollBy(step) } - true - }, - ) { - Text(text, fontSize = 14.sp, lineHeight = 21.sp, color = colors.onSurfaceVariant, modifier = Modifier.verticalScroll(scroll).padding(horizontal = 10.dp, vertical = 6.dp)) - if (scroll.value < scroll.maxValue) Box( - Modifier.align(Alignment.BottomCenter).fillMaxWidth().height(28.dp) - .background(Brush.verticalGradient(listOf(Color.Transparent, colors.background))), - ) - } -} - -@Composable -private fun UninstallButton(title: String, game: com.droiddeck.launcher.stores.InstalledStoreGame, enabled: Boolean, modifier: Modifier = Modifier, onUninstall: () -> Unit) { - val colors = MaterialTheme.colorScheme - val placed = remember { arrayOfNulls(1) } - val heading = stringResource(R.string.stores_uninstall_title, title) - val size = com.droiddeck.launcher.stores.StoreSizes.onDisk["${game.sidecar.store.id}:${game.sidecar.id}"] - val body = if (size != null && size > 0) stringResource(R.string.stores_uninstall_body_size, formatBytes(size)) else stringResource(R.string.stores_uninstall_body) - val keep = stringResource(R.string.stores_notification_keep) - val remove = stringResource(R.string.stores_uninstall) - Box(Modifier.onGloballyPositioned { placed[0] = it }) { - SecondaryButton(remove, enabled = enabled, modifier = modifier) { - val at = placed[0]?.takeIf { it.isAttached }?.boundsInRoot() ?: return@SecondaryButton - var confirmed = false - StoresMotion.ask(StepAsk(anchor = at, side = StepSide.Right, accent = colors.error, items = 3, pillCorner = 12.dp, maxWidth = 340.dp, - onDismiss = { if (confirmed) onUninstall() }, handle = { - Text(remove, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.error, maxLines = 1) - }) { - StepTitle(heading) - Text(body, fontSize = 13.sp, lineHeight = 18.sp, color = colors.onSurfaceVariant, modifier = Modifier.stepItem(1).padding(start = 4.dp, bottom = 12.dp)) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), modifier = Modifier.fillMaxWidth().stepItem(2)) { - StepChoice(keep, enabled = open, modifier = Modifier.focusRequester(first)) { StoresMotion.fold() } - StepChoice(remove, danger = true, enabled = open) { confirmed = true; StoresMotion.fold() } - } - }) - } - } -} - -@Composable -private fun InstallButton(label: String, modifier: Modifier = Modifier, onInstall: () -> Unit) { - val placed = remember { arrayOfNulls(1) } - Box(Modifier.onGloballyPositioned { placed[0] = it }) { - PrimaryButton(label, main = true, modifier = modifier) { - StoresMotion.markInstall(placed[0]?.takeIf { it.isAttached }?.boundsInRoot(), label) - onInstall() - } - } -} - -@Composable -private fun XboxStreamButton(item: com.droiddeck.launcher.stores.CatalogItem, main: Boolean = false, modifier: Modifier = Modifier) { - val ctx = LocalContext.current - androidx.compose.runtime.LaunchedEffect(Unit) { com.droiddeck.launcher.stores.xbox.XboxCloudGames.prefetch(ctx) } - val stream = { com.droiddeck.launcher.stores.xbox.XboxCloudGames.stream(ctx, item.id, item.title) } - if (main) PrimaryButton("Stream", main = true, icon = Icons.Filled.PlayArrow, modifier = modifier) { stream() } - else SecondaryButton("Stream", modifier = modifier) { stream() } -} - -@Composable -private fun XboxAddToGamesButton(item: com.droiddeck.launcher.stores.CatalogItem) { - val ctx = LocalContext.current - androidx.compose.runtime.LaunchedEffect(Unit) { com.droiddeck.launcher.stores.xbox.XboxCloudGames.load(ctx) } - // Add and Remove replace each other; the controller focus moves to the new button instead of getting lost. - val slot = remember { androidx.compose.ui.focus.FocusRequester() } - val inputMode = androidx.compose.ui.platform.LocalInputModeManager.current - val scope = rememberCoroutineScope() - fun refocus() { if (inputMode.inputMode == androidx.compose.ui.input.InputMode.Keyboard) scope.launch { focusSoon { slot } } } - if (com.droiddeck.launcher.stores.xbox.XboxCloudGames.isAdded(item.id)) { - ActionChip("In Games", ok = true) - SecondaryButton("Remove from Games", compact = true, modifier = Modifier.focusRequester(slot)) { - com.droiddeck.launcher.stores.xbox.XboxCloudGames.remove(ctx, item.id); refocus() - } - } else SecondaryButton("Add to Games", modifier = Modifier.focusRequester(slot)) { com.droiddeck.launcher.stores.xbox.XboxCloudGames.add(ctx, item); refocus() } -} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoresDownloads.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoresDownloads.kt index 2bf7dcac0..8c7c92f52 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StoresDownloads.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StoresDownloads.kt @@ -498,16 +498,10 @@ internal fun storesSettingsStep(at: Rect, s: androidx.compose.runtime.State = if (store == Store.AMAZON) TABS.take(1) else TABS - -internal fun tabFor(store: Store?, tab: String): String = if (tab in tabsFor(store)) tab else "library" - -private data class PaneKey(val chip: String, val tab: String, val signedIn: Boolean, val accountsLoaded: Boolean) +private data class PaneKey(val chip: String, val signedIn: Boolean, val accountsLoaded: Boolean) @OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class, androidx.compose.animation.ExperimentalAnimationApi::class) @Composable @@ -180,13 +150,13 @@ internal fun StoresPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier com.droiddeck.launcher.stores.xbox.XboxContent.plan?.let { content -> androidx.compose.material3.AlertDialog( onDismissRequest = { com.droiddeck.launcher.stores.xbox.XboxContent.dismiss() }, - title = { Text("Xbox content") }, + title = { Text(stringResource(R.string.stores_xbox_content_title)) }, text = { Column { if (content.error != null) Text(content.error) - else if (content.offers.isEmpty()) Text("No licensed PC content packages are available for this game.") + else if (content.offers.isEmpty()) Text(stringResource(R.string.stores_xbox_content_none)) content.offers.forEach { offer -> androidx.compose.material3.TextButton(onClick = { com.droiddeck.launcher.stores.xbox.XboxContent.install(ctx, offer.item) }) { - Text(offer.item.title + if (offer.installed) " · Update / verify" else " · Install") + Text(stringResource(if (offer.installed) R.string.stores_xbox_content_update else R.string.stores_xbox_content_install, offer.item.title)) } } } }, @@ -196,17 +166,16 @@ internal fun StoresPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier com.droiddeck.launcher.stores.xbox.XboxBackend.packageChoice?.let { pending -> androidx.compose.material3.AlertDialog( onDismissRequest = { com.droiddeck.launcher.stores.xbox.XboxBackend.dismissPackages() }, - title = { Text("Choose PC content") }, + title = { Text(stringResource(R.string.stores_xbox_choose_content)) }, text = { Column { pending.choices.forEach { choice -> androidx.compose.material3.TextButton(onClick = { com.droiddeck.launcher.stores.xbox.XboxBackend.selectPackage(ctx, choice) }) { - Text(choice.getString("title") + " (" + choice.getString("kind") + ")") + Text(stringResource(R.string.stores_xbox_choice, choice.getString("title"), choice.getString("kind"))) } } } }, confirmButton = {}, dismissButton = { androidx.compose.material3.TextButton(onClick = { com.droiddeck.launcher.stores.xbox.XboxBackend.dismissPackages() }) { Text(stringResource(android.R.string.cancel)) } }, ) } - val colors = MaterialTheme.colorScheme val narrow = LocalNarrowPane.current val padH = if (narrow) 14.dp else 22.dp val tabs = remember { @@ -248,7 +217,6 @@ internal fun StoresPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier val picked = Store.byId(chip) ?: return@LaunchedEffect if (tabs.none { it.store == picked && it.shown }) chip = tabs.firstOrNull { it.shown }?.store?.id ?: chip } - var tab by rememberSaveable { mutableStateOf(if (SessionPrefs.storesOpenTab(ctx) == SessionPrefs.STORES_OPEN_STORE) "store" else "library") } var query by rememberSaveable { mutableStateOf("") } var openGame by rememberSaveable { mutableStateOf(null) } val liveState = androidx.compose.runtime.rememberUpdatedState(s) @@ -257,9 +225,8 @@ internal fun StoresPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier LaunchedEffect(tabs.toList()) { if (chip != DOWNLOADS && Store.byId(chip) !in shownStores) chip = shownStores.firstOrNull()?.id ?: DOWNLOADS } - val shownTab = tabFor(store, tab) val pane = PaneKey( - chip, if (store == null) "" else shownTab, + chip, store == null || (StoresState.isSignedIn(store) && StoresState.expired[store] != true), store == null || StoresState.accountsLoaded, ) @@ -271,8 +238,8 @@ internal fun StoresPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier if (chip == Store.EPIC.id) { EpicClient.ensure(ctx) while (true) { - delay(5_000) if (lifecycle.currentState.isAtLeast(androidx.lifecycle.Lifecycle.State.RESUMED)) EpicClient.refresh(ctx) + delay(5_000) } } } @@ -286,25 +253,20 @@ internal fun StoresPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier fun move(kind: String) { focusMove = kind to (focusMove?.second ?: 0) + 1 } fun open(key: String) { focusManager.clearFocus(force = true); lastOpened = key; openGame = key; move("detail") } fun closeGame() { openGame = null; move("card") } - fun switchTab(to: String) { tab = to; move("first") } val pickChip: (String) -> Unit = { id -> chip = id; openGame = null; query = "" - if (id != DOWNLOADS) tab = tabFor(Store.byId(id), if (s.storesOpenTab == SessionPrefs.STORES_OPEN_STORE) "store" else "library") move("first") } LaunchedEffect(StoresState.openGameRequest) { val key = StoresState.openGameRequest ?: return@LaunchedEffect StoresState.openGameRequest = null - Store.byId(key.substringBefore(':'))?.let { store -> chip = store.id; tab = tabFor(store, "library"); query = ""; open(key) } + Store.byId(key.substringBefore(':'))?.let { store -> chip = store.id; query = ""; open(key) } } var focusedChip by remember { mutableStateOf(null) } LaunchedEffect(focusedChip) { val id = focusedChip ?: return@LaunchedEffect delay(140) - if (id != chip) { - chip = id; openGame = null; query = "" - if (id != DOWNLOADS) tab = tabFor(Store.byId(id), if (s.storesOpenTab == SessionPrefs.STORES_OPEN_STORE) "store" else "library") - } + if (id != chip) { chip = id; openGame = null; query = "" } } val pal = LocalPalette.current fun cycleStore(step: Int) { @@ -329,8 +291,7 @@ internal fun StoresPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier } val scroll = rememberScrollState() val detailScroll = rememberScrollState() - LaunchedEffect(chip, tab) { scroll.scrollTo(0); grid.scrollToItem(0) } - LaunchedEffect(openGame) { detailScroll.scrollTo(0) } + LaunchedEffect(chip) { scroll.scrollTo(0); grid.scrollToItem(0) } LaunchedEffect(focusMove, openGame) { if (inputMode.inputMode != androidx.compose.ui.input.InputMode.Keyboard || ff == null) return@LaunchedEffect var frames = 0 @@ -366,53 +327,21 @@ internal fun StoresPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier } } - var detail by remember { mutableStateOf(null) } - var leaving by remember { mutableStateOf(false) } - var detailFrom by remember { mutableStateOf(null) } - var detailArt by remember { mutableStateOf(null) } - var viewportAt by remember { mutableStateOf(Offset.Zero) } - var viewportH by remember { mutableStateOf(0.dp) } - val density = androidx.compose.ui.platform.LocalDensity.current var hostAt by remember { mutableStateOf(Offset.Zero) } - LaunchedEffect(openGame) { - val g = openGame - if (g != null) { - val mark = StoresMotion.takeCard(g) - detailFrom = mark?.let { Origin(it.bounds.translate(-viewportAt), it.corner) } - detailArt = mark?.art - StoresMotion.heroLands = mark != null - leaving = false - detail = g - } else if (detail != null) { - if (detailFrom != null) { - leaving = true - delay(Motion.ms(PAGE_RETURN_MS).toLong()) - } - detail = null - leaving = false - detailFrom = null - } - } - val covering = detail != null && !leaving - val coveringNow = androidx.compose.runtime.rememberUpdatedState(covering) - val gridScale by animateFloatAsState(if (covering) 0.95f else 1f, if (covering) Motion.tw(480) else Motion.tw(460, 40), label = "gridSink") - val gridAlpha by animateFloatAsState(if (covering) 0f else 1f, if (covering) Motion.tw(320, 80) else Motion.tw(300, 100), label = "gridFade") - + val backLabel = stringResource(R.string.common_back) Box(modifier.onGloballyPositioned { hostAt = it.positionInRoot() }) { Column( modifier = Modifier.fillMaxSize().padding(horizontal = padH, vertical = if (narrow) 10.dp else 14.dp) - .bumpers( + .then(if (openGame != null && !arranging) Modifier else Modifier.bumpers( onPrevious = { val picked = Store.byId(focusedChip) - if (arranging) { if (picked != null) shiftTab(picked, -1) } - else if (openGame == null) cycleStore(-1) + if (arranging) { if (picked != null) shiftTab(picked, -1) } else cycleStore(-1) }, onNext = { val picked = Store.byId(focusedChip) - if (arranging) { if (picked != null) shiftTab(picked, 1) } - else if (openGame == null) cycleStore(1) + if (arranging) { if (picked != null) shiftTab(picked, 1) } else cycleStore(1) }, - ), + )), ) { Box(Modifier.fillMaxWidth().padding(bottom = 8.dp)) { StoreTheme(Store.byId(focusedChip ?: chip)) { @@ -430,108 +359,52 @@ internal fun StoresPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier } } } - val fade = with(androidx.compose.ui.platform.LocalDensity.current) { 14.dp.toPx() } - Box( - Modifier.fillMaxWidth().weight(1f).clipToBounds() - .onGloballyPositioned { viewportAt = it.positionInRoot(); viewportH = with(density) { it.size.height.toDp() } } - .graphicsLayer { compositingStrategy = androidx.compose.ui.graphics.CompositingStrategy.Offscreen } - .drawWithContent { - drawContent() - val scrolled = if (detail != null) detailScroll.value > 0 - else if (store != null && pane.signedIn) grid.firstVisibleItemIndex > 0 || grid.firstVisibleItemScrollOffset > 0 - else scroll.value > 0 - if (scrolled && size.height > fade) drawRect( - Brush.verticalGradient(0f to Color.Transparent, fade / size.height to Color.Black), - blendMode = androidx.compose.ui.graphics.BlendMode.DstIn, - ) - }, - ) { - val gridFocus = if (covering) null else ff - CompositionLocalProvider(LocalFrontFocus provides gridFocus) { - Column( - modifier = Modifier.fillMaxSize() - .graphicsLayer { scaleX = gridScale; scaleY = gridScale; alpha = gridAlpha } - .focusProperties { enter = { if (coveringNow.value) androidx.compose.ui.focus.FocusRequester.Cancel else androidx.compose.ui.focus.FocusRequester.Default } } - .focusGroup(), - ) { - AnimatedContent( - targetState = pane, - modifier = Modifier.fillMaxSize(), - transitionSpec = { - val sameStore = initialState.chip == targetState.chip && initialState.signedIn == targetState.signedIn - (if (sameStore && initialState.tab != targetState.tab) { - val dir = if (TABS.indexOf(targetState.tab) > TABS.indexOf(initialState.tab)) 1 else -1 - (slideInHorizontally(Motion.sp(0.7f, Spring.StiffnessMediumLow)) { it / 6 * dir } + fadeIn(Motion.tw(220, 60))) - .togetherWith(slideOutHorizontally(Motion.tw(170)) { -it / 6 * dir } + fadeOut(Motion.tw(170)) + scaleOut(Motion.tw(170), targetScale = 0.97f)) - } else { - (fadeIn(Motion.tw(300, 80)) + slideInVertically(Motion.tw(420, 80)) { it / 24 }) - .togetherWith(fadeOut(Motion.tw(170)) + scaleOut(Motion.tw(170), targetScale = 0.97f)) - }).using(SizeTransform(clip = false)) - }, - label = "storesPane", - ) { k -> - val outgoing = transition.targetState == androidx.compose.animation.EnterExitState.PostExit - CompositionLocalProvider(LocalFrontFocus provides if (outgoing) null else LocalFrontFocus.current) { - val leavingNow = androidx.compose.runtime.rememberUpdatedState(outgoing) - val st = Store.byId(k.chip) - StoreTheme(st) { - Column( - Modifier.fillMaxSize() - .focusProperties { enter = { if (leavingNow.value) androidx.compose.ui.focus.FocusRequester.Cancel else androidx.compose.ui.focus.FocusRequester.Default } } - .focusGroup(), - ) { - when { - k.chip == DOWNLOADS -> Column(Modifier.fillMaxWidth().verticalScroll(scroll).padding(top = 4.dp, bottom = 16.dp)) { StoresDownloadsPane(s, a) } - st == null -> {} - !k.accountsLoaded -> Box(Modifier.fillMaxWidth().padding(top = 16.dp)) { - androidx.compose.material3.LinearProgressIndicator(Modifier.fillMaxWidth().height(3.dp)) - } - !k.signedIn -> Box(Modifier.fillMaxWidth().verticalScroll(scroll).padding(top = 4.dp, bottom = 16.dp)) { SignInCard(st, viewportH - 20.dp) } - else -> Storefront( - st, k.tab, query, s, a, grids.getOrPut(k) { LazyGridState() }, - clearSearchFocus = openGame != null, - onQuery = { query = it }, onOpen = { open(it) }, onTab = { switchTab(it) }, - ) + var viewportH by remember { mutableStateOf(0.dp) } + val density = androidx.compose.ui.platform.LocalDensity.current + CardDetailHost( + openGame, detailScroll, + gridScrolled = { + if (store != null && pane.signedIn) grid.firstVisibleItemIndex > 0 || grid.firstVisibleItemScrollOffset > 0 + else scroll.value > 0 + }, + onDetailFocus = { detailFocused = it }, + modifier = Modifier.fillMaxWidth().weight(1f).onSizeChanged { viewportH = with(density) { it.height.toDp() } }, + wrap = { key, content -> StoreTheme(Store.byId(key.substringBefore(':')) ?: store) { content() } }, + grid = { + AnimatedContent( + targetState = pane, + modifier = Modifier.fillMaxSize(), + transitionSpec = { + ((fadeIn(Motion.tw(300, 80)) + slideInVertically(Motion.tw(420, 80)) { it / 24 }) + .togetherWith(fadeOut(Motion.tw(170)) + scaleOut(Motion.tw(170), targetScale = 0.97f))).using(SizeTransform(clip = false)) + }, + label = "storesPane", + ) { k -> + val outgoing = transition.targetState == androidx.compose.animation.EnterExitState.PostExit + CompositionLocalProvider(LocalFrontFocus provides if (outgoing) null else LocalFrontFocus.current) { + val leavingNow = androidx.compose.runtime.rememberUpdatedState(outgoing) + val st = Store.byId(k.chip) + StoreTheme(st) { + Column( + Modifier.fillMaxSize() + .focusProperties { enter = { if (leavingNow.value) androidx.compose.ui.focus.FocusRequester.Cancel else androidx.compose.ui.focus.FocusRequester.Default } } + .focusGroup(), + ) { + when { + k.chip == DOWNLOADS -> Column(Modifier.fillMaxWidth().verticalScroll(scroll).padding(top = 4.dp, bottom = 16.dp)) { StoresDownloadsPane(s, a) } + st == null -> {} + !k.accountsLoaded -> Box(Modifier.fillMaxWidth().padding(top = 16.dp)) { + androidx.compose.material3.LinearProgressIndicator(Modifier.fillMaxWidth().height(3.dp)) } - } + !k.signedIn -> Box(Modifier.fillMaxWidth().verticalScroll(scroll).padding(top = 4.dp, bottom = 16.dp)) { SignInCard(st, viewportH - 20.dp) } + else -> Storefront(st, query, s, a, grids.getOrPut(k) { LazyGridState() }, clearSearchFocus = openGame != null, onQuery = { query = it }, onOpen = { open(it) }) + } } - } + } } } - } - detail?.let { key -> - val detailStore = Store.byId(key.substringBefore(':')) ?: store - StoreTheme(detailStore) { - val body: @Composable () -> Unit = { - Column( - Modifier.fillMaxSize().pointerInput(Unit) { detectTapGestures { } } - .verticalScroll(detailScroll).padding(top = 4.dp, bottom = 16.dp), - ) { - if (detailStore != null) Column( - Modifier.fillMaxWidth() - .onFocusChanged { detailFocused = it.hasFocus } - // A step opened from the page (install location, uninstall) sits outside it and has to receive the focus. - .focusProperties { exit = { if (coveringNow.value && !StoresMotion.stepOpen) androidx.compose.ui.focus.FocusRequester.Cancel else androidx.compose.ui.focus.FocusRequester.Default } } - .focusGroup(), - ) { StoreGameDetail(detailStore, key, s, a, heroFocus, onBack = { closeGame() }) } - } - } - val from = detailFrom - if (from == null) body() - else { - val art = detailArt?.let { url -> - coil.compose.rememberAsyncImagePainter( - remember(url) { - coil.request.ImageRequest.Builder(ctx).data(url).size(1280, 720) - .placeholderMemoryCacheKey(url).build() - }, - ) - } - PageFlood(from, leaving, art, onto = { StoresMotion.heroAt }, ontoCorner = 16.dp) { body() } - } - } - } - } + }, + ) { key -> GameDetail(key, s, a, heroFocus, backLabel, hopRail = null, onBack = { closeGame() }) } } FlightLayer(hostAt) StoresMotion.step?.let { ask -> @@ -553,11 +426,10 @@ internal fun StoresPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier seenActive.clear(); seenActive.addAll(activeKeys) if (fresh.isNotEmpty()) StoresMotion.takeInstall()?.let { StoresMotion.fly(it) } } - StoresState.pendingInstall?.let { item -> StoreTheme(item.store) { InstallWhere(item) } } } @Composable -private fun InstallWhere(item: CatalogItem) { +internal fun InstallWhere(item: CatalogItem) { val ctx = LocalContext.current val pal = LocalPalette.current val from = remember(item) { StoresMotion.recentInstall() } @@ -572,7 +444,7 @@ private fun InstallWhere(item: CatalogItem) { val targets = StoreInstallRoot.targets(ctx) val remembered = SessionPrefs.storesInstallTarget(ctx) val defaultIndex = targets.indexOfFirst { it.root.absolutePath == remembered }.coerceAtLeast(0) - StoresMotion.ask(StepAsk( + Steps.ask(StepAsk( anchor = from, side = StepSide.Right, accent = pal.signal, pillCorner = 12.dp, items = 1 + targets.size, maxWidth = (28 + 192 * targets.size - 8).dp, onDismiss = { if (StoresState.pendingInstall === item) StoresState.pendingInstall = null }, @@ -602,7 +474,7 @@ private fun InstallWhere(item: CatalogItem) { SessionPrefs.setStoresInstallTarget(ctx, t.root.absolutePath) StoresMotion.markInstall(from, label) StoresState.install(ctx, item, t.root) - StoresMotion.fold() + Steps.fold() } } } @@ -950,86 +822,32 @@ internal fun StoreLogo(store: Store, size: Dp) { @Composable private fun Storefront( - store: Store, tab: String, query: String, s: FrontEndState, a: FrontEndActions, + store: Store, query: String, s: FrontEndState, a: FrontEndActions, grid: LazyGridState, clearSearchFocus: Boolean, - onQuery: (String) -> Unit, onOpen: (String) -> Unit, onTab: (String) -> Unit, + onQuery: (String) -> Unit, onOpen: (String) -> Unit, ) { val ctx = LocalContext.current - val colors = MaterialTheme.colorScheme val library = StoresState.library[store] ?: emptyList() val shelves = StoresState.shelves[store] val found = StoresState.found[store] ?: emptyList() - val installedItems = remember(StoresState.installed, library) { com.droiddeck.launcher.stores.installedCards(store, StoresState.installed, library) } - val installedKeys = remember(installedItems) { installedItems.map { it.id }.toSet() + StoresState.installed.filter { it.sidecar.store == store }.map { it.sidecar.id } } + val installedKeys = remember(StoresState.installed) { StoresState.installed.filter { it.sidecar.store == store }.map { it.sidecar.id }.toSet() } val q = query.trim().lowercase() val foundKeys = remember(found) { found.map { it.key }.toSet() } - fun matches(i: CatalogItem) = q.isEmpty() || i.title.lowercase().contains(q) || i.key in foundKeys fun shown(i: CatalogItem) = s.storesShowMature || !i.mature || i.accessible fun shelf(l: List?) = l?.filter(::shown) - val everything = remember(library, shelves, found) { (found + library + (shelves?.all ?: emptyList())).distinctBy { it.id } } LaunchedEffect(store, q) { StoresState.search(ctx, store, q) } - val loaded = StoresState.library.containsKey(store) - val browsing = store == Store.ITCH && tab == "store" && q.isEmpty() && ItchBrowseState.active - val showGrid = tab != "store" || q.isNotEmpty() || browsing - val sort = s.storesSort - val lastPlayedFor = remember(s.steamGames) { - val byKey = s.steamGames.associate { "${it.source}:${it.storeId}" to it.lastPlayed } - val lookup: (CatalogItem) -> Long = { i -> byKey[i.key] ?: 0L } - lookup - } - val installedAtFor = remember(StoresState.installed) { - val byKey = StoresState.installed.associate { "${it.sidecar.store.id}:${it.sidecar.id}" to it.sidecar.installedAt } - val lookup: (CatalogItem) -> Long = { i -> byKey[i.key] ?: 0L } - lookup - } - val isSizeSort = tab == "library" && (sort == "sizeAsc" || sort == "sizeDesc") + val browsing = store == Store.ITCH && q.isEmpty() && ItchBrowseState.active val browseItems = if (browsing) ItchBrowseState.items else emptyList() - LaunchedEffect(Unit) { XboxLibraryFilter.load(ctx) } - val xboxFilter = if (store == Store.XBOX) XboxLibraryFilter.current else XboxLibraryFilter.ALL - val candidates = remember(library, everything, tab, q, s.storesShowMature, browsing, browseItems, xboxFilter) { - if (tab == "library") library.filter { matches(it) && XboxLibraryFilter.keeps(xboxFilter, it) } - else if (browsing) { + val visible = remember(found, library, shelves, q, s.storesShowMature, browsing, browseItems) { + if (browsing) { val owned = library.associateBy { it.id } browseItems.map { owned[it.id] ?: it }.filter(::shown) - } else everything.filter { matches(it) && shown(it) } + } else if (q.isEmpty()) emptyList() + else (found + library + (shelves?.all ?: emptyList())).distinctBy { it.id } + .filter { (it.title.lowercase().contains(q) || it.key in foundKeys) && shown(it) } } - val candidateKeys = remember(candidates) { candidates.map { it.key } } - var resolvedSizeSortKey by remember(store, tab) { mutableStateOf?>(null) } - val sizeSortPending = isSizeSort && resolvedSizeSortKey != candidateKeys - var stableVisible by remember(store, tab) { mutableStateOf(emptyList()) } - val resolvedSizes = if (isSizeSort) com.droiddeck.launcher.stores.StoreSizes.known.toMap() else emptyMap() - val sizeFor = remember(resolvedSizes) { - val resolved = resolvedSizes - val lookup: (CatalogItem) -> Long = { item -> resolved[item.key] ?: item.sizeBytes } - lookup - } - val sortedVisible = remember(candidates, tab, sort, lastPlayedFor, installedAtFor, sizeFor, installedKeys) { - if (tab == "library") installedFirst(sortCatalogItems(candidates, sort, lastPlayed = lastPlayedFor, installedAt = installedAtFor, size = sizeFor), installedKeys) - else candidates - } - val visible = remember(sizeSortPending, candidates, stableVisible, sortedVisible) { - if (sizeSortPending) retainCatalogOrder(candidates, stableVisible) else sortedVisible - } - val installedShown = if (tab == "library") visible.count { it.id in installedKeys } else 0 - val split = installedShown.takeIf { it in 1 until visible.size } ?: 0 - SideEffect { - if (!sizeSortPending && stableVisible != sortedVisible) stableVisible = sortedVisible - } - LaunchedEffect(store, tab, sort, candidates, candidateKeys) { - if (!isSizeSort || resolvedSizeSortKey == candidateKeys) return@LaunchedEffect - val requests = candidates.map { item -> - item to com.droiddeck.launcher.stores.StoreSizes.resolve(ctx, item) - } - CompletableFuture.allOf(*requests.map { it.second }.toTypedArray()).await() - requests.forEach { (item, future) -> - val bytes = future.getNow(null) ?: return@forEach - if (item.accessible && item.sizeBytes <= 0) com.droiddeck.launcher.stores.StoreSizes.known[item.key] = bytes - } - resolvedSizeSortKey = candidateKeys - } - val empty = stringResource(when { - tab == "library" && library.isEmpty() && StoresState.status[store] != null -> R.string.stores_library_loading + val nothing = stringResource(when { browsing && ItchBrowseState.loading -> R.string.stores_shelves_loading browsing && ItchBrowseState.failed -> R.string.stores_itch_browse_failed else -> R.string.stores_nothing_matches @@ -1039,19 +857,16 @@ private fun Storefront( val density = androidx.compose.ui.platform.LocalDensity.current val focus = LocalFrontFocus.current val scope = rememberCoroutineScope() - // A bar only where the tab has nothing else on it: shelves, the shelf of owned games, or a grid still loading. - val gridEmpty = if (showGrid) visible.isEmpty() else (shelves == null || shelves.isEmpty) && library.isEmpty() - val barLine = StoresState.status[store]?.takeIf { gridEmpty && syncCount(it) != null } - val itchBar = store == Store.ITCH && tab == "store" && q.isEmpty() - val headerItems = 1 + (if (sizeSortPending) 1 else 0) + (if (barLine != null) 1 else 0) + - (if (StoresState.problems[store] != null) 1 else 0) + (if (itchBar) 1 else 0) - val listMode = s.storesView == "list" && tab == "library" - val capsules = s.storesView == "capsules" && tab == "library" - val tileWidth = if (capsules) CapsuleWidth else CardWidth + val searching = q.isNotEmpty() || browsing + // A bar only while there is nothing else to look at: no shelves yet and no search results. + val bareTab = if (searching) visible.isEmpty() else shelves == null || shelves.isEmpty + val barLine = StoresState.status[store]?.takeIf { bareTab && syncCount(it) != null } + val itchBar = store == Store.ITCH && q.isEmpty() + val headerItems = 2 + (if (barLine != null) 1 else 0) + (if (StoresState.problems[store] != null) 1 else 0) + (if (itchBar) 1 else 0) val card: @Composable (CatalogItem) -> Unit = { item -> - val first = if (showGrid) item.key == visible.firstOrNull()?.key else !firstPlaced + val first = if (searching) item.key == visible.firstOrNull()?.key else !firstPlaced firstPlaced = true - val navigation = if (!showGrid || focus == null) Modifier else Modifier.onPreviewKeyEvent { event -> + val navigation = if (!searching || focus == null) Modifier else Modifier.onPreviewKeyEvent { event -> val key = event.nativeKeyEvent val direction = when (key.keyCode) { KeyEvent.KEYCODE_DPAD_DOWN -> 1 @@ -1059,14 +874,14 @@ private fun Storefront( else -> 0 } val index = visible.indexOfFirst { it.key == item.key } - val next = storeGridNeighbor(index, visible.size, if (listMode) 1 else columns, direction, split) + val next = storeGridNeighbor(index, visible.size, columns, direction) when { direction == 0 || index < 0 -> false next == null -> direction > 0 else -> { if (key.action == KeyEvent.ACTION_DOWN) { val target = "card:${visible[next].key}" - val targetIndex = headerItems + next + if (split == 0) 0 else if (next < split) 1 else 2 + val targetIndex = headerItems + next val visibleTarget = grid.layoutInfo.visibleItemsInfo.any { it.index == targetIndex } if (!visibleTarget || (focus.attached[target] ?: 0) == 0 || runCatching { focus.items.getValue(target).requestFocus() }.isFailure) scope.launch { @@ -1078,131 +893,132 @@ private fun Storefront( } } } - if (listMode) GameListRow(item, store, installedKeys, onOpen, first, navigation) - else GameCard(item, store, s, a, installedKeys, onOpen, first, navigation, capsule = capsules) + GameCard(item, store, installedKeys, onOpen, first, navigation) } LazyVerticalGrid( - columns = if (listMode) GridCells.Fixed(1) else GridCells.Adaptive(tileWidth), state = grid, modifier = Modifier.fillMaxSize().focusGroup().onSizeChanged { + columns = GridCells.Adaptive(CardWidth), state = grid, modifier = Modifier.fillMaxSize().focusGroup().onSizeChanged { val gap = with(density) { 10.dp.roundToPx() } - val minimum = with(density) { (if (listMode) CardWidth * 2 else tileWidth).roundToPx() } - columns = ((it.width + gap) / (minimum + gap)).toInt().coerceAtLeast(1) + val minimum = with(density) { CardWidth.roundToPx() } + columns = ((it.width + gap) / (minimum + gap)).coerceAtLeast(1) }, horizontalArrangement = Arrangement.spacedBy(10.dp), verticalArrangement = Arrangement.spacedBy(10.dp), contentPadding = PaddingValues(top = 4.dp, bottom = 16.dp), ) { - item(key = "header", span = { GridItemSpan(maxLineSpan) }) { - Rise(1) { - val tabs = tabsFor(store) - val libraryName = if (xboxFilter == XboxLibraryFilter.ALL) stringResource(R.string.stores_tab_library) - else stringResource(R.string.stores_tab_library) + " · " + XboxLibraryFilter.label(xboxFilter) - val libraryLabel = if (loaded) stringResource(R.string.stores_tab_count, libraryName, library.count { XboxLibraryFilter.keeps(xboxFilter, it) }) - else libraryName - ModeHeader( - libraryLabel, - query, onQuery, - placeholder = if (tab == "store") stringResource(R.string.stores_search_catalog, store.label) else stringResource(R.string.stores_search_library), - clearSearchFocus = clearSearchFocus, - lead = if (tabs.size < 2) null else { { - val labels = tabs.map { if (it == "store") stringResource(R.string.stores_tab_store) else libraryLabel } - HeaderTabs(labels, tabs.indexOf(tab).coerceAtLeast(0)) { onTab(tabs[it]) } - } }, - ) { - if (tab == "library") { - if (store == Store.XBOX) XboxFilterButton() - SortButton(s, a) - ViewButton(s, a) - } - if (store == Store.EPIC) SmallTextButton(stringResource(R.string.epic_client_title)) { - if (EpicClient.status.installed) a.onEpicLauncher() - else { EpicClient.install(ctx); StoresState.openDownloads = true } - } - SmallTextButton(stringResource(R.string.stores_refresh)) { StoresState.open(ctx, store, force = true) } - if (store == Store.XBOX) SmallTextButton("Cloud settings") { - ctx.startActivity(android.content.Intent(ctx, com.droiddeck.launcher.stores.xbox.XboxCloudSettingsActivity::class.java)) - } - val name = StoresState.accounts[store] ?: "" - SignOutButton(store, name) - } - } - } - if (sizeSortPending) item(key = "size-sort-progress", span = { GridItemSpan(maxLineSpan) }) { - Row( - verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), - ) { - androidx.compose.material3.LinearProgressIndicator(Modifier.weight(1f).height(3.dp)) - Text(stringResource(R.string.store_section_loading), fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) - } + item(key = "profile", span = { GridItemSpan(maxLineSpan) }) { Rise(1) { ProfileStrip(store, library.size, StoresState.library.containsKey(store), s, a) } } + item(key = "search", span = { GridItemSpan(maxLineSpan) }) { + Rise(2) { ModeHeader(store.label, query, onQuery, placeholder = stringResource(R.string.stores_search_catalog, store.label), clearSearchFocus = clearSearchFocus) {} } } barLine?.let { line -> item(key = "sync", span = { GridItemSpan(maxLineSpan) }) { Rise(3) { SyncBar(line) } } } StoresState.problems[store]?.let { line -> item(key = "problem", span = { GridItemSpan(maxLineSpan) }) { Rise(3) { Box(Modifier.padding(bottom = 8.dp)) { Note(line) } } } } if (itchBar) item(key = "itch-browse-bar", span = { GridItemSpan(maxLineSpan) }) { ItchBrowseBar() } - when (tab) { - "library" -> if (!loaded) Unit - else if (split == 0) storeGrid(visible, empty, card) - else { - item(key = "installed-label", span = { GridItemSpan(maxLineSpan) }) { SectionLabel(stringResource(R.string.stores_section_installed)) } - storeGrid(visible.take(split), empty, card) - item(key = "not-installed-label", span = { GridItemSpan(maxLineSpan) }) { SectionLabel(stringResource(R.string.stores_section_not_installed)) } - storeGrid(visible.drop(split), empty, card) - } - else -> { - if (q.isNotEmpty() || browsing) { - storeGrid(visible, empty, card) - if (browsing) item(key = "itch-more", span = { GridItemSpan(maxLineSpan) }) { ItchLoadMore() } - } else { - item(key = "shelves", span = { GridItemSpan(maxLineSpan) }) { - Column { - Shelf(stringResource(R.string.stores_shelf_new), shelf(shelves?.whatsNew), card) - Shelf(stringResource(R.string.stores_shelf_deals), shelf(shelves?.deals), card) - Shelf(stringResource(R.string.stores_shelf_free), shelf(shelves?.free), card) - Shelf(stringResource(R.string.stores_shelf_trending), shelf(shelves?.trending), card) - Shelf(stringResource(R.string.stores_shelf_library), library, card) - if (shelves == null && library.isEmpty()) Rise(4) { Note(if (StoresState.status[store] != null) stringResource(R.string.stores_library_loading) else stringResource(R.string.stores_shelves_loading)) } - else if (shelves != null && shelves.isEmpty && store == Store.AMAZON) Rise(4) { Note(stringResource(R.string.stores_amazon_no_catalog)) } - } - } - } + if (searching) { + storeGrid(visible, nothing, card) + if (browsing) item(key = "itch-more", span = { GridItemSpan(maxLineSpan) }) { ItchLoadMore() } + } else item(key = "shelves", span = { GridItemSpan(maxLineSpan) }) { + Column { + Shelf(stringResource(R.string.stores_shelf_new), shelf(shelves?.whatsNew), card) + Shelf(stringResource(R.string.stores_shelf_deals), shelf(shelves?.deals), card) + Shelf(stringResource(R.string.stores_shelf_free), shelf(shelves?.free), card) + Shelf(stringResource(R.string.stores_shelf_trending), shelf(shelves?.trending), card) + if (shelves == null) Rise(4) { Box(Modifier.padding(top = 8.dp)) { Note(stringResource(R.string.stores_shelves_loading)) } } + else if (shelves.isEmpty && store == Store.AMAZON) Rise(4) { Box(Modifier.padding(top = 8.dp)) { Note(stringResource(R.string.stores_amazon_no_catalog)) } } } } } } -private suspend fun CompletableFuture.await(): T = - suspendCancellableCoroutine { continuation -> - whenComplete { value, error -> - if (error == null) continuation.resume(value) - else continuation.resumeWithException(error) - } - } - @Composable -private fun SectionLabel(title: String) { - Text( - headingUppercase(title), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.fillMaxWidth().padding(top = 6.dp, start = 2.dp), - ) +private fun ProfileStrip(store: Store, owned: Int, loaded: Boolean, s: FrontEndState, a: FrontEndActions) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val ctx = LocalContext.current + val show = LocalShowSection.current + val name = StoresState.accounts[store] ?: "" + val c = sourceColours(store.id) + val shape = RoundedCornerShape(16.dp) + Column( + verticalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp).clip(shape).background(colors.surface).border(1.dp, pal.line, shape) + .padding(horizontal = 14.dp, vertical = 10.dp).testTag("stores-profile"), + ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) { + Box(contentAlignment = Alignment.Center, modifier = Modifier.size(36.dp).clip(CircleShape).background(c.dot)) { + Text(name.take(1).uppercase().ifEmpty { store.shortLabel.take(1) }, fontSize = 16.sp, fontWeight = FontWeight.Black, color = if (c.dot.luminance() > 0.5f) Color.Black else Color.White) + } + Column(Modifier.weight(1f)) { + Text(name.ifEmpty { store.label }, fontSize = 15.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text( + if (loaded) store.shortLabel + " · " + androidx.compose.ui.res.pluralStringResource(R.plurals.mode_added_count, owned, owned) else store.label, + fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, + ) + } + Actions { + SmallTextButton(stringResource(R.string.stores_owned_in_library), key = "owned-in-library") { + LibraryView.showOwned(store) + show("games") + } + SmallTextButton(stringResource(R.string.stores_refresh)) { StoresState.open(ctx, store, force = true) } + if (store == Store.XBOX) SmallTextButton(stringResource(R.string.stores_xbox_cloud_settings), key = "xbox-cloud-settings") { + ctx.startActivity(android.content.Intent(ctx, com.droiddeck.launcher.stores.xbox.XboxCloudSettingsActivity::class.java)) + } + SignOutButton(store, name) + } + } + if (store == Store.EPIC) EpicLauncherRow(s, a) + } } @Composable -private fun HeaderTabs(labels: List, selected: Int, onSelect: (Int) -> Unit) { +private fun EpicLauncherRow(s: FrontEndState, a: FrontEndActions) { + val ctx = LocalContext.current val colors = MaterialTheme.colorScheme val pal = LocalPalette.current - val shape = RoundedCornerShape(8.dp) - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.selectableGroup()) { - labels.forEachIndexed { i, label -> - val src = remember { MutableInteractionSource() } - val hot = rememberHot(src) - val pick = { onSelect(i) } + val epic = EpicClient.status + val title = stringResource(R.string.epic_client_title) + Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line)) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth().testTag("stores-epic-launcher")) { + val icon by androidx.compose.runtime.produceState(null, epic.version) { + value = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { EpicClient.icon(ctx, epic.version) } + } + if (icon != null) AsyncImage(model = icon, contentDescription = null, contentScale = ContentScale.Fit, modifier = Modifier.size(36.dp).clip(RoundedCornerShape(8.dp))) + Column(Modifier.weight(1f)) { + Text(title, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis) + val version = epic.version?.substringBefore('+') Text( - label, fontSize = 15.sp, fontWeight = FontWeight.Bold, maxLines = 1, - color = if (i == selected || hot) colors.onBackground else colors.onSurfaceVariant.copy(alpha = 0.7f), - modifier = Modifier.clip(shape).glideBorder(hot, shape, pal.signal) - .hoverable(src).selectable(selected = i == selected, interactionSource = src, indication = LocalIndication.current, role = Role.Tab, onClick = pick) - .controllerConfirm(onClick = pick) - .padding(horizontal = 4.dp, vertical = 6.dp), + if (epic.installed && version != null) stringResource(R.string.epic_client_version, version) else stringResource(R.string.epic_client_missing), + fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, ) } + Actions { + if (epic.installed) { + SecondaryButton(stringResource(R.string.epic_client_open), enabled = !s.busy, compact = true) { a.onEpicLauncher() } + val entry = remember(epic, title) { EpicClient.launcherEntry(ctx, title, epic, null) } + val scope = entry?.let { com.droiddeck.launcher.session.NoSteamLaunch.scope(it) } + if (entry != null && scope != null) { + val host = rememberMenuHost() + val settings = rememberGameSettings(scope) + Box { + SecondaryButton(stringResource(R.string.game_settings_card), compact = true) { + host.open = if (host.open == GAME_SETTINGS_MENU) null else GAME_SETTINGS_MENU + } + GameSettingsMenus(host, entry, settings) + } + } + UninstallEpicLauncher(s, a) + } else SecondaryButton(stringResource(R.string.epic_client_install), compact = true) { + EpicClient.install(ctx) + StoresState.openDownloads = true + } + } + } +} + +@Composable +internal fun SectionLabel(title: String) { + val pal = LocalPalette.current + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(top = 6.dp, start = 2.dp)) { + Text(headingUppercase(title), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1) + Box(Modifier.weight(1f).height(1.dp).background(pal.line)) } } @@ -1283,169 +1099,24 @@ internal fun StoreTheme(store: Store?, content: @Composable () -> Unit) { } @Composable -private fun SmallTextButton(text: String, key: String = text, onClick: () -> Unit) { - val colors = MaterialTheme.colorScheme - val pal = LocalPalette.current - val src = remember { MutableInteractionSource() } - val hot = rememberHot(src) - Text( - text, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = if (hot) colors.onBackground else colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = Modifier.paneItem("btn:$key").clip(RoundedCornerShape(8.dp)).glideBorder(hot, RoundedCornerShape(8.dp), pal.signal) - .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick) - .controllerConfirm(onClick = onClick).padding(horizontal = 8.dp, vertical = 6.dp), - ) -} - -@Composable -private fun HeaderIconButton(description: String, icon: androidx.compose.ui.graphics.vector.ImageVector, key: String, onClick: () -> Unit) { +private fun SmallTextButton(text: String, key: String = text, danger: Boolean = false, onClick: () -> Unit) { val colors = MaterialTheme.colorScheme val pal = LocalPalette.current val src = remember { MutableInteractionSource() } val hot = rememberHot(src) + val shape = RoundedCornerShape(10.dp) Box( contentAlignment = Alignment.Center, - modifier = Modifier.paneItem("btn:$key").size(32.dp).clip(CircleShape).background(if (hot) pal.signal.copy(alpha = 0.14f) else Color.Transparent) - .glideBorder(hot, CircleShape, pal.signal) + modifier = Modifier.paneItem("btn:$key").heightIn(min = 40.dp).clip(shape) + .background(if (hot) pal.signal.copy(alpha = 0.14f) else Color.White.copy(alpha = 0.03f)) + .glideBorder(hot, shape, pal.signal, pal.line2) .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick) - .controllerConfirm(onClick = onClick), - ) { Icon(icon, description, tint = if (hot) pal.signal else colors.onSurfaceVariant, modifier = Modifier.size(18.dp)) } -} - -@Composable -private fun SortButton(s: FrontEndState, a: FrontEndActions) { - val pal = LocalPalette.current - val title = stringResource(R.string.stores_sort) - var at by remember { mutableStateOf(null) } - Box(Modifier.onGloballyPositioned { at = it.boundsInRoot() }) { - HeaderIconButton(title, Icons.Filled.Sort, "sort") { - val from = at ?: return@HeaderIconButton - StoresMotion.ask(StepAsk( - anchor = from, side = StepSide.Left, accent = pal.signal, pillCorner = 8.dp, items = SessionPrefs.STORES_SORTS.size, - onDismiss = {}, - handle = { Text(title, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = pal.signal, maxLines = 1, overflow = TextOverflow.Ellipsis) }, - ) { - StepTitle(title) - StepList { - SessionPrefs.STORES_SORTS.forEachIndexed { i, sort -> - val label = when (sort) { - "lastPlayed" -> stringResource(R.string.stores_sort_last_played) - "recentlyInstalled" -> stringResource(R.string.stores_sort_recently_installed) - "titleAsc" -> stringResource(R.string.stores_sort_title_asc) - "titleDesc" -> stringResource(R.string.stores_sort_title_desc) - "sizeDesc" -> stringResource(R.string.stores_sort_size_desc) - "sizeAsc" -> stringResource(R.string.stores_sort_size_asc) - else -> stringResource(R.string.stores_sort_release_date) - } - StepChoice( - label, selected = sort == s.storesSort, enabled = open, - modifier = Modifier.fillMaxWidth().stepItem(i) - .then(if (sort == s.storesSort) Modifier.focusRequester(first) else Modifier), - ) { - a.onStoresSort(sort) - StoresMotion.fold() - } - } - } - }) - } - } -} - -@Composable -private fun XboxFilterButton() { - val pal = LocalPalette.current - val ctx = LocalContext.current - val title = "Show" - var at by remember { mutableStateOf(null) } - Box(Modifier.onGloballyPositioned { at = it.boundsInRoot() }) { - HeaderIconButton(title, Icons.Filled.FilterList, "xbox-filter") { - val from = at ?: return@HeaderIconButton - StoresMotion.ask(StepAsk( - anchor = from, side = StepSide.Left, accent = pal.signal, pillCorner = 8.dp, items = XboxLibraryFilter.ALL_FILTERS.size, - onDismiss = {}, - handle = { Text(title, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = pal.signal, maxLines = 1, overflow = TextOverflow.Ellipsis) }, - ) { - StepTitle(title) - StepList { - XboxLibraryFilter.ALL_FILTERS.forEachIndexed { i, filter -> - StepChoice( - XboxLibraryFilter.label(filter), selected = filter == XboxLibraryFilter.current, enabled = open, - modifier = Modifier.fillMaxWidth().stepItem(i) - .then(if (filter == XboxLibraryFilter.current) Modifier.focusRequester(first) else Modifier), - ) { - XboxLibraryFilter.set(ctx, filter) - StoresMotion.fold() - } - } - } - }) - } - } -} - -@Composable -private fun ViewButton(s: FrontEndState, a: FrontEndActions) { - val views = SessionPrefs.STORES_VIEWS - val next = views[(views.indexOf(s.storesView) + 1) % views.size] - HeaderIconButton( - stringResource(when (next) { "capsules" -> R.string.stores_view_capsules; "list" -> R.string.stores_view_list; else -> R.string.stores_view_cards }), - when (next) { "capsules" -> Icons.Filled.ViewModule; "list" -> Icons.Filled.ViewList; else -> Icons.Filled.GridView }, - "view", - ) { a.onStoresView(next) } -} - -@Composable -private fun GameListRow( - item: CatalogItem, store: Store, installedKeys: Set, onOpen: (String) -> Unit, - first: Boolean = false, navigation: Modifier = Modifier, -) { - val colors = MaterialTheme.colorScheme - val pal = LocalPalette.current - val src = remember { MutableInteractionSource() } - val hot = rememberHot(src) - val installed = item.id in installedKeys - val download = StoresState.download("${store.id}:${item.id}")?.takeIf { it.isActive } - if (installed) { - val ctx = LocalContext.current - val game = StoresState.installedGame(store, item.id) - if (game != null) androidx.compose.runtime.LaunchedEffect(item.key) { com.droiddeck.launcher.stores.StoreSizes.requestDisk(ctx, item.key, game) } - } - val placed = remember { arrayOfNulls(1) } - val corner = with(androidx.compose.ui.platform.LocalDensity.current) { 10.dp.toPx() } - val open = { - placed[0]?.takeIf { it.isAttached }?.let { StoresMotion.markCard(CardMark(item.key, it.boundsInRoot(), corner, item.imageUrl ?: item.tallImageUrl)) } - onOpen(item.key) - } - val meta = listOfNotNull( - item.developer.takeIf { it.isNotBlank() }, - item.releaseDate.take(4).takeIf { it.length == 4 }, - when { - installed -> { - val bytes = com.droiddeck.launcher.stores.StoreSizes.onDisk[item.key] - ?: com.droiddeck.launcher.stores.StoreSizes.size(item).takeIf { it > 0 } ?: 0L - formatBytes(bytes).takeIf { it.isNotEmpty() } - } - item.isFree -> stringResource(R.string.stores_free) - item.isDiscounted -> "-${item.discountPercent}%" - item.hasPrice -> item.finalPrice.takeIf { it.isNotBlank() } - else -> null - }, - ) - Row( - verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.fillMaxWidth().heightIn(min = 64.dp).then(navigation).paneItem("card:${item.key}") - .then(if (first) Modifier.firstTile() else Modifier) - .clip(RoundedCornerShape(10.dp)).glideBorder(hot, RoundedCornerShape(10.dp), pal.signal, pal.line) - .hoverable(src).clickable(role = androidx.compose.ui.semantics.Role.Button, interactionSource = src, indication = LocalIndication.current, onClick = open).controllerConfirm(onClick = open) - .padding(horizontal = 10.dp, vertical = 8.dp), + .controllerConfirm(onClick = onClick).padding(horizontal = 12.dp, vertical = 8.dp), ) { - CardArt(item, Modifier.width(96.dp).height(54.dp).clip(RoundedCornerShape(8.dp))) - Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { - Text(item.title, fontSize = 14.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis) - Text(meta.joinToString(" · "), fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) - } - if (installed) Icon(Icons.Filled.Check, stringResource(R.string.stores_installed_chip), tint = pal.good, modifier = Modifier.size(16.dp)) - else if (download != null) Text("${download.percent}%", fontSize = 11.sp, fontWeight = FontWeight.Bold, color = pal.signal, maxLines = 1) + Text( + text, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis, + color = if (danger) colors.error else if (hot) colors.onBackground else colors.onSurfaceVariant, + ) } } @@ -1460,7 +1131,7 @@ private fun SignOutButton(store: Store, name: String) { val go = stringResource(R.string.stores_signout_go) var at by remember { mutableStateOf(null) } Box(Modifier.onGloballyPositioned { at = it.boundsInRoot() }) { - SmallTextButton(label, key = "signout") { + SmallTextButton(label, key = "signout", danger = true) { val from = at if (from == null) { StoresState.signOut(ctx, store); return@SmallTextButton } StoresMotion.ask(StepAsk( @@ -1496,18 +1167,12 @@ internal fun SearchIcon(description: String, onClick: () -> Unit) { @Composable private fun Shelf(title: String, items: List?, card: @Composable (CatalogItem) -> Unit) { if (items.isNullOrEmpty()) return - val colors = MaterialTheme.colorScheme - Text(title, fontSize = 16.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, modifier = Modifier.padding(top = 14.dp, bottom = 6.dp)) + Box(Modifier.padding(top = 10.dp, bottom = 8.dp)) { SectionLabel(title) } Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(vertical = 2.dp)) { for (item in items.take(24)) key(item.key) { Box(Modifier.width(CardWidth)) { card(item) } } } } -internal fun installedFirst(items: List, installed: Set): List { - val (on, off) = items.partition { it.id in installed } - return on + off -} - private fun LazyGridScope.storeGrid(games: List, empty: String, card: @Composable (CatalogItem) -> Unit) { if (games.isEmpty()) item(key = "empty", span = { GridItemSpan(maxLineSpan) }) { Box(Modifier.padding(vertical = 32.dp).fillMaxWidth(), contentAlignment = Alignment.Center) { @@ -1517,16 +1182,15 @@ private fun LazyGridScope.storeGrid(games: List, empty: String, car else items(games, key = { it.key }, contentType = { "game" }) { card(it) } } -private val CardWidth = 112.dp -private val CapsuleWidth = 92.dp +internal val CardWidth = 112.dp +internal val CapsuleWidth = 92.dp @Composable private fun GameCard( - item: CatalogItem, store: Store, s: FrontEndState, a: FrontEndActions, installedKeys: Set, onOpen: (String) -> Unit, - first: Boolean = false, navigation: Modifier = Modifier, capsule: Boolean = false, + item: CatalogItem, store: Store, installedKeys: Set, onOpen: (String) -> Unit, + first: Boolean = false, navigation: Modifier = Modifier, ) { val ctx = LocalContext.current - val colors = MaterialTheme.colorScheme val pal = LocalPalette.current val src = remember { MutableInteractionSource() } val hot = rememberHot(src) @@ -1537,8 +1201,7 @@ private fun GameCard( val placed = remember { arrayOfNulls(1) } val corner = with(androidx.compose.ui.platform.LocalDensity.current) { 10.dp.toPx() } val open = { - val art = if (capsule) item.tallImageUrl ?: item.imageUrl else item.imageUrl ?: item.tallImageUrl - placed[0]?.takeIf { it.isAttached }?.let { StoresMotion.markCard(CardMark(item.key, it.boundsInRoot(), corner, art)) } + placed[0]?.takeIf { it.isAttached }?.let { StoresMotion.markCard(CardMark(item.key, it.boundsInRoot(), corner, item.imageUrl ?: item.tallImageUrl)) } onOpen(item.key) } val screen = with(androidx.compose.ui.platform.LocalDensity.current) { androidx.compose.ui.platform.LocalConfiguration.current.screenHeightDp.dp.toPx() } @@ -1556,11 +1219,11 @@ private fun GameCard( .clip(RoundedCornerShape(10.dp)).glideBorder(hot, RoundedCornerShape(10.dp), pal.signal, pal.line) .hoverable(src).clickable(role = androidx.compose.ui.semantics.Role.Button, interactionSource = src, indication = LocalIndication.current, onClick = open).controllerConfirm(onClick = open), ) { - CardFace(item, download, hot, capsule) { CardMarker(item, installed, download, compact = capsule) } + CardFace(item.title, item.imageUrl, item.tallImageUrl, download, hot) { CardMarker(item, installed, download) } } } -private fun syncCount(line: String): Pair? { +internal fun syncCount(line: String): Pair? { val match = Regex("(\\d+)\\s*/\\s*(\\d+)").find(line) ?: return null val done = match.groupValues[1].toIntOrNull() ?: return null val total = match.groupValues[2].toIntOrNull()?.takeIf { it > 0 } ?: return null @@ -1568,7 +1231,7 @@ private fun syncCount(line: String): Pair? { } @Composable -private fun SyncBar(line: String) { +internal fun SyncBar(line: String) { val count = syncCount(line) ?: return val done = count.first val total = count.second @@ -1581,9 +1244,9 @@ private fun SyncBar(line: String) { private val CardStrip = 26.dp @Composable -internal fun cardArtRequest(url: String?, cover: String?): coil.request.ImageRequest? { +internal fun cardArtRequest(url: Any?, cover: Any?): coil.request.ImageRequest? { val ctx = LocalContext.current - val under = cover?.takeIf { it != url } + val under = (cover as? String)?.takeIf { it != url } return remember(url, under) { url?.let { coil.request.ImageRequest.Builder(ctx).data(it).apply { if (under != null) placeholderMemoryCacheKey(under) }.build() } } @@ -1591,17 +1254,17 @@ internal fun cardArtRequest(url: String?, cover: String?): coil.request.ImageReq @OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class) @Composable -private fun CardFace( - item: CatalogItem, download: com.droiddeck.launcher.stores.download.DownloadEntry?, hot: Boolean, capsule: Boolean = false, - marker: @Composable () -> Unit, +internal fun CardFace( + title: String, wide: Any?, tall: Any?, download: com.droiddeck.launcher.stores.download.DownloadEntry?, hot: Boolean, capsule: Boolean = false, + dim: Boolean = false, badge: (@Composable BoxScope.() -> Unit)? = null, marker: @Composable () -> Unit, ) { - val url = if (capsule) item.tallImageUrl ?: item.imageUrl else item.imageUrl ?: item.tallImageUrl - val art = cardArtRequest(url, item.tallImageUrl) + val url = if (capsule) tall ?: wide else wide ?: tall + val art = cardArtRequest(url, tall) val blurs = android.os.Build.VERSION.SDK_INT >= 31 var landscape by remember(url) { mutableStateOf(false) } BoxWithConstraints(Modifier.fillMaxWidth()) { val total = if (capsule) maxWidth * 7f / 5f + CardStrip else maxWidth * 9f / 16f + CardStrip - Box(Modifier.fillMaxWidth().height(total).background(Color(0xFF101318)).then(if (url == null) Modifier.background(artBrush(hueOf(item.title))) else Modifier)) { + Box(Modifier.fillMaxWidth().height(total).background(Color(0xFF101318)).then(if (url == null) Modifier.background(artBrush(hueOf(title))) else Modifier)) { if (art != null && capsule && landscape) { // Stores without portrait covers (itch) would lose most of the art to a portrait crop. AsyncImage(model = art, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.matchParentSize().blur(24.dp)) @@ -1614,6 +1277,7 @@ private fun CardFace( if (capsule && drawn.intrinsicWidth > 0 && drawn.intrinsicHeight > 0) landscape = drawn.intrinsicWidth > drawn.intrinsicHeight * 0.95f }, ) + if (dim) Spacer(Modifier.matchParentSize().background(Color.Black.copy(alpha = 0.5f))) Box(Modifier.align(Alignment.BottomCenter).fillMaxWidth().height(CardStrip).clipToBounds()) { if (art != null) AsyncImage( model = art, contentDescription = null, contentScale = ContentScale.Crop, @@ -1625,7 +1289,7 @@ private fun CardFace( modifier = Modifier.matchParentSize().padding(horizontal = 8.dp), ) { Text( - item.title, fontSize = 11.sp, fontWeight = FontWeight.Bold, color = Color.White, maxLines = 1, + title, fontSize = 11.sp, fontWeight = FontWeight.Bold, color = Color.White, maxLines = 1, overflow = if (hot) TextOverflow.Clip else TextOverflow.Ellipsis, modifier = Modifier.weight(1f).then(if (hot) Modifier.basicMarquee(iterations = Int.MAX_VALUE, initialDelayMillis = 700, velocity = 40.dp) else Modifier), ) @@ -1636,6 +1300,7 @@ private fun CardFace( if (download != null) Box(Modifier.align(Alignment.BottomCenter).padding(start = 6.dp, end = 6.dp, bottom = CardStrip + 5.dp)) { ProgressBarThin(download.fraction, paused = download.state == DownloadState.PAUSED, verify = download.stage == DownloadStage.VERIFY) } + badge?.invoke(this) } } } @@ -1666,18 +1331,6 @@ private fun CardMarker(item: CatalogItem, installed: Boolean, download: com.droi } } -@Composable -internal fun CardArt(item: CatalogItem, modifier: Modifier) { - val url = item.imageUrl ?: item.tallImageUrl - val art = cardArtRequest(url, item.tallImageUrl) - val colors = MaterialTheme.colorScheme - Box(modifier.background(colors.surfaceVariant).then(if (url == null) Modifier.background(artBrush(hueOf(item.title))) else Modifier)) { - if (art != null) AsyncImage(model = art, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.matchParentSize()) - else Text(item.title, fontSize = 10.sp, fontWeight = FontWeight.Black, color = Color.White, maxLines = 3, overflow = TextOverflow.Ellipsis, modifier = Modifier.align(Alignment.BottomStart).padding(6.dp)) - Spacer(Modifier.matchParentSize().background(androidx.compose.ui.graphics.Brush.verticalGradient(0.6f to Color.Transparent, 1f to colors.surface.copy(alpha = 0.85f)))) - } -} - @Composable internal fun ProgressBarThin(fraction: Float, paused: Boolean = false, verify: Boolean = false, height: Dp = 4.dp, done: Boolean = false) { val pal = LocalPalette.current @@ -1734,7 +1387,7 @@ internal fun openUrl(ctx: android.content.Context, url: String) { internal fun launchStoreGame(ctx: android.content.Context, store: Store, id: String, s: FrontEndState, a: FrontEndActions) { val game = s.steamGames.firstOrNull { it.source == store.id && it.storeId == id } if (game != null) { - com.droiddeck.launcher.stores.StoreLaunch.prepare(ctx, store, id) { a.onSteamGame(game) } + a.onSteamGame(game) return } val app = ctx.applicationContext @@ -1742,7 +1395,7 @@ internal fun launchStoreGame(ctx: android.content.Context, store: Store, id: Str val installed = runCatching { com.droiddeck.launcher.stores.StoreLaunch.installedGame(app, store, id) }.getOrNull() StoresState.post { if (installed == null) android.widget.Toast.makeText(ctx, R.string.stores_launch_not_registered, android.widget.Toast.LENGTH_LONG).show() - else com.droiddeck.launcher.stores.StoreLaunch.prepare(ctx, store, id) { a.onSteamGame(installed) } + else a.onSteamGame(installed) } }, "store-play").start() } diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index fd412f88b..d457db27a 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -1047,8 +1047,6 @@ الألعاب التي تثبّتها في Steam تظهر هنا بعد الجلسة. تشغيل Steam تشغيل SteamVR - إعدادات التشغيل - تشغيل ملفات اللعبة بادئة Proton جارٍ البحث عن الحفظ… @@ -1056,7 +1054,6 @@ %1$d مجلدات حفظ %1$s · %2$d ملفات · %3$s %1$d KB - إدارة الحفظ حفظ اللعبة استيراد الحفظ… تصدير بصيغة GameHub المضغوطة… @@ -1068,7 +1065,6 @@ تُثبَّت بيئة التشغيل عند أول تشغيل يتوفر تحديث لبيئة التشغيل ● بيئة التشغيل جاهزة - آخر تشغيل %1$s التخزين الداخلي لعبة مُضافة لعبة مخصّصة @@ -1137,7 +1133,7 @@ الوحدة الطرفية لـ Linux جلسة Linux ليست جاهزة للوحدة الطرفية بعد. Steam - الألعاب + المكتبة سطح المكتب المتجر المكوّنات @@ -2031,7 +2027,6 @@ Steam مخصَّص المتاجر - فتح متجر على فئة سرعة التنزيل محرك المتاجر ● المحرك جاهز @@ -2045,11 +2040,9 @@ ترتيب التبويبات تم (لم يتم تسجيل الدخول) - المتجر المكتبة الكل التنزيلات - %1$s (%2$d) تسجيل الدخول %1$s · تسجيل الخروج تسجيل الخروج من %1$s؟ @@ -2067,7 +2060,6 @@ العروض ألعاب مجانية الرائج - مكتبتك تشغيل تثبيت استئناف التثبيت @@ -3144,4 +3136,38 @@ يمكن إعادة حساب المشاهدات المحفوظة. ابدأ معايرة جديدة لالتقاطها في نظام إحداثيات التتبع الحالي. المجموع الاختباري لنموذج تتبع اليدين غير مطابق تعذر تحميل OpenCV + غير مثبَّت + فتح + تثبيت + حول + تثبيت + الموقع + مجلد + الحجم على القرص + مثبّت + تحديث / تحقق + إدارة المحتوى + أغلق لعبة Xbox هذه قبل تحديثها. + محتوى Xbox + لا توجد حزم محتوى PC مرخّصة لهذه اللعبة. + %1$s · تحديث / تحقق + %1$s · تثبيت + اختر محتوى PC + متاحة مع %1$s + الألعاب المملوكة في المكتبة + مُضافة + التشغيل + الحفظ والسحابة + اختصار Steam + الإصدار + آخر لعب + الوصول + مملوكة + بث + إضافة إلى المكتبة + إزالة من المكتبة + في المكتبة + عرض + سحابية + إعدادات السحابة diff --git a/app/src/main/res/values-b+es+419/strings.xml b/app/src/main/res/values-b+es+419/strings.xml index 168dd7612..598c110e2 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -1086,8 +1086,6 @@ Los juegos que instales en Steam aparecen aquí después de la sesión. Jugar en Steam Jugar en SteamVR - Ajustes de inicio - Iniciar Archivos del juego Prefijo de Proton Buscando partidas guardadas… @@ -1095,7 +1093,6 @@ %1$d carpetas de guardado %1$s · %2$d archivos · %3$s %1$d KB - Administrar partidas guardadas Partidas guardadas Importar partidas guardadas… Exportar como zip de GameHub… @@ -1107,7 +1104,6 @@ El runtime se instala al jugar Actualización del runtime disponible ● Runtime listo - Última partida %1$s Almacenamiento interno Juego agregado Juego personalizado @@ -1176,7 +1172,7 @@ Terminal de Linux La sesión de Linux aún no está lista para un terminal. Steam - Juegos + Biblioteca Escritorio Tienda Componentes @@ -1999,7 +1995,6 @@ Steam Personalizados Tiendas - Abrir una tienda en Nivel de velocidad de descarga Motor de las tiendas ● Motor listo @@ -2013,11 +2008,9 @@ Organizar pestañas Listo (sin iniciar sesión) - Tienda Biblioteca Todos Descargas - %1$s (%2$d) Iniciar sesión %1$s · cerrar sesión ¿Cerrar sesión en %1$s? @@ -2035,7 +2028,6 @@ Ofertas Juegos gratis En tendencia - Tu biblioteca Jugar Instalar Reanudar instalación @@ -2994,4 +2986,38 @@ Las vistas guardadas se pueden recalcular. Inicie una nueva calibración para capturar en el sistema de coordenadas de seguimiento actual. La suma de comprobación del modelo de mano no coincide OpenCV no se pudo cargar + No instalada + Abrir + Instalar + Acerca de + Instalar + Ubicación + Carpeta + Tamaño en disco + Instalado + Actualizar / verificar + Administrar contenido + Cierra este juego de Xbox antes de actualizarlo. + Contenido de Xbox + No hay paquetes de contenido de PC con licencia para este juego. + %1$s · Actualizar / verificar + %1$s · Instalar + Elegir contenido de PC + disponible con %1$s + Juegos comprados en Biblioteca + Agregados + Inicio + Partidas y nube + Acceso directo de Steam + Versión + Jugado por última vez + Acceso + Comprado + Transmitir + Agregar a Biblioteca + Quitar de Biblioteca + En Biblioteca + Mostrar + Nube + Configuración de nube diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml index be27b7ec1..a0bc668c8 100644 --- a/app/src/main/res/values-bg/strings.xml +++ b/app/src/main/res/values-bg/strings.xml @@ -1023,8 +1023,6 @@ Игрите, които инсталирате в Steam, се показват тук след сесията. Пускане на Steam Пускане на SteamVR - Настройки за стартиране - Стартиране Файлове на играта Префикс на Proton Търсене на записи… @@ -1032,7 +1030,6 @@ %1$d папки за записи %1$s · %2$d файла · %3$s %1$d KB - Управление на записите Записи на играта Импортиране на записи… Експортиране като GameHub zip… @@ -1044,7 +1041,6 @@ Средата за изпълнение се инсталира при първото Play Налична актуализация на средата за изпълнение ● Средата за изпълнение е готова - Последна игра %1$s Вътрешна памет Добавена игра Персонализирана игра @@ -1113,7 +1109,7 @@ Терминал на Linux Сесията на Linux все още не е готова за терминал. Steam - Игри + Библиотека Работен плот Магазин Компоненти @@ -1947,7 +1943,6 @@ Steam Персонализирана Магазини - Отваряне на магазин на Скорост на изтегляне Ядро на магазините ● Ядрото е готово @@ -1961,11 +1956,9 @@ Подреждане на раздели Готово (не е вписан) - Магазин Библиотека Всички Изтегляния - %1$s (%2$d) Вход %1$s · изход Изход от %1$s? @@ -1983,7 +1976,6 @@ Промоции Безплатни игри Актуални - Твоята библиотека Играй Инсталирай Продължи инсталацията @@ -3008,4 +3000,38 @@ Запазените изгледи могат да бъдат преизчислени. Започнете ново калибриране за заснемане в текущата координатна система за проследяване. Несъответствие на контролната сума на модела на ръката OpenCV не можа да се зареди + Не е инсталирано + Отвори + Инсталиране + За приложението + Инсталиране + Местоположение + Папка + Размер на диска + Инсталиран + Обнови / провери + Управление на съдържанието + Затворете тази Xbox игра, преди да я обновите. + Xbox съдържание + Няма лицензирани пакети с PC съдържание за тази игра. + %1$s · Обнови / провери + %1$s · Инсталирай + Изберете PC съдържание + налична с %1$s + Притежавани игри в Библиотека + Добавени + Стартиране + Записи и облак + Пряк път в Steam + Версия + Последно игране + Достъп + Притежавана + Стрийминг + Добави в Библиотека + Премахни от Библиотека + В Библиотека + Показване + Облак + Настройки на облака diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 9ad560cc4..8880695bb 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -1035,8 +1035,6 @@ Hry, které nainstalujete ve Steamu, se zde zobrazí po relaci. Hrát ve Steamu Hrát ve SteamVR - Nastavení spuštění - Spustit Soubory hry Prefix Protonu Hledání uložených pozic… @@ -1044,7 +1042,6 @@ %1$d složek s uloženými pozicemi %1$s · %2$d souborů · %3$s %1$d KB - Správa uložených pozic Uložené pozice hry Importovat uložené pozice… Exportovat jako GameHub zip… @@ -1056,7 +1053,6 @@ Runtime se nainstaluje při prvním spuštění hry K dispozici aktualizace runtime ● Runtime připraven - Poslední hraní %1$s Interní úložiště Přidaná hra Vlastní hra @@ -1125,7 +1121,7 @@ Terminál Linuxu Relace Linuxu ještě není připravena na terminál. Steam - Hry + Knihovna Plocha Obchod Komponenty @@ -1989,7 +1985,6 @@ Steam Vlastní Obchody - Otevřít obchod na Úroveň rychlosti stahování Jádro obchodů ● Jádro je připraveno @@ -2003,11 +1998,9 @@ Uspořádat karty Hotovo (nepřihlášen) - Obchod Knihovna Vše Stahování - %1$s (%2$d) Přihlásit se %1$s · odhlásit Odhlásit se z %1$s? @@ -2025,7 +2018,6 @@ Nabídky Hry zdarma Populární - Vaše knihovna Hrát Instalovat Pokračovat v instalaci @@ -3076,4 +3068,38 @@ Uložená zobrazení lze přepočítat. Spusťte novou kalibraci pro zachycení v aktuálním sledovacím souřadnicovém systému. Neshoda kontrolního součtu modelu ruky OpenCV se nepodařilo načíst + Není nainstalováno + Otevřít + Instalovat + O aplikaci + Instalovat + Umístění + Složka + Velikost na disku + Nainstalováno + Aktualizovat / ověřit + Spravovat obsah + Před aktualizací tuto hru pro Xbox zavřete. + Obsah Xbox + Pro tuto hru nejsou k dispozici žádné licencované balíčky obsahu pro PC. + %1$s · Aktualizovat / ověřit + %1$s · Instalovat + Vyberte obsah pro PC + dostupné s %1$s + Vlastněné hry v knihovně + Přidané + Spuštění + Uložené hry a cloud + Zástupce ve Steamu + Verze + Naposledy hráno + Přístup + Vlastněno + Streamovat + Přidat do knihovny + Odebrat z knihovny + V knihovně + Zobrazit + Cloud + Nastavení cloudu diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 6fa7070bb..853328bfd 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -1023,8 +1023,6 @@ Spil, du installerer i Steam, vises her efter sessionen. Spil Steam Spil SteamVR - Startindstillinger - Start Spilfiler Proton-prefix Søger efter gemte spil… @@ -1032,7 +1030,6 @@ %1$d savemapper %1$s · %2$d filer · %3$s %1$d KB - Administrer gemte spil Gemte spil Importer gemte spil… Eksporter som GameHub-zip… @@ -1044,7 +1041,6 @@ Runtime installeres ved første Spil Runtimeopdatering tilgængelig ● Runtime klar - Sidst spillet %1$s Internt lager Tilføjet spil Brugerdefineret spil @@ -1113,7 +1109,7 @@ Linux-terminal Linux-sessionen er ikke klar til en terminal endnu. Steam - Spil + Bibliotek Skrivebord Butik Komponenter @@ -1947,7 +1943,6 @@ Steam Brugerdefineret Butikker - Åbn en butik på Downloadhastighed Butiksmotor ● Motor klar @@ -1961,11 +1956,9 @@ Arranger faner Færdig (ikke logget ind) - Butik Bibliotek Alle Downloads - %1$s (%2$d) Log ind %1$s · log ud Log ud af %1$s? @@ -1983,7 +1976,6 @@ Tilbud Gratis spil Populært - Dit bibliotek Spil Installer Genoptag installation @@ -3008,4 +3000,38 @@ Gemte visninger kan genberegnes. Start en ny kalibrering for at fange i det aktuelle sporingskoordinatsystem. Håndmodel kontrolsum uoverensstemmelse OpenCV kunne ikke indlæse + Ikke installeret + Åbn + Installer + Om + Installer + Placering + Mappe + Størrelse på disk + Installeret + Opdater / bekræft + Administrer indhold + Luk dette Xbox-spil, før du opdaterer det. + Xbox-indhold + Der er ingen licenserede pc-indholdspakker til dette spil. + %1$s · Opdater / bekræft + %1$s · Installer + Vælg pc-indhold + tilgængelig med %1$s + Ejede spil i Bibliotek + Tilføjet + Start + Gemte spil og sky + Steam-genvej + Version + Sidst spillet + Adgang + Ejet + Stream + Føj til Bibliotek + Fjern fra Bibliotek + I Bibliotek + Vis + Sky + Skyindstillinger diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 3a78db29a..db7241eaf 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -1015,8 +1015,6 @@ Spiele, die du in Steam installierst, erscheinen hier nach der Sitzung. Mit Steam spielen Mit SteamVR spielen - Starteinstellungen - Starten Spieldateien Proton-Präfix Suche nach Spielständen… @@ -1024,7 +1022,6 @@ %1$d Spielstandordner %1$s · %2$d Dateien · %3$s %1$d KB - Spielstände verwalten Spielstände Spielstände importieren… Als GameHub-ZIP exportieren… @@ -1036,7 +1033,6 @@ Laufzeit wird beim ersten „Spielen“ installiert Laufzeit-Update verfügbar ● Laufzeit bereit - Zuletzt gespielt: %1$s Interner Speicher Hinzugefügtes Spiel Eigenes Spiel @@ -1105,7 +1101,7 @@ Linux-Terminal Die Linux-Sitzung ist noch nicht bereit für ein Terminal. Steam - Spiele + Bibliothek Desktop Store Komponenten @@ -1939,7 +1935,6 @@ Steam Eigene Stores - Store öffnen auf Download-Tempo Store-Engine ● Engine bereit @@ -1953,11 +1948,9 @@ Tabs anordnen Fertig (nicht angemeldet) - Store Bibliothek Alle Downloads - %1$s (%2$d) Anmelden %1$s · abmelden Von %1$s abmelden? @@ -1975,7 +1968,6 @@ Angebote Kostenlose Spiele Im Trend - Deine Bibliothek Spielen Installieren Installation fortsetzen @@ -3008,4 +3000,38 @@ Gespeicherte Ansichten können neu berechnet werden. Starten Sie eine neue Kalibrierung zur Erfassung im aktuellen Tracking-Koordinatensystem. Die Prüfsumme des Handmodells stimmt nicht überein OpenCV konnte nicht geladen werden + Nicht installiert + Öffnen + Installieren + Über + Installieren + Speicherort + Ordner + Speicherbedarf + Installiert + Aktualisieren / prüfen + Inhalte verwalten + Schließe dieses Xbox-Spiel, bevor du es aktualisierst. + Xbox-Inhalte + Für dieses Spiel gibt es keine lizenzierten PC-Inhaltspakete. + %1$s · Aktualisieren / prüfen + %1$s · Installieren + PC-Inhalt wählen + verfügbar mit %1$s + Eigene Spiele in der Bibliothek + Hinzugefügt + Start + Spielstände & Cloud + Steam-Verknüpfung + Version + Zuletzt gespielt + Zugang + Gekauft + Streamen + Zur Bibliothek hinzufügen + Aus der Bibliothek entfernen + In der Bibliothek + Anzeigen + Cloud + Cloud-Einstellungen diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 71fbfc6f0..f720eba4f 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -1023,8 +1023,6 @@ Τα παιχνίδια που εγκαθιστάτε στο Steam εμφανίζονται εδώ μετά τη συνεδρία. Αναπαραγωγή Steam Αναπαραγωγή SteamVR - Ρυθμίσεις εκκίνησης - Εκκίνηση Αρχεία παιχνιδιού Prefix Proton Αναζήτηση αποθηκευμένων αρχείων… @@ -1032,7 +1030,6 @@ %1$d φάκελοι αποθήκευσης %1$s · %2$d αρχεία · %3$s %1$d KB - Διαχείριση αποθηκευμένων Αποθηκευμένα παιχνιδιού Εισαγωγή αποθηκευμένων… Εξαγωγή ως zip GameHub… @@ -1044,7 +1041,6 @@ Το runtime εγκαθίσταται στο πρώτο Play Υπάρχει ενημέρωση runtime ● Το runtime είναι έτοιμο - Παίχτηκε τελευταία φορά %1$s Εσωτερική αποθήκευση Προστέθηκε παιχνίδι Προσαρμοσμένο παιχνίδι @@ -1113,7 +1109,7 @@ Τερματικό Linux Η συνεδρία Linux δεν είναι ακόμα έτοιμη για τερματικό. Steam - Παιχνίδια + Βιβλιοθήκη Επιφάνεια Κατάστημα Στοιχεία @@ -1947,7 +1943,6 @@ Steam Προσαρμοσμένο Καταστήματα - Άνοιγμα καταστήματος σε Επίπεδο ταχύτητας λήψης Μηχανή καταστημάτων ● Η μηχανή είναι έτοιμη @@ -1961,11 +1956,9 @@ Τακτοποίηση καρτελών Έγινε (μη συνδεδεμένος) - Κατάστημα Βιβλιοθήκη Όλα Λήψεις - %1$s (%2$d) Σύνδεση %1$s · αποσύνδεση Αποσύνδεση από %1$s; @@ -1983,7 +1976,6 @@ Προσφορές Δωρεάν παιχνίδια Σε άνοδο - Η βιβλιοθήκη σας Αναπαραγωγή Εγκατάσταση Συνέχιση εγκατάστασης @@ -3008,4 +3000,38 @@ Οι αποθηκευμένες προβολές μπορούν να υπολογιστούν εκ νέου. Ξεκινήστε μια νέα βαθμονόμηση για αποτύπωση στο τρέχον σύστημα συντεταγμένων παρακολούθησης. Αναντιστοιχία αθροίσματος ελέγχου μοντέλου χεριού Δεν ήταν δυνατή η φόρτωση του OpenCV + Δεν είναι εγκατεστημένο + Άνοιγμα + Εγκατάσταση + Σχετικά + Εγκατάσταση + Τοποθεσία + Φάκελος + Μέγεθος στον δίσκο + Εγκατεστημένο + Ενημέρωση / επαλήθευση + Διαχείριση περιεχομένου + Κλείστε αυτό το παιχνίδι Xbox πριν το ενημερώσετε. + Περιεχόμενο Xbox + Δεν υπάρχουν αδειοδοτημένα πακέτα περιεχομένου PC για αυτό το παιχνίδι. + %1$s · Ενημέρωση / επαλήθευση + %1$s · Εγκατάσταση + Επιλογή περιεχομένου PC + διαθέσιμο με %1$s + Παιχνίδια σας στη Βιβλιοθήκη + Προστέθηκαν + Εκκίνηση + Αποθηκεύσεις και cloud + Συντόμευση Steam + Έκδοση + Τελευταίο παιχνίδι + Πρόσβαση + Δικό σας + Ροή + Προσθήκη στη Βιβλιοθήκη + Αφαίρεση από τη Βιβλιοθήκη + Στη Βιβλιοθήκη + Εμφάνιση + Cloud + Ρυθμίσεις cloud diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index a22c9a850..118be6cee 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1031,8 +1031,6 @@ Los juegos que instales en Steam aparecen aquí después de la sesión. Jugar en Steam Jugar en SteamVR - Ajustes de inicio - Iniciar Archivos del juego Prefijo de Proton Buscando partidas guardadas… @@ -1040,7 +1038,6 @@ %1$d carpetas de guardado %1$s · %2$d archivos · %3$s %1$d KB - Gestionar partidas guardadas Partidas guardadas Importar partidas guardadas… Exportar como zip de GameHub… @@ -1052,7 +1049,6 @@ El runtime se instala al jugar Actualización del runtime disponible ● Runtime listo - Última partida %1$s Almacenamiento interno Juego añadido Juego personalizado @@ -1121,7 +1117,7 @@ Terminal de Linux La sesión de Linux aún no está lista para un terminal. Steam - Juegos + Biblioteca Escritorio Tienda Componentes @@ -1970,7 +1966,6 @@ Steam Personalizados Tiendas - Abrir una tienda en Nivel de velocidad de descarga Motor de las tiendas ● Motor listo @@ -1984,11 +1979,9 @@ Organizar pestañas Listo (sin iniciar sesión) - Tienda Biblioteca Todos Descargas - %1$s (%2$d) Iniciar sesión %1$s · cerrar sesión ¿Cerrar sesión en %1$s? @@ -2006,7 +1999,6 @@ Ofertas Juegos gratis En tendencia - Tu biblioteca Jugar Instalar Reanudar instalación @@ -3044,4 +3036,38 @@ Las vistas guardadas se pueden recalcular. Inicie una nueva calibración para capturar en el sistema de coordenadas de seguimiento actual. La suma de comprobación del modelo de mano no coincide OpenCV no se pudo cargar + No instalada + Abrir + Instalar + Acerca de + Instalar + Ubicación + Carpeta + Tamaño en disco + Instalado + Actualizar / verificar + Gestionar contenido + Cierra este juego de Xbox antes de actualizarlo. + Contenido de Xbox + No hay paquetes de contenido de PC con licencia para este juego. + %1$s · Actualizar / verificar + %1$s · Instalar + Elegir contenido de PC + disponible con %1$s + Juegos comprados en la Biblioteca + Añadidos + Inicio + Partidas y nube + Acceso directo de Steam + Versión + Jugado por última vez + Acceso + Comprado + Transmitir + Añadir a la Biblioteca + Quitar de la Biblioteca + En la Biblioteca + Mostrar + Nube + Ajustes de nube diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index 61ca19389..f94c35c11 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -1023,8 +1023,6 @@ Steamissa asentamasi pelit näkyvät tässä istunnon jälkeen. Pelaa Steamissa Pelaa SteamVR:ssä - Käynnistysasetukset - Käynnistä Pelitiedostot Proton-prefix Etsitään tallennuksia… @@ -1032,7 +1030,6 @@ %1$d tallennuskansiota %1$s · %2$d tiedostoa · %3$s %1$d kt - Hallitse tallennuksia Pelitallennukset Tuo tallennuksia… Vie GameHub-zipinä… @@ -1044,7 +1041,6 @@ Ajoympäristö asentuu ensimmäisellä Pelaa-painalluksella Ajoympäristön päivitys saatavilla ● Ajoympäristö valmis - Viimeksi pelattu %1$s Sisäinen tallennustila Lisätty peli Mukautettu peli @@ -1113,7 +1109,7 @@ Linux-pääte Linux-istunto ei ole vielä valmis päätteelle. Steam - Pelit + Kirjasto Työpöytä Kauppa Komponentit @@ -1947,7 +1943,6 @@ Steam Mukautettu Kaupat - Avaa kauppa näytöllä Latausnopeuden taso Kauppojen moottori ● Moottori valmis @@ -1961,11 +1956,9 @@ Järjestä välilehdet Valmis (ei kirjautunut) - Kauppa Kirjasto Kaikki Lataukset - %1$s (%2$d) Kirjaudu sisään %1$s · kirjaudu ulos Kirjaudutaanko ulos %1$s:stä? @@ -1983,7 +1976,6 @@ Tarjoukset Ilmaiset pelit Nousussa - Kirjastosi Pelaa Asenna Jatka asennusta @@ -3008,4 +3000,38 @@ Tallennetut näkymät voidaan laskea uudelleen. Aloita uusi kalibrointi kaapataksesi nykyiseen seurantakoordinaattijärjestelmään. Käsimallin tarkistussumman epäsuhta OpenCV:tä ei voitu ladata + Ei asennettu + Avaa + Asenna + Tietoja + Asenna + Sijainti + Kansio + Koko levyllä + Asennettu + Päivitä / tarkista + Hallitse sisältöä + Sulje tämä Xbox-peli ennen päivittämistä. + Xbox-sisältö + Tälle pelille ei ole lisensoituja PC-sisältöpaketteja. + %1$s · Päivitä / tarkista + %1$s · Asenna + Valitse PC-sisältö + saatavilla palvelussa %1$s + Omistetut pelit kirjastossa + Lisätyt + Käynnistys + Tallennukset ja pilvi + Steam-pikakuvake + Versio + Viimeksi pelattu + Käyttöoikeus + Omistettu + Striimaa + Lisää kirjastoon + Poista kirjastosta + Kirjastossa + Näytä + Pilvi + Pilviasetukset diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index e13940f8d..6b22a92a6 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -1031,8 +1031,6 @@ Les jeux installés sur Steam apparaîtront ici après la fin de la session. Lancer Steam Lancer SteamVR - Paramètres de lancement - Lancer Fichiers du jeu Préfixe Proton Recherche des sauvegardes… @@ -1040,7 +1038,6 @@ %1$d dossiers de sauvegarde %1$s · %2$d fichiers · %3$s %1$d Ko - Gérer les sauvegardes Sauvegardes de jeu Importer des sauvegardes… Exporter en tant qu\'archive GameHub… @@ -1052,7 +1049,6 @@ Installation de l\'environnement d\'exécution lors du premier lancement du jeu Mise à jour de l\'environnement d\'exécution disponible ● Environnement d\'exécution prêt - Dernier lancement %1$s Stockage interne Jeux ajoutés Jeu personnalisé @@ -1121,7 +1117,7 @@ Terminal Linux La session Linux n\'est pas prête, le terminal ne peut pas être utilisé. Steam - Jeux + Bibliothèque Bureau Boutique Composants @@ -1970,7 +1966,6 @@ Steam Personnalisé Boutiques - Ouvrir une boutique sur Niveau de vitesse de téléchargement Moteur des boutiques ● Moteur prêt @@ -1984,11 +1979,9 @@ Organiser les onglets Terminé (non connecté) - Boutique Bibliothèque Tous Téléchargements - %1$s (%2$d) Se connecter %1$s · se déconnecter Se déconnecter de %1$s ? @@ -2006,7 +1999,6 @@ Promotions Jeux gratuits Tendances - Votre bibliothèque Jouer Installer Reprendre l\'installation @@ -3044,4 +3036,38 @@ Les vues enregistrées peuvent être recalculées. Démarrez un nouvel étalonnage pour capturer dans le système de coordonnées de suivi actuel. Incohérence de la somme de contrôle du modèle manuel OpenCV n\'a pas pu charger + Non installé + Ouvrir + Installer + À propos + Installer + Emplacement + Dossier + Taille sur le disque + Installé + Mettre à jour / vérifier + Gérer le contenu + Fermez ce jeu Xbox avant de le mettre à jour. + Contenu Xbox + Aucun pack de contenu PC sous licence n\'est disponible pour ce jeu. + %1$s · Mettre à jour / vérifier + %1$s · Installer + Choisir le contenu PC + disponible avec %1$s + Jeux possédés dans la Bibliothèque + Ajoutés + Lancement + Sauvegardes et cloud + Raccourci Steam + Version + Dernière partie + Accès + Possédé + Streamer + Ajouter à la Bibliothèque + Retirer de la Bibliothèque + Dans la Bibliothèque + Afficher + Cloud + Paramètres cloud diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 971ebd34c..d0e385c61 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -1023,8 +1023,6 @@ Steam में इंस्टॉल किए गेम सेशन के बाद यहां दिखते हैं। Steam चलाएं SteamVR चलाएं - लॉन्च सेटिंग - लॉन्च करें गेम फ़ाइलें Proton प्रीफ़िक्स सेव खोजे जा रहे हैं… @@ -1032,7 +1030,6 @@ %1$d सेव फ़ोल्डर %1$s · %2$d फ़ाइलें · %3$s %1$d KB - सेव मैनेज करें गेम सेव सेव इंपोर्ट करें… GameHub zip के रूप में एक्सपोर्ट करें… @@ -1044,7 +1041,6 @@ पहली बार Play पर रनटाइम इंस्टॉल होता है रनटाइम अपडेट उपलब्ध ● रनटाइम तैयार - आख़िरी बार %1$s को खेला इंटरनल स्टोरेज जोड़ा गया गेम कस्टम गेम @@ -1113,7 +1109,7 @@ Linux टर्मिनल Linux सेशन अभी टर्मिनल के लिए तैयार नहीं है। Steam - गेम + लाइब्रेरी डेस्कटॉप स्टोर कॉम्पोनेंट @@ -1947,7 +1943,6 @@ Steam कस्टम स्टोर - स्टोर खोलें डाउनलोड स्पीड टियर स्टोर इंजन ● इंजन तैयार @@ -1961,11 +1956,9 @@ टैब व्यवस्थित करें हो गया (साइन इन नहीं है) - स्टोर लाइब्रेरी सभी डाउनलोड - %1$s (%2$d) साइन इन करें %1$s · साइन आउट करें %1$s से साइन आउट करें? @@ -1983,7 +1976,6 @@ डील फ़्री गेम ट्रेंडिंग - आपकी लाइब्रेरी Play इंस्टॉल करें इंस्टॉल फिर से शुरू करें @@ -3008,4 +3000,38 @@ सहेजे गए दृश्यों की पुनर्गणना की जा सकती है। वर्तमान ट्रैकिंग समन्वय प्रणाली में कैप्चर करने के लिए एक नया अंशांकन प्रारंभ करें। हैंड मॉडल चेकसम बेमेल OpenCV लोड नहीं हो सका + इंस्टॉल नहीं है + खोलें + इंस्टॉल करें + परिचय + इंस्टॉल करें + जगह + फ़ोल्डर + डिस्क पर आकार + इंस्टॉल है + अपडेट / सत्यापित करें + कंटेंट प्रबंधित करें + अपडेट करने से पहले यह Xbox गेम बंद करें। + Xbox कंटेंट + इस गेम के लिए कोई लाइसेंस वाला PC कंटेंट पैकेज उपलब्ध नहीं है। + %1$s · अपडेट / सत्यापित करें + %1$s · इंस्टॉल करें + PC कंटेंट चुनें + %1$s के साथ उपलब्ध + लाइब्रेरी में आपके गेम + जोड़े गए + लॉन्च + सेव और क्लाउड + Steam शॉर्टकट + संस्करण + आखिरी बार खेला + एक्सेस + आपका + स्ट्रीम करें + लाइब्रेरी में जोड़ें + लाइब्रेरी से हटाएँ + लाइब्रेरी में + दिखाएँ + क्लाउड + क्लाउड सेटिंग्स diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 5463a8a5c..ada8fe46c 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -1023,8 +1023,6 @@ A Steamben telepített játékok a munkamenet után itt jelennek meg. Steam elindítása SteamVR elindítása - Indítási beállítások - Indítás Játékfájlok Proton-prefix Mentések keresése… @@ -1032,7 +1030,6 @@ %1$d mentésmappa %1$s · %2$d fájl · %3$s %1$d KB - Mentések kezelése Játékmentések Mentések importálása… Exportálás GameHub zipként… @@ -1044,7 +1041,6 @@ A futtatókörnyezet az első Játék gombnyomáskor települ Elérhető futtatókörnyezet-frissítés ● A futtatókörnyezet kész - Utoljára játszva: %1$s Belső tárhely Hozzáadott játék Egyéni játék @@ -1113,7 +1109,7 @@ Linux terminál A Linux-munkamenet még nem áll készen a terminálra. Steam - Játékok + Könyvtár Asztal Áruház Összetevők @@ -1947,7 +1943,6 @@ Steam Egyéni Áruházak - Áruház megnyitása itt Letöltési sebességi szint Áruházmotor ● A motor kész @@ -1961,11 +1956,9 @@ Fülek rendezése Kész (nincs bejelentkezve) - Áruház Könyvtár Összes Letöltések - %1$s (%2$d) Bejelentkezés %1$s · kijelentkezés Kijelentkezik a %1$s-ből? @@ -1983,7 +1976,6 @@ Akciók Ingyenes játékok Felkapott - A könyvtára Játék Telepítés Telepítés folytatása @@ -3008,4 +3000,38 @@ Az OpenCV betöltése nem sikerült 3DoF virtuális képernyő 6DoF virtuális képernyő + Nincs telepítve + Megnyitás + Telepítés + Névjegy + Telepítés + Hely + Mappa + Méret a tárhelyen + Telepítve + Frissítés / ellenőrzés + Tartalom kezelése + Frissítés előtt zárd be ezt az Xbox-játékot. + Xbox-tartalom + Ehhez a játékhoz nincs licencelt PC-s tartalomcsomag. + %1$s · Frissítés / ellenőrzés + %1$s · Telepítés + PC-s tartalom választása + elérhető ezzel: %1$s + Saját játékok a könyvtárban + Hozzáadott + Indítás + Mentések és felhő + Steam-parancsikon + Verzió + Utoljára játszva + Hozzáférés + Saját + Streamelés + Hozzáadás a könyvtárhoz + Eltávolítás a könyvtárból + A könyvtárban + Megjelenítés + Felhő + Felhőbeállítások diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index c04243615..7a93dff31 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -1017,8 +1017,6 @@ Gim yang Anda instal di Steam akan muncul di sini setelah sesi. Mainkan Steam Mainkan SteamVR - Setelan peluncuran - Luncurkan File gim Prefix Proton Mencari data simpanan… @@ -1026,7 +1024,6 @@ %1$d folder simpanan %1$s · %2$d file · %3$s %1$d KB - Kelola data simpanan Data simpanan gim Impor data simpanan… Ekspor sebagai zip GameHub… @@ -1038,7 +1035,6 @@ Runtime terinstal saat Main pertama kali Pembaruan runtime tersedia ● Runtime siap - Terakhir dimainkan %1$s Penyimpanan internal Gim ditambahkan Gim khusus @@ -1107,7 +1103,7 @@ Terminal Linux Sesi Linux belum siap untuk terminal. Steam - Game + Pustaka Desktop Toko Komponen @@ -1926,7 +1922,6 @@ Steam Khusus Toko - Buka toko di Tingkat kecepatan unduhan Mesin toko ● Mesin siap @@ -1940,11 +1935,9 @@ Atur tab Selesai (belum masuk) - Toko Pustaka Semua Unduhan - %1$s (%2$d) Masuk %1$s · keluar Keluar dari %1$s? @@ -1962,7 +1955,6 @@ Penawaran Gim gratis Sedang tren - Pustaka Anda Mainkan Instal Lanjutkan instalasi @@ -2983,4 +2975,38 @@ Tampilan yang disimpan dapat dihitung ulang. Mulai kalibrasi baru untuk menangkap sistem koordinat pelacakan saat ini. Ketidakcocokan checksum model tangan OpenCV tidak dapat memuat + Belum terinstal + Buka + Pasang + Tentang + Pasang + Lokasi + Folder + Ukuran di disk + Terinstal + Perbarui / verifikasi + Kelola konten + Tutup game Xbox ini sebelum memperbaruinya. + Konten Xbox + Tidak ada paket konten PC berlisensi untuk game ini. + %1$s · Perbarui / verifikasi + %1$s · Instal + Pilih konten PC + tersedia dengan %1$s + Game yang dimiliki di Pustaka + Ditambahkan + Peluncuran + Simpanan & cloud + Pintasan Steam + Versi + Terakhir dimainkan + Akses + Dimiliki + Streaming + Tambahkan ke Pustaka + Hapus dari Pustaka + Di Pustaka + Tampilkan + Cloud + Pengaturan cloud diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index db15c513c..5491ea524 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -1021,8 +1021,6 @@ I giochi che installi in Steam compaiono qui dopo la sessione. Gioca su Steam Gioca su SteamVR - Impostazioni di avvio - Avvia File di gioco Prefisso Proton Ricerca dei salvataggi… @@ -1030,7 +1028,6 @@ %1$d cartelle di salvataggio %1$s · %2$d file · %3$s %1$d KB - Gestisci salvataggi Salvataggi di gioco Importa salvataggi… Esporta come zip GameHub… @@ -1042,7 +1039,6 @@ Il runtime si installa al primo Gioca Aggiornamento del runtime disponibile ● Runtime pronto - Giocato l\'ultima volta %1$s Memoria interna Gioco aggiunto Gioco personalizzato @@ -1111,7 +1107,7 @@ Terminale Linux La sessione Linux non è ancora pronta per un terminale. Steam - Giochi + Libreria Desktop Negozio Componenti @@ -1960,7 +1956,6 @@ Steam Personalizzati Negozi - Apri un negozio su Livello di velocità di download Motore dei negozi ● Motore pronto @@ -1974,11 +1969,9 @@ Disponi schede Fine (accesso non eseguito) - Negozio Libreria Tutti Download - %1$s (%2$d) Accedi %1$s · esci Uscire da %1$s? @@ -1996,7 +1989,6 @@ Offerte Giochi gratuiti Di tendenza - La tua libreria Gioca Installa Riprendi installazione @@ -3042,4 +3034,38 @@ Impossibile caricare OpenCV Le impostazioni dello schermo e del tracciamento della testa sono condivise tra menu, giochi e sessioni desktop di DroidDeck. DroidDeck ChArUco 30 mm + Non installata + Apri + Installa + Informazioni + Installa + Posizione + Cartella + Spazio su disco + Installati + Aggiorna / verifica + Gestisci contenuti + Chiudi questo gioco Xbox prima di aggiornarlo. + Contenuti Xbox + Non ci sono pacchetti di contenuti PC con licenza per questo gioco. + %1$s · Aggiorna / verifica + %1$s · Installa + Scegli i contenuti PC + disponibile con %1$s + Giochi posseduti nella Libreria + Aggiunti + Avvio + Salvataggi e cloud + Collegamento Steam + Versione + Ultima partita + Accesso + Posseduto + Riproduci in streaming + Aggiungi alla Libreria + Rimuovi dalla Libreria + Nella Libreria + Mostra + Cloud + Impostazioni cloud diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index e8f495fcb..f0df57bd0 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -1019,8 +1019,6 @@ Steamでインストールされたゲームは、セッション終了後にここに表示されます。 Steamを実行 SteamVRを実行 - 起動設定 - 起動 ゲームファイル Protonプレフィックス セーブデータを検索中… @@ -1028,7 +1026,6 @@ %1$d 個のセーブフォルダ %1$s · %2$d 個のファイル · %3$s %1$d KB - セーブデータの管理 ゲームセーブデータ セーブデータをインポート… GameHubアーカイブとしてエクスポート… @@ -1040,7 +1037,6 @@ 初回実行時に実行環境を導入 実行環境にアップデートがあります ● 実行環境の準備完了 - 最終プレイ %1$s 内部ストレージ 追加されたゲーム カスタムゲーム @@ -1109,7 +1105,7 @@ Linuxターミナル Linuxセッションの準備ができていないため、ターミナルを使用できません。 Steam - ゲーム + ライブラリ デスクトップ ストア コンポーネント @@ -1928,7 +1924,6 @@ Steam カスタム ストア - ストアページを開くときの初期タブ ダウンロード速度レベル ストアエンジン ● エンジン準備完了 @@ -1942,11 +1937,9 @@ タブを整理 完了 (未ログイン) - ストア ライブラリ すべて ダウンロード - %1$s(%2$d) ログイン %1$s · ログアウト %1$sからログアウトしますか? @@ -1964,7 +1957,6 @@ セール 無料ゲーム トレンド - マイライブラリ プレイ インストール インストールを再開 @@ -2985,4 +2977,38 @@ OpenCVをロードできませんでした 明るい部屋でゆっくりと周囲を見回してください。\nトラッキングカメラを遮るものがないように、家具などの見えるものに顔を向けてください。 DroidDeck ChArUco 30 mm + 未インストール + 開く + インストール + 概要 + インストール + 場所 + フォルダ + ディスク上のサイズ + インストール済み + 更新 / 検証 + コンテンツを管理 + 更新する前にこの Xbox ゲームを閉じてください。 + Xbox コンテンツ + このゲームにはライセンス済みの PC コンテンツパッケージがありません。 + %1$s · 更新 / 検証 + %1$s · インストール + PC コンテンツを選択 + %1$s で利用可能 + ライブラリの所有ゲーム + 追加したゲーム + 起動 + セーブとクラウド + Steam ショートカット + バージョン + 最終プレイ + アクセス + 所有 + ストリーミング + ライブラリに追加 + ライブラリから削除 + ライブラリにあります + 表示 + クラウド + クラウド設定 diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index f38feeb16..94e23ea9d 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -1019,8 +1019,6 @@ Steam에서 설치한 게임은 세션 종료 후 여기에 표시됩니다. Steam 실행 SteamVR 실행 - 실행 설정 - 실행 게임 파일 Proton 프리픽스 저장 데이터 검색 중… @@ -1028,7 +1026,6 @@ %1$d개 저장 폴더 %1$s · %2$d개 파일 · %3$s %1$d KB - 저장 데이터 관리 게임 저장 데이터 저장 데이터 가져오기… GameHub 아카이브로 내보내기… @@ -1040,7 +1037,6 @@ 처음 \"Steam 실행\" 시 런타임 설치 런타임 업데이트 있음 ● 런타임 준비됨 - 마지막 실행 %1$s 내부 저장 공간 추가된 게임 사용자 정의 게임 @@ -1109,7 +1105,7 @@ Linux 터미널 Linux 세션이 준비되지 않아 터미널을 사용할 수 없습니다. Steam - 게임 + 라이브러리 데스크톱 스토어 구성 요소 @@ -1928,7 +1924,6 @@ Steam 사용자 정의 스토어 - 스토어를 열 때 표시 다운로드 속도 단계 스토어 엔진 ● 엔진 준비됨 @@ -1942,11 +1937,9 @@ 탭 정리 완료 (로그인 안 됨) - 스토어 라이브러리 전체 다운로드 - %1$s (%2$d) 로그인 %1$s · 로그아웃 %1$s에서 로그아웃하시겠습니까? @@ -1964,7 +1957,6 @@ 할인 무료 게임 인기 급상승 - 내 라이브러리 플레이 설치 설치 재개 @@ -2985,4 +2977,38 @@ 카메라 측정 %1$s; 선택한 거리 %2$s. %3$s 보정을 시작하기 전에 두 카메라 미리보기를 모두 기다리세요. 저장된 뷰는 다시 계산될 수 있습니다. 현재 추적 좌표계에서 캡처하려면 새 보정을 시작하세요. + 설치되지 않음 + 열기 + 설치 + 정보 + 설치 + 위치 + 폴더 + 디스크 사용량 + 설치됨 + 업데이트 / 확인 + 콘텐츠 관리 + 업데이트하기 전에 이 Xbox 게임을 닫으세요. + Xbox 콘텐츠 + 이 게임에 사용할 수 있는 라이선스된 PC 콘텐츠 패키지가 없습니다. + %1$s · 업데이트 / 확인 + %1$s · 설치 + PC 콘텐츠 선택 + %1$s 이용 가능 + 라이브러리의 보유 게임 + 추가됨 + 실행 + 저장 및 클라우드 + Steam 바로가기 + 버전 + 마지막 플레이 + 이용 권한 + 보유 + 스트리밍 + 라이브러리에 추가 + 라이브러리에서 제거 + 라이브러리에 있음 + 표시 + 클라우드 + 클라우드 설정 diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml index 80b7d10fb..9ba680d28 100644 --- a/app/src/main/res/values-ms/strings.xml +++ b/app/src/main/res/values-ms/strings.xml @@ -1017,8 +1017,6 @@ Permainan yang anda pasang dalam Steam muncul di sini selepas sesi. Main Steam Main SteamVR - Tetapan pelancaran - Lancarkan Fail permainan Prefix Proton Mencari simpanan… @@ -1026,7 +1024,6 @@ %1$d folder simpanan %1$s · %2$d fail · %3$s %1$d KB - Urus simpanan Simpanan permainan Import simpanan… Eksport sebagai zip GameHub… @@ -1038,7 +1035,6 @@ Masa jalan dipasang pada Main pertama Kemas kini masa jalan tersedia ● Masa jalan sedia - Terakhir dimainkan %1$s Storan dalaman Permainan ditambah Permainan tersuai @@ -1107,7 +1103,7 @@ Terminal Linux Sesi Linux belum sedia untuk terminal. Steam - Permainan + Pustaka Desktop Kedai Komponen @@ -1926,7 +1922,6 @@ Steam Tersuai Kedai - Buka kedai pada Tahap kelajuan muat turun Enjin kedai ● Enjin sedia @@ -1940,11 +1935,9 @@ Susun tab Selesai (belum log masuk) - Kedai Pustaka Semua Muat turun - %1$s (%2$d) Log masuk %1$s · log keluar Log keluar daripada %1$s? @@ -1962,7 +1955,6 @@ Tawaran Permainan percuma Tular - Pustaka anda Main Pasang Sambung pemasangan @@ -2983,4 +2975,38 @@ OpenCV tidak dapat dimuatkan DroidDeck - Penentukuran Luma Ultra Penentukuran asas Luma Ultra dimuatkan + Belum dipasang + Buka + Pasang + Perihal + Pasang + Lokasi + Folder + Saiz pada cakera + Dipasang + Kemas kini / sahkan + Urus kandungan + Tutup permainan Xbox ini sebelum mengemas kininya. + Kandungan Xbox + Tiada pakej kandungan PC berlesen untuk permainan ini. + %1$s · Kemas kini / sahkan + %1$s · Pasang + Pilih kandungan PC + tersedia dengan %1$s + Permainan dimiliki dalam Pustaka + Ditambah + Pelancaran + Simpanan & awan + Pintasan Steam + Versi + Terakhir dimainkan + Akses + Dimiliki + Strim + Tambah ke Pustaka + Alih keluar dari Pustaka + Dalam Pustaka + Tunjukkan + Awan + Tetapan awan diff --git a/app/src/main/res/values-nb/strings.xml b/app/src/main/res/values-nb/strings.xml index 210c71f33..cd62ccb5a 100644 --- a/app/src/main/res/values-nb/strings.xml +++ b/app/src/main/res/values-nb/strings.xml @@ -1023,8 +1023,6 @@ Spill du installerer i Steam, vises her etter sesjonen. Spill Steam Spill SteamVR - Startinnstillinger - Start Spillfiler Proton-prefiks Ser etter lagringer… @@ -1032,7 +1030,6 @@ %1$d lagringsmapper %1$s · %2$d filer · %3$s %1$d KB - Behandle lagringer Spilllagringer Importer lagringer… Eksporter som GameHub-zip… @@ -1044,7 +1041,6 @@ Kjøretiden installeres ved første Spill Kjøretidsoppdatering tilgjengelig ● Kjøretid klar - Sist spilt %1$s Intern lagring Lagt til spill Egendefinert spill @@ -1113,7 +1109,7 @@ Linux-terminal Linux-sesjonen er ikke klar for en terminal ennå. Steam - Spill + Bibliotek Skrivebord Butikk Komponenter @@ -1947,7 +1943,6 @@ Steam Egendefinert Butikker - Åpne en butikk på Nedlastingshastighetsnivå Butikkmotor ● Motor klar @@ -1961,11 +1956,9 @@ Ordne faner Ferdig (ikke innlogget) - Butikk Bibliotek Alle Nedlastinger - %1$s (%2$d) Logg inn %1$s · logg ut Logge ut av %1$s? @@ -1983,7 +1976,6 @@ Tilbud Gratisspill Trender - Biblioteket ditt Spill Installer Gjenoppta installasjon @@ -3008,4 +3000,38 @@ Lagrede visninger kan beregnes på nytt. Start en ny kalibrering for å fange opp i gjeldende sporingskoordinatsystem. Uoverensstemmelse med håndmodellsjekksum OpenCV kunne ikke laste + Ikke installert + Åpne + Installer + Om + Installer + Plassering + Mappe + Størrelse på disk + Installert + Oppdater / bekreft + Administrer innhold + Lukk dette Xbox-spillet før du oppdaterer det. + Xbox-innhold + Det finnes ingen lisensierte PC-innholdspakker for dette spillet. + %1$s · Oppdater / bekreft + %1$s · Installer + Velg PC-innhold + tilgjengelig med %1$s + Eide spill i Bibliotek + Lagt til + Start + Lagrede spill og sky + Steam-snarvei + Versjon + Sist spilt + Tilgang + Eid + Strøm + Legg til i Bibliotek + Fjern fra Bibliotek + I Bibliotek + Vis + Sky + Skyinnstillinger diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 52f5fc527..c83f80dff 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -1023,8 +1023,6 @@ Games die je in Steam installeert, verschijnen hier na de sessie. Steam spelen SteamVR spelen - Opstartinstellingen - Starten Gamebestanden Proton-prefix Opslagbestanden zoeken… @@ -1032,7 +1030,6 @@ %1$d opslagmappen %1$s · %2$d bestanden · %3$s %1$d KB - Opslagbestanden beheren Gameopslag Opslagbestanden importeren… Exporteren als GameHub-zip… @@ -1044,7 +1041,6 @@ Runtime wordt geïnstalleerd bij de eerste keer Spelen Runtime-update beschikbaar ● Runtime gereed - Laatst gespeeld %1$s Interne opslag Toegevoegde game Aangepaste game @@ -1113,7 +1109,7 @@ Linux-terminal De Linux-sessie is nog niet klaar voor een terminal. Steam - Games + Bibliotheek Desktop Winkel Componenten @@ -1947,7 +1943,6 @@ Steam Aangepast Winkels - Open een winkel op Niveau voor downloadsnelheid Winkels-engine ● Engine gereed @@ -1961,11 +1956,9 @@ Tabbladen rangschikken Klaar (niet aangemeld) - Winkel Bibliotheek Alle Downloads - %1$s (%2$d) Aanmelden %1$s · afmelden Afmelden bij %1$s? @@ -1983,7 +1976,6 @@ Aanbiedingen Gratis games Populair - Jouw bibliotheek Spelen Installeren Installatie hervatten @@ -3008,4 +3000,38 @@ OpenCV kon niet laden Virtueel scherm met 6DoF DroidDeck ChArUco 30 mm + Niet geïnstalleerd + Openen + Installeren + Over + Installeren + Locatie + Map + Grootte op schijf + Geïnstalleerd + Bijwerken / controleren + Inhoud beheren + Sluit deze Xbox-game voordat je hem bijwerkt. + Xbox-inhoud + Er zijn geen gelicentieerde pc-inhoudspakketten voor deze game. + %1$s · Bijwerken / controleren + %1$s · Installeren + Pc-inhoud kiezen + beschikbaar met %1$s + Games in je Bibliotheek + Toegevoegd + Starten + Opslag en cloud + Steam-snelkoppeling + Versie + Laatst gespeeld + Toegang + In bezit + Streamen + Toevoegen aan Bibliotheek + Verwijderen uit Bibliotheek + In Bibliotheek + Weergeven + Cloud + Cloudinstellingen diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 5d539ce78..f1234702a 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -1035,8 +1035,6 @@ Gry, które instalujesz w Steam, pojawią się tutaj po sesji. Zagraj w Steam Zagraj w SteamVR - Ustawienia uruchamiania - Uruchom Pliki gry Prefiks Proton Wyszukiwanie zapisów… @@ -1044,7 +1042,6 @@ %1$d folderów zapisów %1$s · %2$d plików · %3$s %1$d KB - Zarządzaj zapisami Zapisy gry Importuj zapisy… Eksportuj jako zip GameHub… @@ -1056,7 +1053,6 @@ Środowisko instaluje się przy pierwszym uruchomieniu Dostępna aktualizacja środowiska ● Środowisko gotowe - Ostatnio grano %1$s Pamięć wewnętrzna Dodana gra Własna gra @@ -1125,7 +1121,7 @@ Terminal Linux Sesja Linux nie jest jeszcze gotowa na terminal. Steam - Gry + Biblioteka Pulpit Sklep Komponenty @@ -1989,7 +1985,6 @@ Steam Własna Sklepy - Otwórz sklep w Poziom szybkości pobierania Mechanizm sklepów ● Mechanizm gotowy @@ -2003,11 +1998,9 @@ Ułóż karty Gotowe (niezalogowano) - Sklep Biblioteka Wszystkie Pobierania - %1$s (%2$d) Zaloguj się %1$s · wyloguj się Wylogować się z %1$s? @@ -2025,7 +2018,6 @@ Promocje Darmowe gry Popularne - Twoja biblioteka Graj Zainstaluj Wznów instalację @@ -3079,4 +3071,38 @@ DroidDeck ChArUco 30 mm Podłącz Luma Ultra i włącz śledzenie pozycji. Wykrywanie dłoni jest niedostępne: %1$s. Zmień ustawienie CPU/GPU lub wyłącz i ponownie włącz sterowanie dłońmi, aby spróbować ponownie. + Nie zainstalowano + Otwórz + Zainstaluj + Informacje + Zainstaluj + Lokalizacja + Folder + Rozmiar na dysku + Zainstalowane + Aktualizuj / zweryfikuj + Zarządzaj zawartością + Zamknij tę grę Xbox przed aktualizacją. + Zawartość Xbox + Dla tej gry nie ma licencjonowanych pakietów zawartości na PC. + %1$s · Aktualizuj / zweryfikuj + %1$s · Zainstaluj + Wybierz zawartość na PC + dostępna w %1$s + Posiadane gry w bibliotece + Dodane + Uruchamianie + Zapisy i chmura + Skrót Steam + Wersja + Ostatnio grano + Dostęp + Posiadana + Streamuj + Dodaj do biblioteki + Usuń z biblioteki + W bibliotece + Pokaż + Chmura + Ustawienia chmury diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index af19c47e0..662a45b79 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -1021,8 +1021,6 @@ Os jogos que instalar no Steam aparecem aqui após a sessão. Jogar no Steam Jogar no SteamVR - Definições de arranque - Iniciar Ficheiros do jogo Prefixo do Proton A procurar gravações… @@ -1030,7 +1028,6 @@ %1$d pastas de gravações %1$s · %2$d ficheiros · %3$s %1$d KB - Gerir gravações Gravações do jogo Importar gravações… Exportar como zip do GameHub… @@ -1042,7 +1039,6 @@ O ambiente de execução é instalado ao tocar em Jogar pela primeira vez Atualização do ambiente de execução disponível ● Ambiente de execução pronto - Jogado pela última vez %1$s Armazenamento interno Jogo adicionado Jogo personalizado @@ -1111,7 +1107,7 @@ Terminal Linux A sessão Linux ainda não está pronta para um terminal. Steam - Jogos + Biblioteca Ambiente de trabalho Loja Componentes @@ -1960,7 +1956,6 @@ Steam Personalizados Lojas - Abrir uma loja em Nível de velocidade de transferência Motor das lojas ● Motor pronto @@ -1974,11 +1969,9 @@ Organizar separadores Concluído (sessão não iniciada) - Loja Biblioteca Todos Transferências - %1$s (%2$d) Iniciar sessão %1$s · terminar sessão Terminar sessão em %1$s? @@ -1996,7 +1989,6 @@ Promoções Jogos gratuitos Tendências - A sua biblioteca Jogar Instalar Retomar instalação @@ -3042,4 +3034,38 @@ Incompatibilidade de checksum do modelo de mãos O OpenCV não conseguiu carregar Olhe em redor lentamente num ambiente bem iluminado.\nMantenha as câmaras de seguimento desobstruídas e viradas para móveis ou outros detalhes visíveis. + Não instalado + Abrir + Instalar + Acerca de + Instalar + Localização + Pasta + Tamanho no disco + Instalado + Atualizar / verificar + Gerir conteúdo + Feche este jogo Xbox antes de o atualizar. + Conteúdo Xbox + Não há pacotes de conteúdo para PC licenciados para este jogo. + %1$s · Atualizar / verificar + %1$s · Instalar + Escolher conteúdo para PC + disponível com %1$s + Jogos adquiridos na Biblioteca + Adicionados + Arranque + Gravações e nuvem + Atalho do Steam + Versão + Jogado pela última vez + Acesso + Adquirido + Transmitir + Adicionar à Biblioteca + Remover da Biblioteca + Na Biblioteca + Mostrar + Nuvem + Definições da nuvem diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 810cb1ab1..c0c4ca359 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -1021,8 +1021,6 @@ Os jogos que você instala na Steam aparecem aqui após a sessão. Jogar na Steam Jogar no SteamVR - Configurações de inicialização - Iniciar Arquivos do jogo Prefixo do Proton Procurando jogos salvos… @@ -1030,7 +1028,6 @@ %1$d pastas de jogos salvos %1$s · %2$d arquivos · %3$s %1$d KB - Gerenciar jogos salvos Jogos salvos Importar jogos salvos… Exportar como zip do GameHub… @@ -1042,7 +1039,6 @@ O runtime é instalado no primeiro Jogar Atualização do runtime disponível ● Runtime pronto - Jogado por último: %1$s Armazenamento interno Jogo adicionado Jogo personalizado @@ -1111,7 +1107,7 @@ Terminal Linux A sessão Linux ainda não está pronta para um terminal. Steam - Jogos + Biblioteca Área de trabalho Loja Componentes @@ -1960,7 +1956,6 @@ Steam Personalizado Lojas - Abrir uma loja em Nível de velocidade de download Mecanismo das lojas ● Mecanismo pronto @@ -1974,11 +1969,9 @@ Organizar abas Concluído (sem login) - Loja Biblioteca Todos Downloads - %1$s (%2$d) Fazer login %1$s · sair Sair de %1$s? @@ -1996,7 +1989,6 @@ Ofertas Jogos grátis Em alta - Sua biblioteca Jogar Instalar Retomar instalação @@ -3042,4 +3034,38 @@ Incompatibilidade de soma de verificação do modelo de mãos OpenCV não pôde carregar Olhe ao redor lentamente em um ambiente bem iluminado.\nMantenha as câmeras de rastreamento desobstruídas e voltadas para móveis ou outros detalhes visíveis. + Não instalado + Abrir + Instalar + Sobre + Instalar + Local + Pasta + Tamanho em disco + Instalados + Atualizar / verificar + Gerenciar conteúdo + Feche este jogo do Xbox antes de atualizá-lo. + Conteúdo do Xbox + Não há pacotes de conteúdo para PC licenciados para este jogo. + %1$s · Atualizar / verificar + %1$s · Instalar + Escolher conteúdo para PC + disponível com %1$s + Jogos comprados na Biblioteca + Adicionados + Inicialização + Salvamentos e nuvem + Atalho do Steam + Versão + Jogado pela última vez + Acesso + Comprado + Transmitir + Adicionar à Biblioteca + Remover da Biblioteca + Na Biblioteca + Mostrar + Nuvem + Configurações da nuvem diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index bded086b4..9bd9a1df3 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -1029,8 +1029,6 @@ Jocurile pe care le instalezi în Steam apar aici după sesiune. Joacă Steam Joacă SteamVR - Setări de lansare - Lansează Fișierele jocului Prefix Proton Se caută salvările… @@ -1038,7 +1036,6 @@ %1$d foldere de salvare %1$s · %2$d fișiere · %3$s %1$d KB - Gestionează salvările Salvările jocului Importă salvări… Exportă ca arhivă GameHub… @@ -1050,7 +1047,6 @@ Mediul de execuție se instalează la prima apăsare pe Play Actualizare disponibilă pentru mediul de execuție ● Mediu de execuție pregătit - Jucat ultima dată %1$s Stocare internă Joc adăugat Joc personalizat @@ -1119,7 +1115,7 @@ Terminal Linux Sesiunea Linux nu este încă pregătită pentru un terminal. Steam - Jocuri + Bibliotecă Desktop Magazin Componente @@ -1968,7 +1964,6 @@ Steam Personalizat Magazine - Deschide un magazin pe Nivelul vitezei de descărcare Motorul magazinelor ● Motor pregătit @@ -1982,11 +1977,9 @@ Aranjează filele Gata (neautentificat) - Magazin Bibliotecă Toate Descărcări - %1$s (%2$d) Autentificare %1$s · deconectare Te deconectezi de la %1$s? @@ -2004,7 +1997,6 @@ Oferte Jocuri gratuite În tendințe - Biblioteca ta Joacă Instalează Reia instalarea @@ -3042,4 +3034,38 @@ Vizualizările salvate pot fi recalculate. Începe o nouă calibrare pentru a captura în sistemul de coordonate de urmărire curent. Nepotrivire a sumei de control a modelului de mâini OpenCV nu s-a putut încărca + Neinstalat + Deschide + Instalează + Despre + Instalează + Locație + Folder + Dimensiune pe disc + Instalat + Actualizează / verifică + Gestionează conținutul + Închide acest joc Xbox înainte de a-l actualiza. + Conținut Xbox + Nu există pachete de conținut PC licențiate pentru acest joc. + %1$s · Actualizează / verifică + %1$s · Instalează + Alege conținutul PC + disponibil cu %1$s + Jocuri deținute în Bibliotecă + Adăugate + Lansare + Salvări și cloud + Scurtătură Steam + Versiune + Jucat ultima dată + Acces + Deținut + Redă în flux + Adaugă în Bibliotecă + Elimină din Bibliotecă + În Bibliotecă + Afișează + Cloud + Setări cloud diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 863ae944e..7fa1bc8a6 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1037,8 +1037,6 @@ Игры, установленные в Steam, появятся здесь после завершения сессии. Запустить Steam Запустить SteamVR - Настройки запуска - Запустить Файлы игры Префикс Proton Поиск сохранений… @@ -1046,7 +1044,6 @@ Папок сохранений: %1$d %1$s · файлов: %2$d · %3$s %1$d КБ - Управление сохранениями Сохранения игры Импорт сохранений… Экспорт как архив GameHub… @@ -1058,7 +1055,6 @@ Среда выполнения установится при первом запуске игры Доступно обновление среды выполнения ● Среда выполнения готова - Последний запуск %1$s Внутреннее хранилище Добавленная игра Своя игра @@ -1127,7 +1123,7 @@ Терминал Linux Сессия Linux не готова, терминал недоступен. Steam - Игры + Библиотека Рабочий стол Магазин Компоненты @@ -1991,7 +1987,6 @@ Steam Свои Магазины - Открывать магазин на Уровень скорости загрузки Движок магазинов ● Движок готов @@ -2005,11 +2000,9 @@ Упорядочить вкладки Готово (вход не выполнен) - Магазин Библиотека Все Загрузки - %1$s (%2$d) Войти %1$s · выйти Выйти из %1$s? @@ -2027,7 +2020,6 @@ Скидки Бесплатные игры Популярное - Ваша библиотека Играть Установить Продолжить установку @@ -3078,4 +3070,38 @@ Распознавание рук на GPU По умолчанию используется CPU. Режим GPU экспериментальный и может вызывать перезапуск SteamVR. Выбор сбрасывается при перезапуске отслеживания. Распознавание рук недоступно: %1$s. Измените режим CPU/GPU или выключите и снова включите управление руками, чтобы повторить попытку. + Не установлено + Открыть + Установить + О приложении + Установить + Расположение + Папка + Размер на диске + Установлен + Обновить / проверить + Управление контентом + Закройте эту игру Xbox перед обновлением. + Контент Xbox + Для этой игры нет лицензированных пакетов контента для ПК. + %1$s · Обновить / проверить + %1$s · Установить + Выберите контент для ПК + доступно с %1$s + Ваши игры в библиотеке + Добавленные + Запуск + Сохранения и облако + Ярлык Steam + Версия + Последний запуск + Доступ + Куплена + Стриминг + Добавить в библиотеку + Убрать из библиотеки + В библиотеке + Показать + Облако + Настройки облака diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 970c2f790..1729070d3 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -1023,8 +1023,6 @@ Spel du installerar i Steam visas här efter sessionen. Spela Steam Spela SteamVR - Startinställningar - Starta Spelfiler Proton-prefix Letar efter sparfiler… @@ -1032,7 +1030,6 @@ %1$d sparmappar %1$s · %2$d filer · %3$s %1$d KB - Hantera sparfiler Sparfiler Importera sparfiler… Exportera som GameHub-zip… @@ -1044,7 +1041,6 @@ Körmiljön installeras första gången du trycker på Play Uppdatering av körmiljön tillgänglig ● Körmiljö redo - Senast spelad %1$s Internt lagringsutrymme Tillagt spel Anpassat spel @@ -1113,7 +1109,7 @@ Linux-terminal Linux-sessionen är inte redo för en terminal än. Steam - Spel + Bibliotek Skrivbord Butik Komponenter @@ -1947,7 +1943,6 @@ Steam Egna Butiker - Öppna en butik på Nedladdningshastighet Butiksmotor ● Motor redo @@ -1961,11 +1956,9 @@ Ordna flikar Klart (inte inloggad) - Butik Bibliotek Alla Nedladdningar - %1$s (%2$d) Logga in %1$s · logga ut Logga ut från %1$s? @@ -1983,7 +1976,6 @@ Erbjudanden Gratisspel Populärt nu - Ditt bibliotek Spela Installera Återuppta installation @@ -3008,4 +3000,38 @@ Kontrollsumman för handmodellen inte överensstämmer OpenCV kunde inte laddas Virtuell skärm med 6DoF + Inte installerad + Öppna + Installera + Om + Installera + Plats + Mapp + Storlek på disk + Installerad + Uppdatera / verifiera + Hantera innehåll + Stäng det här Xbox-spelet innan du uppdaterar det. + Xbox-innehåll + Det finns inga licensierade PC-innehållspaket för det här spelet. + %1$s · Uppdatera / verifiera + %1$s · Installera + Välj PC-innehåll + tillgängligt med %1$s + Ägda spel i Bibliotek + Tillagda + Start + Sparade spel och moln + Steam-genväg + Version + Senast spelat + Åtkomst + Ägt + Strömma + Lägg till i Bibliotek + Ta bort från Bibliotek + I Bibliotek + Visa + Moln + Molninställningar diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index c22267719..3fca7f7dd 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -1017,8 +1017,6 @@ เกมที่คุณติดตั้งใน Steam จะปรากฏที่นี่หลังจากจบเซสชัน เล่น Steam เล่น SteamVR - การตั้งค่าการเปิดเกม - เปิดเกม ไฟล์เกม Prefix ของ Proton กำลังค้นหาเซฟ… @@ -1026,7 +1024,6 @@ %1$d โฟลเดอร์เซฟ %1$s · %2$d ไฟล์ · %3$s %1$d KB - จัดการเซฟ เซฟของเกม นำเข้าเซฟ… ส่งออกเป็น GameHub zip… @@ -1038,7 +1035,6 @@ รันไทม์จะติดตั้งเมื่อกด Play ครั้งแรก มีอัปเดตรันไทม์ ● รันไทม์พร้อมใช้งาน - เล่นล่าสุด %1$s พื้นที่จัดเก็บภายใน เกมที่เพิ่ม เกมกำหนดเอง @@ -1107,7 +1103,7 @@ เทอร์มินัล Linux เซสชัน Linux ยังไม่พร้อมสำหรับเทอร์มินัล Steam - เกม + ไลบรารี เดสก์ท็อป สโตร์ Components @@ -1926,7 +1922,6 @@ Steam กำหนดเอง สโตร์ - เปิดสโตร์บน ระดับความเร็วการดาวน์โหลด เอนจินสโตร์ ● เอนจินพร้อมใช้งาน @@ -1940,11 +1935,9 @@ จัดเรียงแท็บ เสร็จสิ้น (ยังไม่ลงชื่อเข้าใช้) - สโตร์ ไลบรารี ทั้งหมด การดาวน์โหลด - %1$s (%2$d) ลงชื่อเข้าใช้ %1$s · ลงชื่อออก ลงชื่อออกจาก %1$s? @@ -1962,7 +1955,6 @@ ดีล เกมฟรี กำลังเป็นที่นิยม - ไลบรารีของคุณ เล่น ติดตั้ง ดำเนินการติดตั้งต่อ @@ -2983,4 +2975,38 @@ ผลรวมตรวจสอบแบบจำลองมือไม่ตรงกัน OpenCV ไม่สามารถโหลดได้ กู้คืนมุมมองที่บันทึกไว้แล้ว %1$d รายการ คำนวณการสอบเทียบเพื่อลองอีกครั้ง หรือเริ่มชุดใหม่เพื่อบันทึกข้อมูลเพิ่มเติม + ยังไม่ติดตั้ง + เปิด + ติดตั้ง + เกี่ยวกับ + ติดตั้ง + ตำแหน่ง + โฟลเดอร์ + ขนาดบนดิสก์ + ติดตั้งแล้ว + อัปเดต / ตรวจสอบ + จัดการเนื้อหา + ปิดเกม Xbox นี้ก่อนอัปเดต + เนื้อหา Xbox + ไม่มีแพ็กเกจเนื้อหา PC ที่มีลิขสิทธิ์สำหรับเกมนี้ + %1$s · อัปเดต / ตรวจสอบ + %1$s · ติดตั้ง + เลือกเนื้อหา PC + มีให้เล่นกับ %1$s + เกมที่เป็นเจ้าของในไลบรารี + ที่เพิ่มเอง + การเปิดเกม + เซฟและคลาวด์ + ทางลัด Steam + เวอร์ชัน + เล่นล่าสุด + สิทธิ์เข้าถึง + เป็นเจ้าของ + สตรีม + เพิ่มในไลบรารี + นำออกจากไลบรารี + อยู่ในไลบรารี + แสดง + คลาวด์ + การตั้งค่าคลาวด์ diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index e992ce67d..9b917e233 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -1015,8 +1015,6 @@ Steam\'de yüklediğiniz oyunlar oturumdan sonra burada görünür. Steam\'de oyna SteamVR\'da oyna - Başlatma ayarları - Başlat Oyun dosyaları Proton öneki Kayıtlar aranıyor… @@ -1024,7 +1022,6 @@ %1$d kayıt klasörü %1$s · %2$d dosya · %3$s %1$d KB - Kayıtları yönet Oyun kayıtları Kayıtları içe aktar… GameHub zip\'i olarak dışa aktar… @@ -1036,7 +1033,6 @@ Çalışma ortamı ilk Oyna\'da yüklenir Çalışma ortamı güncellemesi var ● Çalışma ortamı hazır - Son oynanma %1$s Dahili depolama Eklenen oyun Özel oyun @@ -1105,7 +1101,7 @@ Linux terminali Linux oturumu henüz terminal için hazır değil. Steam - Oyunlar + Kütüphane Masaüstü Mağaza Bileşenler @@ -1941,7 +1937,6 @@ Steam Özel Mağazalar - Bir mağazayı şurada aç İndirme hızı katmanı Mağazalar motoru ● Motor hazır @@ -1955,11 +1950,9 @@ Sekmeleri düzenle Bitti (oturum açılmadı) - Mağaza Kütüphane Tümü İndirmeler - %1$s (%2$d) Oturum aç %1$s · oturumu kapat %1$s\'ten oturum kapatılsın mı? @@ -1977,7 +1970,6 @@ Fırsatlar Ücretsiz oyunlar Gündemde - Kütüphaneniz Oyna Yükle Yüklemeyi sürdür @@ -3010,4 +3002,38 @@ İyi aydınlatılmış bir odada yavaşça etrafa bakın.\nİzleme kameralarını temiz tutun ve mobilyalara veya diğer görünür ayrıntılara doğru bakın. Sabit kalibrasyon hedefinin en az 24 farklı görünümünü yakalayın. Akıllı gözlükleri sola ve sağa çevirin, yukarı ve aşağı eğin; dönme olmadan hareket, kamera hizalamasını kalibre edemez. + Yüklü değil + Aç + Yükle + Hakkında + Yükle + Konum + Klasör + Diskteki boyut + Yüklü + Güncelle / doğrula + İçeriği yönet + Güncellemeden önce bu Xbox oyununu kapatın. + Xbox içeriği + Bu oyun için lisanslı PC içerik paketi yok. + %1$s · Güncelle / doğrula + %1$s · Yükle + PC içeriği seçin + %1$s ile kullanılabilir + Kütüphane içindeki oyunlarınız + Eklenenler + Başlatma + Kayıtlar ve bulut + Steam kısayolu + Sürüm + Son oynanma + Erişim + Sahip olunan + Akış + Kütüphane içine ekle + Kütüphane içinden kaldır + Kütüphane içinde + Göster + Bulut + Bulut ayarları diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index d2de6b786..8b55100f6 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -1035,8 +1035,6 @@ Ігри, які ви встановите в Steam, з’являться тут після сеансу. Грати в Steam Грати в SteamVR - Налаштування запуску - Запустити Файли гри Префікс Proton Пошук збережень… @@ -1044,7 +1042,6 @@ %1$d папок збережень %1$s · %2$d файлів · %3$s %1$d KB - Керування збереженнями Збереження гри Імпортувати збереження… Експортувати як zip GameHub… @@ -1056,7 +1053,6 @@ Середовище встановлюється під час першого натискання «Грати» Є оновлення середовища ● Середовище готове - Останній раз грали %1$s Внутрішнє сховище Додана гра Власна гра @@ -1125,7 +1121,7 @@ Термінал Linux Сеанс Linux ще не готовий до роботи з терміналом. Steam - Ігри + Бібліотека Робочий стіл Магазин Компоненти @@ -1989,7 +1985,6 @@ Steam Власна Магазини - Відкривати магазин на Рівень швидкості завантаження Рушій магазинів ● Рушій готовий @@ -2003,11 +1998,9 @@ Упорядкувати вкладки Готово (вхід не виконано) - Магазин Бібліотека Усі Завантаження - %1$s (%2$d) Увійти %1$s · вийти Вийти з %1$s? @@ -2025,7 +2018,6 @@ Знижки Безкоштовні ігри Популярне - Ваша бібліотека Грати Встановити Продовжити встановлення @@ -3076,4 +3068,38 @@ Невідповідність контрольної суми моделі руки Не вдалося завантажити OpenCV Типово використовується CPU. Режим GPU є експериментальним і може спричинити перезапуск SteamVR. Вибір скидається після перезапуску відстеження. + Не встановлено + Відкрити + Установити + Про додаток + Установити + Розташування + Папка + Розмір на диску + Встановлено + Оновити / перевірити + Керувати вмістом + Закрийте цю гру Xbox перед оновленням. + Вміст Xbox + Для цієї гри немає ліцензованих пакетів вмісту для ПК. + %1$s · Оновити / перевірити + %1$s · Установити + Виберіть вміст для ПК + доступно з %1$s + Ваші ігри в бібліотеці + Додані + Запуск + Збереження й хмара + Ярлик Steam + Версія + Остання гра + Доступ + Придбана + Стримінг + Додати до бібліотеки + Прибрати з бібліотеки + У бібліотеці + Показати + Хмара + Налаштування хмари diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 57cca067a..0bde8b4a9 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -1017,8 +1017,6 @@ Game bạn cài trong Steam sẽ xuất hiện ở đây sau phiên. Chơi trên Steam Chơi trên SteamVR - Thiết lập khởi chạy - Khởi chạy Tệp game Prefix Proton Đang tìm các lần lưu… @@ -1026,7 +1024,6 @@ %1$d thư mục lưu %1$s · %2$d tệp · %3$s %1$d KB - Quản lý tệp lưu Tệp lưu game Nhập tệp lưu… Xuất dưới dạng zip GameHub… @@ -1038,7 +1035,6 @@ Runtime sẽ cài ở lần Play đầu tiên Có cập nhật runtime ● Runtime đã sẵn sàng - Chơi lần cuối %1$s Bộ nhớ trong Game đã thêm Game tùy chỉnh @@ -1107,7 +1103,7 @@ Terminal Linux Phiên Linux chưa sẵn sàng cho terminal. Steam - Games + Thư viện Desktop Cửa hàng Thành phần @@ -1926,7 +1922,6 @@ Steam Tùy chỉnh Cửa hàng - Mở một store trên Mức tốc độ tải Engine stores ● Engine sẵn sàng @@ -1940,11 +1935,9 @@ Sắp xếp thẻ Xong (chưa đăng nhập) - Cửa hàng Thư viện Tất cả Tải xuống - %1$s (%2$d) Đăng nhập %1$s · đăng xuất Đăng xuất khỏi %1$s? @@ -1962,7 +1955,6 @@ Ưu đãi Game miễn phí Xu hướng - Thư viện của bạn Chơi Cài Tiếp tục cài @@ -2983,4 +2975,38 @@ Tổng kiểm tra mô hình bàn tay không khớp OpenCV không thể tải Nhìn xung quanh chậm rãi trong phòng có đủ ánh sáng.\nGiữ cho camera theo dõi rõ ràng và hướng về phía đồ nội thất hoặc các chi tiết có thể nhìn thấy khác. + Chưa cài + Mở + Cài đặt + Giới thiệu + Cài đặt + Vị trí + Thư mục + Dung lượng trên đĩa + Đã cài + Cập nhật / xác minh + Quản lý nội dung + Hãy đóng trò chơi Xbox này trước khi cập nhật. + Nội dung Xbox + Không có gói nội dung PC có giấy phép cho trò chơi này. + %1$s · Cập nhật / xác minh + %1$s · Cài đặt + Chọn nội dung PC + có trong %1$s + Trò chơi đã sở hữu trong Thư viện + Đã thêm + Khởi chạy + Bản lưu và đám mây + Lối tắt Steam + Phiên bản + Chơi lần cuối + Quyền truy cập + Đã sở hữu + Phát trực tuyến + Thêm vào Thư viện + Xóa khỏi Thư viện + Trong Thư viện + Hiển thị + Đám mây + Cài đặt đám mây diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 8e188f2bc..a2ca7e857 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -1101,8 +1101,6 @@ 你在 Steam 安装的游戏会在会话结束后显示在这里。 游玩 Steam 游玩 SteamVR - 启动设置 - 启动 游戏文件 Proton 前缀 正在查找存档… @@ -1110,7 +1108,6 @@ %1$d 个存档文件夹 %1$s · %2$d 个文件 · %3$s %1$d KB - 管理存档 游戏存档 导入存档… 导出为 GameHub zip 包… @@ -1122,7 +1119,6 @@ 运行时在首次“游玩”时安装 运行时有可用更新 ● 运行时就绪 - 上次游玩于 %1$s 内部存储 已添加游戏 自定义游戏 @@ -1191,7 +1187,7 @@ Linux 终端 Linux 会话尚未准备好使用终端。 Steam - 游戏 + 库 桌面 商店 组件 @@ -2005,7 +2001,6 @@ Steam 自定义 游戏商店 - 打开商店时显示 下载速度档位 商店引擎 ● 引擎就绪 @@ -2019,11 +2014,9 @@ 整理标签 完成 (未登录) - 商店 库 全部 下载 - %1$s(%2$d) 登录 %1$s · 退出登录 要从 %1$s 退出登录吗? @@ -2041,7 +2034,6 @@ 特惠 免费游戏 趋势 - 你的库 游玩 安装 继续安装 @@ -2986,4 +2978,38 @@ 6DoF 虚拟屏幕 在光线充足的房间内缓慢环顾四周。\n不要遮挡追踪摄像头,并面向家具等有明显细节的物体。 DroidDeck ChArUco 30 mm + 未安装 + 打开 + 安装 + 关于 + 安装 + 位置 + 文件夹 + 占用空间 + 已安装 + 更新 / 验证 + 管理内容 + 更新前请先关闭此 Xbox 游戏。 + Xbox 内容 + 此游戏没有已授权的 PC 内容包。 + %1$s · 更新 / 验证 + %1$s · 安装 + 选择 PC 内容 + %1$s 可玩 + 游戏库中已拥有的游戏 + 已添加 + 启动 + 存档和云 + Steam 快捷方式 + 版本 + 上次游玩 + 访问权限 + 已拥有 + 串流 + 添加到游戏库 + 从游戏库移除 + 已在游戏库 + 显示 + 云 + 云设置 diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml index e271577a2..82daf9cca 100644 --- a/app/src/main/res/values-zh-rHK/strings.xml +++ b/app/src/main/res/values-zh-rHK/strings.xml @@ -1019,8 +1019,6 @@ 你在 Steam 安裝的遊戲會在會話結束後顯示在這裡。 開啟 Steam 開啟 SteamVR - 啟動設定 - 啟動 遊戲檔案 Proton 前綴 正在尋找存檔… @@ -1028,7 +1026,6 @@ %1$d 個存檔資料夾 %1$s · %2$d 個檔案 · %3$s %1$d KB - 管理存檔 遊戲存檔 匯入存檔… 匯出為 GameHub zip… @@ -1040,7 +1037,6 @@ 首次開啟 Steam 時安裝運行環境 運行環境有可用更新 ● 運行環境已就緒 - 上次遊玩:%1$s 內部儲存空間 已新增的遊戲 自訂遊戲 @@ -1109,7 +1105,7 @@ Linux 終端機 Linux 會話尚未準備好使用終端機。 Steam - 遊戲 + 收藏庫 桌面 商店 組件 @@ -1929,7 +1925,6 @@ Steam 自訂 商店 - 開啟商店時顯示 下載速度等級 商店引擎 ● 引擎已就緒 @@ -1943,11 +1938,9 @@ 整理標籤 完成 (未登入) - 商店 收藏庫 全部 下載 - %1$s(%2$d) 登入 %1$s · 登出 登出 %1$s? @@ -1965,7 +1958,6 @@ 優惠 免費遊戲 趨勢 - 你的收藏庫 開啟遊戲 安裝 繼續安裝 @@ -2986,4 +2978,38 @@ 6DoF 虛擬螢幕 在光線充足的房間內緩慢環顧四周。\n不要遮擋追蹤攝影機,並面向家具等有明顯細節的物體。 DroidDeck ChArUco 30 mm + 未安裝 + 開啟 + 安裝 + 關於 + 安裝 + 位置 + 資料夾 + 佔用空間 + 已安裝 + 更新 / 驗證 + 管理內容 + 更新前請先關閉此 Xbox 遊戲。 + Xbox 內容 + 此遊戲沒有已授權的 PC 內容套件。 + %1$s · 更新 / 驗證 + %1$s · 安裝 + 選擇 PC 內容 + %1$s 可玩 + 收藏庫中已擁有的遊戲 + 已加入 + 啟動 + 存檔及雲端 + Steam 捷徑 + 版本 + 上次遊玩 + 存取權 + 已擁有 + 串流 + 加入收藏庫 + 從收藏庫移除 + 已在收藏庫 + 顯示 + 雲端 + 雲端設定 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index f9f7cd52e..26d108539 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -1019,8 +1019,6 @@ 在 Steam 中安裝的遊戲會在工作階段結束後顯示在這裡。 執行 Steam 執行 SteamVR - 啟動設定 - 啟動 遊戲檔案 Proton 前綴 正在尋找存檔… @@ -1028,7 +1026,6 @@ %1$d 個存檔資料夾 %1$s · %2$d 個檔案 · %3$s %1$d KB - 管理存檔 遊戲存檔 匯入存檔… 匯出為 GameHub 壓縮檔… @@ -1040,7 +1037,6 @@ 首次點擊「執行」時安裝執行環境 執行環境有可用更新 ● 執行環境已就緒 - 上次執行 %1$s 內部儲存空間 已新增的遊戲 自訂遊戲 @@ -1109,7 +1105,7 @@ Linux 終端機 Linux 工作階段尚未準備好,無法使用終端機。 Steam - 遊戲 + 收藏庫 桌面 商店 元件 @@ -1929,7 +1925,6 @@ Steam 自訂 遊戲商店 - 開啟商店時顯示 下載速度等級 商店引擎 ● 引擎已就緒 @@ -1943,11 +1938,9 @@ 整理分頁 完成 (未登入) - 商店 收藏庫 全部 下載 - %1$s(%2$d) 登入 %1$s · 登出 要登出 %1$s 嗎? @@ -1965,7 +1958,6 @@ 特惠 免費遊戲 趨勢 - 你的收藏庫 執行 安裝 繼續安裝 @@ -2986,4 +2978,38 @@ 6DoF 虛擬螢幕 在光線充足的房間內緩慢環顧四周。\n不要遮擋追蹤攝影機,並面向家具等有明顯細節的物體。 DroidDeck ChArUco 30 mm + 未安裝 + 開啟 + 安裝 + 關於 + 安裝 + 位置 + 資料夾 + 佔用空間 + 已安裝 + 更新 / 驗證 + 管理內容 + 更新前請先關閉這款 Xbox 遊戲。 + Xbox 內容 + 這款遊戲沒有已授權的 PC 內容套件。 + %1$s · 更新 / 驗證 + %1$s · 安裝 + 選擇 PC 內容 + %1$s 可玩 + 收藏庫中已擁有的遊戲 + 已加入 + 啟動 + 存檔與雲端 + Steam 捷徑 + 版本 + 上次遊玩 + 存取權 + 已擁有 + 串流 + 加入收藏庫 + 從收藏庫移除 + 已在收藏庫 + 顯示 + 雲端 + 雲端設定 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d0d524647..1ca306627 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1069,8 +1069,6 @@ Games you install in Steam show up here after the session. Play Steam Play SteamVR - Launch settings - Launch Game files Proton prefix Looking for saves… @@ -1078,7 +1076,6 @@ %1$d save folders %1$s · %2$d files · %3$s %1$d KB - Manage saves Game saves Import saves… Export as GameHub zip… @@ -1090,7 +1087,6 @@ Runtime installs on first Play Runtime update available ● Runtime ready - Last played %1$s Internal storage Added game Custom game @@ -1162,7 +1158,7 @@ Linux terminal The Linux session isn\'t ready for a terminal yet. Steam - Games + Library Desktop Store Components @@ -2026,7 +2022,6 @@ Steam Custom Stores - Open a store on Download speed tier Stores engine ● Engine ready @@ -2040,11 +2035,9 @@ Arrange tabs Done (not signed in) - Store Library All Downloads - %1$s (%2$d) Sign in %1$s · sign out Sign out of %1$s? @@ -2062,7 +2055,6 @@ Deals Free games Trending - Your library Play Install Resume install @@ -3090,4 +3082,43 @@ Saved views can be recalculated. Start a new calibration to capture in the current tracking frame. Hand model checksum mismatch OpenCV could not load + Update / verify + Manage content + Close this Xbox game before updating it. + Xbox content + No licensed PC content packages are available for this game. + %1$s · Update / verify + %1$s · Install + Choose PC content + %1$s (%2$s) + PC Game Pass + available with %1$s + Owned games in Library + Not installed + Open + Install + Added + About + %1$s %2$d/%3$d + Launch + Saves & cloud + Install + Steam shortcut + Location + Folder + Size on disk + Version + Installed + Last played + Access + Owned + Stream + Add to Library + Remove from Library + In Library + Show + Cloud + Cloud settings + Xbox Cloud Gaming + Game Pass diff --git a/app/src/test/java/com/droiddeck/launcher/stores/SortCatalogItemsTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/SortCatalogItemsTest.kt index d0e7ef94f..8a315f3a1 100644 --- a/app/src/test/java/com/droiddeck/launcher/stores/SortCatalogItemsTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/stores/SortCatalogItemsTest.kt @@ -7,6 +7,13 @@ class SortCatalogItemsTest { private fun item(id: String, title: String, size: Long = 0, releaseDate: String = "") = CatalogItem(Store.GOG, id, title, imageUrl = null, sizeBytes = size, releaseDate = releaseDate, owned = true) + private fun sortCatalogItems( + items: List, sort: String, + lastPlayed: (CatalogItem) -> Long = { 0L }, installedAt: (CatalogItem) -> Long = { 0L }, + ) = sortGames(items, sort, { it.title }, { it.releaseDate }, lastPlayed, installedAt, { it.sizeBytes }) + + private fun retainCatalogOrder(items: List, previous: List) = retainOrder(items, previous) { it.key } + @Test fun titleOrderIsCaseInsensitiveAndDescendingIsItsMirror() { val items = listOf(item("1", "Zelda"), item("2", "alpha"), item("3", "Beta")) assertEquals(listOf("alpha", "Beta", "Zelda"), sortCatalogItems(items, "titleAsc").map { it.title }) diff --git a/app/src/test/java/com/droiddeck/launcher/stores/epic/EpicClientTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/epic/EpicClientTest.kt index c0687db98..f5ba46384 100644 --- a/app/src/test/java/com/droiddeck/launcher/stores/epic/EpicClientTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/stores/epic/EpicClientTest.kt @@ -16,17 +16,6 @@ import java.security.MessageDigest class EpicClientTest { @get:Rule val tmp = TemporaryFolder() - private fun game(appId: Int, source: String) = - com.droiddeck.launcher.frontend.Library.SteamGame(appId, "g$appId", null, "internal", source = source) - - @Test fun theLauncherSitsRightAboveTheFirstEpicGame() { - val launcher = game(EpicClient.LAUNCHER_APP_ID, "epic") - val games = listOf(game(1, "steam"), game(2, "epic"), game(3, "steam"), game(4, "epic")) - assertEquals(listOf(1, 0, 2, 3, 4), EpicClient.withLauncher(games, launcher).map { it.appId }) - assertEquals(listOf(1, 3, 0), EpicClient.withLauncher(listOf(games[0], games[2]), launcher).map { it.appId }) - assertEquals(listOf(1, 2), EpicClient.withLauncher(games.take(2), null).map { it.appId }) - } - private fun added(name: String, source: String, storeId: String?) = com.droiddeck.launcher.frontend.AddedGames.Game(File(tmp.root, name), name, File(tmp.root, "$name/$name.exe"), "/root/Games/$name/$name.exe", "/root/Games/$name", 3_000_000_000L, 0L, emptyList(), source = source, storeId = storeId) diff --git a/app/src/test/java/com/droiddeck/launcher/ui/GamesClientTagTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/GamesClientTagTest.kt index 9f89330c1..a86bf350f 100644 --- a/app/src/test/java/com/droiddeck/launcher/ui/GamesClientTagTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/ui/GamesClientTagTest.kt @@ -48,7 +48,7 @@ class GamesClientTagTest { DroidDeckTheme { CompositionLocalProvider(LocalFrontFocus provides FrontFocus(), LocalNarrowPane provides narrow) { FocusGlideHost { - GamesPage(FrontEndState( + LibraryPage(FrontEndState( installed = "r9", ready = true, available = null, busy = false, stage = "", percent = -1, desktopInstalled = true, offlineAccount = null, offline = false, frameGenLabel = "", romsDir = null, logsEnabled = false, steamGames = games, steamGamesX64 = x64, emulators = emptyList(), running = null, @@ -60,7 +60,7 @@ class GamesClientTagTest { onRoms = {}, onFiles = {}, onLogs = {}, onOffline = {}, onWirelessAdbPair = { _, _, _, _ -> }, onWirelessAdbApply = { _, _, _, _ -> }, onSetPhantomProcessLimit = { _, _ -> }, - ), selected = "app:440", onSelect = {}, modifier = Modifier.fillMaxSize()) + ), selected = "games", onSelect = {}, modifier = Modifier.fillMaxSize()) } } } diff --git a/app/src/test/java/com/droiddeck/launcher/ui/LibraryEntriesTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/LibraryEntriesTest.kt new file mode 100644 index 000000000..65a3a72b0 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/ui/LibraryEntriesTest.kt @@ -0,0 +1,51 @@ +package com.droiddeck.launcher.ui + +import com.droiddeck.launcher.frontend.Library +import com.droiddeck.launcher.stores.CatalogItem +import com.droiddeck.launcher.stores.Store +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class LibraryEntriesTest { + private fun owned(store: Store, id: String, title: String = id) = CatalogItem(store, id, title, imageUrl = null, owned = true) + + @Test fun anInstalledStoreGameClaimsItsOwnedProductSoItShowsOnce() { + val games = listOf( + Library.SteamGame(440, "Team Fortress 2", null, "Steam library"), + Library.SteamGame(-5, "Cyberpunk", null, Library.ADDED, source = "gog", storeId = "1423049311"), + ) + val library = mapOf(Store.GOG to listOf(owned(Store.GOG, "1423049311", "Cyberpunk 2077"), owned(Store.GOG, "1207658924", "Witcher"))) + val entries = libraryEntries(games, library, emptySet()) + assertEquals(listOf("app:440", "app:-5", "app:gog:1207658924"), entries.map { it.key }) + assertEquals("gog:1423049311", entries[1].item?.key) + assertTrue(entries[1].installed) + assertFalse(entries[2].installed) + assertNull(entries[2].game) + } + + @Test fun theEpicLauncherIsNotAGame() { + val launcher = Library.SteamGame(0, Library.EPIC_LAUNCHER_NAME, null, Library.EPIC_LAUNCHER) + assertEquals(emptyList(), libraryEntries(listOf(launcher), emptyMap(), emptySet()).map { it.key }) + } + + @Test fun aStoreInstallTheLibraryHasNotScannedYetCountsAsInstalled() { + val entries = libraryEntries(emptyList(), mapOf(Store.EPIC to listOf(owned(Store.EPIC, "fn"))), setOf("epic:fn")) + assertTrue(entries.single().installed) + } + + @Test fun installedGamesComeFirstKeepingTheirSortedOrder() { + val entries = listOf("a", "b", "c", "d").map { LibraryEntry(it, "steam", it, null, null, installed = it == "b" || it == "d") } + assertEquals(listOf("b", "d", "a", "c"), installedFirst(entries).map { it.key }) + } + + @Test fun gameKeysNameEitherAnAppOrAStoreProduct() { + GameRef.parse("app:440").let { assertEquals(440, it.appId); assertNull(it.store) } + GameRef.parse("app:-12").let { assertEquals(-12, it.appId) } + GameRef.parse("app:gog:1207658924").let { assertEquals(Store.GOG, it.store); assertEquals("1207658924", it.storeId); assertNull(it.appId) } + GameRef.parse("xbox:9NBLGGH4R315").let { assertEquals(Store.XBOX, it.store); assertEquals("9NBLGGH4R315", it.storeId) } + GameRef.parse("app:nope:1").let { assertNull(it.store); assertNull(it.appId) } + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/ui/StoreGridNavigationTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/StoreGridNavigationTest.kt index ad1b13df7..2fe9509d8 100644 --- a/app/src/test/java/com/droiddeck/launcher/ui/StoreGridNavigationTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/ui/StoreGridNavigationTest.kt @@ -46,9 +46,4 @@ class StoreGridNavigationTest { assertEquals(10, storeGridNeighbor(6, 11, 4, 1, split = 6)) assertNull(storeGridNeighbor(1, 11, 4, -1, split = 6)) } - - @Test fun installedGamesComeFirstKeepingTheirSortedOrder() { - val items = listOf("a", "b", "c", "d").map { com.droiddeck.launcher.stores.CatalogItem(com.droiddeck.launcher.stores.Store.GOG, it, it, null) } - assertEquals(listOf("b", "d", "a", "c"), installedFirst(items, setOf("d", "b")).map { it.id }) - } } diff --git a/app/src/test/java/com/droiddeck/launcher/ui/StoreTabsTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/StoreTabsTest.kt deleted file mode 100644 index a5578a0f7..000000000 --- a/app/src/test/java/com/droiddeck/launcher/ui/StoreTabsTest.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.droiddeck.launcher.ui - -import com.droiddeck.launcher.stores.Store -import org.junit.Assert.assertEquals -import org.junit.Test - -class StoreTabsTest { - @Test fun libraryAndStoreAmazonLibraryOnly() { - assertEquals(listOf("library", "store"), tabsFor(Store.GOG)) - assertEquals(listOf("library", "store"), tabsFor(Store.ITCH)) - assertEquals(listOf("library"), tabsFor(Store.AMAZON)) - assertEquals("library", tabFor(Store.AMAZON, "store")) - assertEquals("library", tabFor(Store.GOG, "all")) - assertEquals("library", tabFor(Store.EPIC, "installed")) - assertEquals("store", tabFor(Store.GOG, "store")) - } -} diff --git a/docs/agent-control.md b/docs/agent-control.md index 98cf552e3..b3ec1c073 100644 --- a/docs/agent-control.md +++ b/docs/agent-control.md @@ -338,8 +338,11 @@ Automator show them: | Tag | Where | |---|---| -| `rail-steam`, `rail-games`, `rail-desktop`, `rail-store`, `rail-components`, `rail-android-apps`, `rail-setup`, `rail-updates` | the front end's rail | -| `play-steam` | Play on the Steam and Games pages | +| `rail-steam`, `rail-games` (the Library), `rail-desktop`, `rail-store`, `rail-components`, `rail-android-apps`, `rail-setup`, `rail-updates` | the front end's rail | +| `play-steam` | Play on the Steam page, and on the Library when it has no games | +| `library-source-` | the Library's source filter chips (`all`, `steam`, `gog`, ...) | +| `game-tab-` | the open tab of a game page | +| `stores-profile`, `stores-epic-launcher` | a store's profile strip, and the Epic Games Launcher row in Epic's | | `setting-` | every toggle and choice row, by its key (`setting-logs`, `setting-agent-commands`, ...) | | `tab-` | a page's tab strip (Setup's Overview, Controller, Session, Launcher...), by position | | `drawer-tab-controller`, `-second_screen`, `-display`, `-effects`, `-vr`, `-games`, `-session` | the session drawer's pages | diff --git a/docs/development/added-games.md b/docs/development/added-games.md index c39ae8d14..625d8188e 100644 --- a/docs/development/added-games.md +++ b/docs/development/added-games.md @@ -1,6 +1,6 @@ # Added games -Windows games the user adds to DroidDeck's Games tab, each a non-Steam shortcut in the +Windows games the user adds to DroidDeck's Library, each a non-Steam shortcut in the Steam client that runs under `droiddeck-proton-arm64`. They show with the Custom chip. ## Adding diff --git a/docs/development/steam-display-scaling.md b/docs/development/steam-display-scaling.md index 9551de73a..3cc677db7 100644 --- a/docs/development/steam-display-scaling.md +++ b/docs/development/steam-display-scaling.md @@ -227,7 +227,7 @@ flag for an experiment. Device check (Thor): Alan Wake's American Nightmare with its 1280x720 Game Resolution runs on `:1` at 1280x720 with no `--force-windows-fullscreen` in the session, 2 frame-size changes, no crash -in logcat. The Games tab and the drawer render without the switch. +in logcat. The Library and the drawer render without the switch. ## Steam picture position diff --git a/docs/development/stores.md b/docs/development/stores.md index fbeec4f6b..0d35745b6 100644 --- a/docs/development/stores.md +++ b/docs/development/stores.md @@ -3,6 +3,21 @@ How the Stores section (`app/src/main/java/com/droiddeck/launcher/stores/`, `ui/Stores*.kt`) fits the rest of the app, and the on-disk contract that makes a store game an ordinary added game. +## Library and Stores + +The rail's Library (`ui/LibraryPage.kt`, rail key `games`) lists every owned game: installed ones +from the scanned library (`FrontEndState.steamGames`: Steam, added folders, store installs), then, +under Not installed, each signed-in store's `StoresState.library` products that are not. An +installed game claims its product by `":" == CatalogItem.key`, so nothing shows +twice. Stores is only for getting games: a store opens on its storefront (search, then the shelves) +under a profile strip whose "Owned games in Library" opens the Library filtered to that store. The +Epic Games Launcher (open, settings, uninstall, install) lives in Epic's profile strip. + +Every game opens one page (`ui/GameDetail.kt`) from either place: the hero and its actions by state +(installed, downloading, failed, unfinished, owned, not owned), then tabs LB/RB cycle through: +About, Achievements, Launch, Saves & cloud and Install, each hidden when it has nothing to show. +Library pages are `app:` for an installed game and `app::` for a store product. + ## Where a store game lives ``` @@ -180,8 +195,8 @@ The appid Steam derives (CRC32 of the quoted exe plus the name, high bit set) is listing carries, so the live add and the writer never disagree. Uninstall removes the folder and the listing drops the entry; the live client is asked to remove it too. -`Library.SteamGame.source` is `steam`, `gog`, `epic`, `amazon` or `added`; the Games tab shows it as -a chip on every row and in the hero. +`Library.SteamGame.source` is `steam`, `gog`, `epic`, `amazon` or `added`; the Library shows it as +a chip on every tile and in the hero, and filters by it. ## Launch @@ -194,7 +209,7 @@ game that needs arguments or environment gets `.droiddeck-launch.bat` (`StoreLau the variable it read the code into is cleared on that same line, so nothing of it is left in the game's environment. A launcher from an earlier build is rewritten before the next Epic launch. -The code is minted for every launch, wherever it starts. The Games tab and Stores call +The code is minted for every launch, wherever it starts. The game page calls `StoreLaunch.prepare` before they launch; the compat tool (`droiddeck-proton`, `droiddeck-proton-wrap`) runs `droiddeck-store-launch` before Proton on every real launch, the Steam client's Play button included. It finds the store launcher among Steam's arguments, reads the @@ -202,7 +217,7 @@ sidecar beside it, and for an Epic game asks the app through `/stores/r (`StoreLaunchRequests` → `StoreLaunch.epicCode`), waiting up to 5 s; the app writes the code into the game's folder itself. Each attempt logs `epic launch id=… code=yes|no reason=…`, never the code. -An Epic game's launch choices are the Epic card in the Games tab's launch settings - "Epic sign-in +An Epic game's launch choices are the Epic card in its page's Launch tab - "Epic sign-in (EOS)" (on) and "Launch offline" (off), stored in the sidecar's `epic` block so both launch paths read the same thing - and "Resolve Epic sign-in", which opens Epic's account page behind its sign-in in Android's browser. @@ -293,7 +308,6 @@ report "the store engine is not available". | Setting | Pref | Where | |---|---|---| | Show Stores in the rail | `SessionPrefs.gameStoresEnabled` (off) | Setup › Stores | -| Open a store on: Library · Store | `SessionPrefs.storesOpenTab` (library) | the chip row's cog, Setup › Stores | | Download speed tier | `SessionPrefs.gameStoresSpeedTier` (fast) | the cog, Setup › Stores, Downloads | | Downloads at a time | `SessionPrefs.gameStoresParallel` (1) | Downloads | | Install to (last pick, dialog default only) | `SessionPrefs.storesInstallTarget` | the Install dialog | diff --git a/docs/development/x86-64-steam-client.md b/docs/development/x86-64-steam-client.md index af3c02f4a..b3e2c5caf 100644 --- a/docs/development/x86-64-steam-client.md +++ b/docs/development/x86-64-steam-client.md @@ -3,7 +3,7 @@ Valve's x86-64 Linux Steam client, run unchanged under FEX beside the default arm64 client. It is opt-in: Steam settings, **Steam client: x86-64** (`steamClientArch`, passed to the session as `BL_STEAM_CLIENT_ARCH=x86_64`, applies from the next session), or the desktop menu entry -**Steam x86-64 (FEX, experimental)**. The Games tab lists the active client's games and tags its +**Steam x86-64 (FEX, experimental)**. The Library lists the active client's games and tags its header "x86-64" while that client's list is shown. This page records why each workaround exists and what was seen on devices. The timeline is in `docs/progress-log.md`.