From 332c4ea75183fc9c4da6e2eda3f795e4bf06ae03 Mon Sep 17 00:00:00 2001 From: The412Banner Date: Mon, 26 Oct 2026 10:38:03 +0000 Subject: [PATCH] GFN: GeForce NOW as a Stores tab GeForce NOW was its own screen behind a chip. It is now a tab in the Stores tab row after the store tabs, reached by tapping, the shoulder buttons or focus like the others, with its pane in the same animated pane slot: - Signed out: a sign-in card like the stores' (title, Sign in, device code inline). - Signed in: a profile strip (name, library count, Refresh, Sign out), Store / My library / Connected stores pills next to the usual search header, and games as the app's own cards (CardFace) in the adaptive store grid, with controller focus. A card plays the game. - NVIDIA green for the tab's chip, dot and accents (GfnTheme). The OpenNOW core locks its data directory, so it keeps running once, in the :gfn process with the player. GfnCoreService there answers core requests from the main process over a binder; GfnCore routes to it outside :gfn. The state and requests the old screen held moved to GfnState so they survive tab switches. GfnActivity is removed. --- app/src/main/AndroidManifest.xml | 8 +- .../launcher/stores/gfn/GfnActivity.kt | 515 ------------------ .../droiddeck/launcher/stores/gfn/GfnCore.kt | 8 + .../launcher/stores/gfn/GfnCoreService.kt | 100 ++++ .../droiddeck/launcher/stores/gfn/GfnState.kt | 367 +++++++++++++ .../java/com/droiddeck/launcher/ui/GfnPane.kt | 375 +++++++++++++ .../com/droiddeck/launcher/ui/StoresPage.kt | 38 +- app/src/main/res/values/strings.xml | 30 + 8 files changed, 905 insertions(+), 536 deletions(-) delete mode 100644 app/src/main/java/com/droiddeck/launcher/stores/gfn/GfnActivity.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/stores/gfn/GfnCoreService.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/stores/gfn/GfnState.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/ui/GfnPane.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8fd115b62..5fdb058d2 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -180,11 +180,9 @@ android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|smallestScreenSize|density|navigation|uiMode" android:theme="@style/AppTheme" /> - - + + (Screen.Loading) } - val scope = rememberCoroutineScope() - val load: () -> Unit = { - screen = Screen.Loading - scope.launch { screen = withContext(Dispatchers.IO) { loadSignedIn() } } - } - LaunchedEffect(Unit) { load() } - Column(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) { - Row(Modifier.fillMaxWidth().padding(16.dp), verticalAlignment = Alignment.CenterVertically) { - OutlinedButton(onClick = { finish() }) { Text("Back") } - Text("GeForce NOW", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onBackground, modifier = Modifier.padding(horizontal = 16.dp).weight(1f)) - val current = screen - if (current is Screen.Games) { - Text(current.user, color = MaterialTheme.colorScheme.onBackground, modifier = Modifier.padding(end = 12.dp)) - OutlinedButton(onClick = { - scope.launch { - withContext(Dispatchers.IO) { runCatching { GfnCore.request(this@GfnActivity, "auth.logout") } } - screen = Screen.SignedOut() - } - }) { Text("Sign out") } - } - } - Text("Unofficial client built on OpenNOW. NVIDIA does not support third-party GeForce NOW apps.", - color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f), fontSize = 12.sp, - modifier = Modifier.padding(horizontal = 16.dp)) - when (val current = screen) { - Screen.Loading -> Message("Loading…") - is Screen.Failed -> Column(Modifier.padding(24.dp)) { - Text(current.message, color = MaterialTheme.colorScheme.onBackground) - Spacer(Modifier.height(12.dp)) - Button(onClick = load) { Text("Try again") } - } - is Screen.SignedOut -> Column(Modifier.padding(24.dp)) { - Text("Sign in with your NVIDIA account to play your GeForce NOW games.", - color = MaterialTheme.colorScheme.onBackground) - current.error?.let { Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(top = 8.dp)) } - Spacer(Modifier.height(16.dp)) - Button(onClick = { - scope.launch { - screen = withContext(Dispatchers.IO) { - runCatching { startSignIn() }.getOrElse { Screen.SignedOut(it.message) } - } - } - }) { Text("Sign in") } - } - is Screen.Code -> { - SignInCode(current) { - startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(current.url))) - } - LaunchedEffect(current.attemptId) { - screen = withContext(Dispatchers.IO) { waitForSignIn(current) } - } - } - is Screen.Games -> GamesHome(current) - } - } - } - - @Composable - private fun Message(text: String) { - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text(text, color = MaterialTheme.colorScheme.onBackground) - } - } - - @Composable - private fun SignInCode(code: Screen.Code, openBrowser: () -> Unit) { - Column(Modifier.padding(24.dp)) { - Text("On this phone or another device, open", color = MaterialTheme.colorScheme.onBackground) - Text(code.url, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold, modifier = Modifier.padding(vertical = 6.dp)) - Text("and enter this code:", color = MaterialTheme.colorScheme.onBackground) - Text(code.userCode, fontSize = 34.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onBackground, - modifier = Modifier.padding(vertical = 12.dp)) - Button(onClick = openBrowser) { Text("Open the sign-in page") } - Text("Waiting for you to sign in…", color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f), - modifier = Modifier.padding(top = 12.dp)) - } - } - - @Composable - private fun GamesHome(catalog: Screen.Games) { - var tab by androidx.compose.runtime.saveable.rememberSaveable { mutableStateOf(0) } - Row(Modifier.padding(horizontal = 16.dp, vertical = 6.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { - listOf("Store", "My library", "Connected stores").forEachIndexed { index, label -> - androidx.compose.material3.FilterChip(selected = tab == index, onClick = { tab = index }, label = { Text(label) }) - } - } - when (tab) { - 0 -> GameGrid(catalog) { game -> play(game) } - 1 -> LibraryGrid { game -> play(game) } - else -> ConnectedStores() - } - } - - @Composable - private fun GameCard(game: Game, onPlay: (Game) -> Unit, markLibrary: Boolean = true) { - Column(Modifier.clip(RoundedCornerShape(8.dp)).background(MaterialTheme.colorScheme.surfaceVariant) - .clickable { onPlay(game) }) { - AsyncImage(model = game.imageUrl, contentDescription = game.title, contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxWidth().aspectRatio(460f / 215f)) - Text(game.title, maxLines = 1, overflow = TextOverflow.Ellipsis, fontWeight = FontWeight.SemiBold, fontSize = 13.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 8.dp, top = 5.dp, end = 8.dp)) - Text(game.store + if (markLibrary && game.inLibrary) " · in library" else "", fontSize = 11.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), - modifier = Modifier.padding(start = 8.dp, bottom = 6.dp, end = 8.dp)) - } - } - - /** The account's GeForce NOW library: games from connected stores and ones added on GeForce NOW. */ - @Composable - private fun LibraryGrid(onPlay: (Game) -> Unit) { - var games by remember { mutableStateOf?>(null) } - var error by remember { mutableStateOf(null) } - var query by remember { mutableStateOf("") } - var reload by remember { mutableStateOf(0) } - LaunchedEffect(reload) { - error = null - withContext(Dispatchers.IO) { runCatching { libraryGames() } } - .onSuccess { games = it.sortedBy { game -> game.title.lowercase() } } - .onFailure { error = it.message ?: "Your GeForce NOW library could not be loaded." } - } - val words = query.trim().lowercase().split(' ').filter { it.isNotEmpty() } - val shown = games.orEmpty().filter { game -> words.all { game.search.contains(it) } } - Column { - Row(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) { - OutlinedTextField(value = query, onValueChange = { query = it }, singleLine = true, - label = { Text("Search your ${games?.size ?: 0} games") }, modifier = Modifier.weight(1f)) - OutlinedButton(onClick = { games = null; reload += 1 }, modifier = Modifier.padding(start = 12.dp)) { Text("Refresh") } - } - error?.let { Text(it, color = MaterialTheme.colorScheme.error, fontSize = 12.sp, modifier = Modifier.padding(horizontal = 16.dp)) } - when { - games == null && error == null -> Message("Loading your library…") - games?.isEmpty() == true -> Message("Your GeForce NOW library is empty. Connect your stores in Connected stores, then sync.") - else -> LazyVerticalGrid(columns = GridCells.Fixed(5), modifier = Modifier.fillMaxSize(), - horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp), - contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 16.dp, vertical = 8.dp)) { - items(shown, key = { it.id }) { game -> GameCard(game, onPlay, markLibrary = false) } - } - } - } - } - - private fun libraryGames(): List { - val games = LinkedHashMap() - val traversal = System.currentTimeMillis().toString() - var cursor = "" - var revision: Any? = null - var context = "" - repeat(50) { - val params = JSONObject().put("limit", 100).put("cursor", cursor).put("traversalId", traversal) - .put("catalogRevision", revision ?: JSONObject.NULL).put("catalogContext", context) - val page = GfnCore.request(this, "catalog.library.list", params, timeoutMs = 60_000) - gamesOf(page).forEach { games.putIfAbsent(it.id, it.copy(inLibrary = true)) } - revision = page.opt("catalogRevision") - context = page.optString("catalogContext") - cursor = page.optString("nextCursor") - if (!page.optBoolean("hasNextPage") || cursor.isEmpty()) return games.values.toList() - } - return games.values.toList() - } - - private data class StoreAccount(val provider: String, val label: String, val displayName: String, val connected: Boolean, - val status: String, val syncState: String, val canLink: Boolean, val canSync: Boolean) - - /** Store accounts linked to GeForce NOW, which fill the GeForce NOW library. */ - @Composable - private fun ConnectedStores() { - var accounts by remember { mutableStateOf?>(null) } - var message by remember { mutableStateOf(null) } - var busy by remember { mutableStateOf(false) } - var reload by remember { mutableStateOf(0) } - val scope = rememberCoroutineScope() - val scroll = androidx.compose.foundation.rememberScrollState() - LaunchedEffect(reload) { - withContext(Dispatchers.IO) { runCatching { storeAccounts() } } - .onSuccess { accounts = it }.onFailure { message = it.message ?: "Connected stores could not be loaded." } - } - fun perform(action: suspend () -> String) { - if (busy) return - busy = true - scope.launch { - message = runCatching { action() }.getOrElse { it.message ?: "That did not work. Try again." } - busy = false - reload += 1 - } - } - Column(Modifier.fillMaxSize().padding(horizontal = 16.dp).verticalScroll(scroll)) { - Text("Games in stores you connect here show up in My library and can be streamed. Amazon and itch.io games are not on GeForce NOW.", - color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f), fontSize = 13.sp, modifier = Modifier.padding(vertical = 8.dp)) - message?.let { Text(it, color = MaterialTheme.colorScheme.primary, modifier = Modifier.padding(bottom = 8.dp)) } - if (accounts == null) Text("Loading…", color = MaterialTheme.colorScheme.onBackground) - accounts.orEmpty().forEach { account -> - Row(Modifier.fillMaxWidth().padding(vertical = 6.dp).clip(RoundedCornerShape(8.dp)) - .background(MaterialTheme.colorScheme.surfaceVariant).padding(12.dp), verticalAlignment = Alignment.CenterVertically) { - Column(Modifier.weight(1f)) { - Text(account.label, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurfaceVariant) - Text(statusText(account), fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.75f)) - } - when (actionFor(account)) { - "link" -> Button(enabled = !busy, onClick = { perform { link(account) } }) { - Text(if (account.connected) "Reconnect" else "Connect") - } - "sync" -> Button(enabled = !busy, onClick = { perform { sync(account) } }) { Text("Sync library") } - "unlink" -> OutlinedButton(enabled = !busy, onClick = { perform { unlink(account) } }) { Text("Disconnect") } - else -> if (account.provider == "STEAM" && !account.connected) - Text("Link Steam on NVIDIA's site", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) - } - } - } - } - } - - private fun statusText(account: StoreAccount): String = when { - account.status == "expired" -> "Connection expired: reconnect" - account.status == "sync_error" -> "Last sync failed" - account.connected -> "Connected" + if (account.displayName.isNotEmpty()) " as ${account.displayName}" else "" - else -> "Not connected" - } - - /** OpenNOW's own rule for which action a store row offers. */ - private fun actionFor(account: StoreAccount): String = when { - account.canLink && (!account.connected || account.status == "expired" || - (account.syncState == "SYNC_DENIED" && account.provider != "STEAM")) -> "link" - account.canSync -> "sync" - account.connected && account.canLink -> "unlink" - else -> "none" - } - - private fun storeAccounts(): List { - val list = GfnCore.request(this, "account.connections.list").optJSONArray("accounts") ?: return emptyList() - return (0 until list.length()).mapNotNull { index -> - val account = list.optJSONObject(index) ?: return@mapNotNull null - if (account.optString("provider") in setOf("", "UNKNOWN", "NONE", "NVIDIA")) return@mapNotNull null - StoreAccount(provider = account.optString("provider"), label = account.optString("label").ifEmpty { account.optString("provider") }, - displayName = account.optString("displayName"), - connected = account.optBoolean("isConnected") || account.optString("status") == "connected", - status = account.optString("status"), syncState = account.optString("syncState"), - canLink = account.optBoolean("supportsLinking"), canSync = account.optBoolean("supportsSync")) - }.filter { it.canLink || it.canSync || it.connected } - } - - private suspend fun link(account: StoreAccount): String { - val attempt = withContext(Dispatchers.IO) { - GfnCore.request(this@GfnActivity, "account.connections.link.start", JSONObject().put("provider", account.provider)) - } - val url = attempt.optString("loginUrl") - if (url.isNotEmpty()) startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) - repeat(150) { - delay(2_000) - val poll = withContext(Dispatchers.IO) { - GfnCore.request(this@GfnActivity, "account.connections.link.poll", - JSONObject().put("attemptId", attempt.optString("attemptId")), timeoutMs = 10_000) - } - when (poll.optString("status")) { - "pending" -> {} - "complete" -> return "${account.label} connected." - else -> return poll.optString("message").ifEmpty { "${account.label} was not connected." } - } - } - return "Stopped waiting for ${account.label}. Finish signing in, then refresh." - } - - private suspend fun sync(account: StoreAccount): String = withContext(Dispatchers.IO) { - var state = GfnCore.request(this@GfnActivity, "account.connections.sync", JSONObject().put("provider", account.provider)) - val operation = state.optString("operationId") - if (operation.isEmpty()) return@withContext state.optString("message").ifEmpty { "${account.label} synced." } - repeat(90) { - when (state.optString("phase")) { - "waiting_remote", "starting" -> { - delay(state.optLong("retryAfterMs", 2_000).coerceAtLeast(2_000)) - state = GfnCore.request(this@GfnActivity, "account.connections.sync.status", JSONObject().put("operationId", operation)) - } - "refreshing_library" -> { - runCatching { libraryGames() } - runCatching { - GfnCore.request(this@GfnActivity, "account.connections.sync.status", - JSONObject().put("operationId", operation).put("libraryRefreshed", true), timeoutMs = 10_000) - } - return@withContext "${account.label} synced. Your library is up to date." - } - "timed_out" -> return@withContext "The sync could not be confirmed. ${account.label} may still finish; refresh later." - else -> return@withContext "${account.label} could not finish the sync. Try again." - } - } - "Still waiting for ${account.label}; refresh later." - } - - private suspend fun unlink(account: StoreAccount): String = withContext(Dispatchers.IO) { - GfnCore.request(this@GfnActivity, "account.connections.unlink", JSONObject().put("provider", account.provider)) - .optString("message").ifEmpty { "${account.label} disconnected." } - } - - private class Page(val games: List, val next: String, val more: Boolean, val total: Int) - - @Composable - private fun GameGrid(catalog: Screen.Games, onPlay: (Game) -> Unit) { - var query by remember { mutableStateOf("") } - var games by remember { mutableStateOf(emptyList()) } - var next by remember { mutableStateOf("") } - var more by remember { mutableStateOf(true) } - var total by remember { mutableStateOf(0) } - var loading by remember { mutableStateOf(false) } - var error by remember { mutableStateOf(null) } - var generation by remember { mutableStateOf(0) } - val scope = rememberCoroutineScope() - val grid = androidx.compose.foundation.lazy.grid.rememberLazyGridState() - fun loadMore(reset: Boolean) { - if (loading || (!reset && !more)) return - loading = true - val asked = generation - val cursor = if (reset) "" else next - val text = query.trim() - scope.launch { - val page = withContext(Dispatchers.IO) { runCatching { storePage(cursor, text) } } - if (asked != generation) return@launch - loading = false - page.onSuccess { - error = null - val seen = if (reset) HashSet() else games.map { game -> game.id }.toHashSet() - games = (if (reset) emptyList() else games) + it.games.filter { game -> seen.add(game.id) } - next = it.next - more = it.more - total = maxOf(it.total, games.size) - }.onFailure { error = it.message ?: "The GeForce NOW store did not answer." } - } - } - LaunchedEffect(query) { - delay(if (query.isEmpty()) 0 else 350) - generation += 1 - loading = false - more = true - loadMore(reset = true) - } - val nearEnd by remember { - androidx.compose.runtime.derivedStateOf { - val last = grid.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 - last >= grid.layoutInfo.totalItemsCount - 10 - } - } - LaunchedEffect(nearEnd, games.size) { if (nearEnd && games.isNotEmpty()) loadMore(reset = false) } - Column { - OutlinedTextField(value = query, onValueChange = { query = it }, singleLine = true, - label = { Text(if (total > 0) "Search $total games" else "Search games") }, - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) - error?.let { - Text(it, color = MaterialTheme.colorScheme.error, fontSize = 12.sp, modifier = Modifier.padding(horizontal = 16.dp)) - } - if (games.isEmpty() && loading) Message("Loading the GeForce NOW store…") - else if (games.isEmpty() && !loading && error == null) Message("No games found.") - LazyVerticalGrid(state = grid, columns = GridCells.Fixed(5), modifier = Modifier.fillMaxSize(), - horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp), - contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 16.dp, vertical = 8.dp)) { - items(games, key = { it.id }) { game -> GameCard(game, onPlay) } - } - } - } - - private fun play(game: Game) { - startActivity(Intent(this, GfnPlayerActivity::class.java) - .putExtra(GfnPlayerActivity.EXTRA_APP_ID, game.id) - .putExtra(GfnPlayerActivity.EXTRA_VARIANT_ID, game.variantId) - .putExtra(GfnPlayerActivity.EXTRA_TITLE, game.title)) - } - - private fun loadSignedIn(): Screen = runCatching { - val auth = GfnCore.request(this, "auth.session.get") - val user = auth.optJSONObject("session")?.optJSONObject("user") ?: auth.optJSONObject("user") - if (user == null || user.optString("userId").isEmpty()) return Screen.SignedOut() - Screen.Games(user.optString("displayName").ifEmpty { "Signed in" }) - }.getOrElse { Screen.Failed(it.message ?: "GeForce NOW could not start.") } - - /** - * NVIDIA's signed-in store catalog (its game and variant ids are the ones launching needs). - * Browsing reads the core's local copy, which grows a page at a time; a search asks NVIDIA. - */ - private fun storePage(cursor: String, query: String): Page { - val params = JSONObject().put("limit", 40).put("cursor", cursor).put("searchQuery", query) - if (query.isEmpty()) params.put("genre", "").put("store", "").put("categoryId", "").put("refresh", false) - val result = GfnCore.request(this, if (query.isEmpty()) "catalog.store.local" else "catalog.store.list", params, timeoutMs = 60_000) - val games = gamesOf(result) - return Page(games, result.optString("nextCursor"), result.optBoolean("hasNextPage") && result.optString("nextCursor").isNotEmpty(), - result.optInt("totalCount", games.size)) - } - - private fun gamesOf(result: JSONObject): List { - val list = result.optJSONArray("games") ?: return emptyList() - return (0 until list.length()).mapNotNull { index -> - val game = list.optJSONObject(index) ?: return@mapNotNull null - val variants = game.optJSONArray("variants") - val variant = variants?.optJSONObject(game.optInt("selectedVariantIndex", 0).coerceIn(0, (variants.length() - 1).coerceAtLeast(0))) - Game(id = game.optString("id"), - variantId = variant?.optString("id").orEmpty().ifEmpty { game.optString("launchAppId") }, - title = game.optString("title"), - store = storeLabel(variant?.optString("store").orEmpty()), - imageUrl = game.optString("imageUrl"), - inLibrary = game.optBoolean("isInLibrary") || variant?.optBoolean("inLibrary") == true, - search = game.optString("searchText").ifEmpty { game.optString("title").lowercase() }) - }.filter { it.id.isNotEmpty() && it.variantId.isNotEmpty() } - } - - private fun storeLabel(store: String): String = when (store.uppercase()) { - "STEAM" -> "Steam" - "EPIC" -> "Epic Games" - "XBOX" -> "Xbox" - "BATTLENET" -> "Battle.net" - "UPLAY", "UBISOFT" -> "Ubisoft" - "GOG" -> "GOG" - "EA_APP", "EA", "ORIGIN" -> "EA app" - "GAIJIN" -> "Gaijin" - else -> store.lowercase().replaceFirstChar { it.uppercase() } - } - - private fun startSignIn(): Screen { - val challenge = GfnCore.request(this, "auth.device.start") - return Screen.Code( - userCode = challenge.optString("userCode"), - url = challenge.optString("verificationUriComplete").ifEmpty { challenge.optString("verificationUri") }, - attemptId = challenge.optString("attemptId"), - intervalMs = (challenge.optDouble("intervalSeconds", 5.0) * 1000).toLong().coerceAtLeast(1000)) - } - - private suspend fun waitForSignIn(code: Screen.Code): Screen { - var interval = code.intervalMs - while (true) { - delay(interval) - val poll = runCatching { GfnCore.request(this, "auth.device.poll", JSONObject().put("attemptId", code.attemptId)) } - .getOrElse { return Screen.SignedOut(it.message) } - when (poll.optString("status")) { - "authorized" -> { - runCatching { - GfnCore.request(this, "auth.device.complete", - JSONObject().put("attemptId", code.attemptId).put("staySignedIn", true)) - }.onFailure { return Screen.SignedOut(it.message) } - return loadSignedIn() - } - "pending", "slow_down" -> interval = poll.optLong("retryAfterMs", - (poll.optDouble("intervalSeconds", interval / 1000.0) * 1000).toLong()).coerceAtLeast(1000) - else -> return Screen.SignedOut(poll.optString("message").ifEmpty { "Sign-in did not finish. Try again." }) - } - } - } -} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/gfn/GfnCore.kt b/app/src/main/java/com/droiddeck/launcher/stores/gfn/GfnCore.kt index 185e6eaaf..331400329 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/gfn/GfnCore.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/gfn/GfnCore.kt @@ -30,6 +30,13 @@ object GfnCore { private val nextId = AtomicLong(1) private val listeners = java.util.concurrent.CopyOnWriteArrayList<(String, JSONObject) -> Unit>() + /** The core runs in the :gfn process; other processes reach it through [GfnCoreService]. */ + private val inCoreProcess: Boolean by lazy { + val name = if (android.os.Build.VERSION.SDK_INT >= 28) android.app.Application.getProcessName() + else runCatching { File("/proc/self/cmdline").readText().trimEnd('\u0000') }.getOrDefault("") + name.endsWith(":gfn") + } + fun addListener(listener: (String, JSONObject) -> Unit) { listeners += listener } fun removeListener(listener: (String, JSONObject) -> Unit) { listeners -= listener } @@ -63,6 +70,7 @@ object GfnCore { /** One request, answered with its `result` or thrown as [GfnError]. Blocking: call off the main thread. */ fun request(context: Context, method: String, params: JSONObject = JSONObject(), timeoutMs: Long = 30_000): JSONObject { + if (!inCoreProcess) return GfnRemote.request(context, method, params, timeoutMs) if (method != "core.hello") ensureStarted(context) val id = "dd-" + nextId.getAndIncrement() val answer = CompletableFuture() diff --git a/app/src/main/java/com/droiddeck/launcher/stores/gfn/GfnCoreService.kt b/app/src/main/java/com/droiddeck/launcher/stores/gfn/GfnCoreService.kt new file mode 100644 index 000000000..33be9be6b --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/gfn/GfnCoreService.kt @@ -0,0 +1,100 @@ +package com.droiddeck.launcher.stores.gfn + +import android.app.Service +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.os.Binder +import android.os.IBinder +import android.os.Parcel +import android.os.Process +import android.os.RemoteException +import org.json.JSONObject + +/** + * Hosts the one OpenNOW core in the :gfn process (the core locks its data directory, so a second + * copy cannot run) and answers core requests from the Stores page in the main process. + */ +class GfnCoreService : Service() { + private val binder = object : Binder() { + override fun onTransact(code: Int, data: Parcel, reply: Parcel?, flags: Int): Boolean { + if (code != REQUEST) return super.onTransact(code, data, reply, flags) + if (getCallingUid() != Process.myUid() || reply == null) return false + val method = data.readString().orEmpty() + val params = runCatching { JSONObject(data.readString() ?: "{}") }.getOrDefault(JSONObject()) + val timeoutMs = data.readLong() + try { + val result = GfnCore.request(applicationContext, method, params, timeoutMs) + reply.writeInt(1) + reply.writeString(result.toString()) + } catch (e: Exception) { + reply.writeInt(0) + reply.writeString(JSONObject().put("code", (e as? GfnError)?.code ?: "error") + .put("message", e.message ?: "GeForce NOW request failed ($method)").toString()) + } + return true + } + } + + override fun onBind(intent: Intent?): IBinder = binder + + companion object { + const val REQUEST = IBinder.FIRST_CALL_TRANSACTION + } +} + +/** The main process's side of [GfnCoreService]. Blocking: call off the main thread. */ +internal object GfnRemote { + private val lock = Object() + @Volatile private var binder: IBinder? = null + private var bound = false + + private val connection = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName?, service: IBinder?) { + synchronized(lock) { binder = service; lock.notifyAll() } + } + override fun onServiceDisconnected(name: ComponentName?) { binder = null } + override fun onBindingDied(name: ComponentName?) { synchronized(lock) { binder = null; bound = false } } + } + + fun request(context: Context, method: String, params: JSONObject, timeoutMs: Long): JSONObject { + val service = connect(context) + val data = Parcel.obtain() + val reply = Parcel.obtain() + try { + data.writeString(method) + data.writeString(params.toString()) + data.writeLong(timeoutMs) + if (!service.transact(GfnCoreService.REQUEST, data, reply, 0)) + throw GfnError("core_stopped", "The GeForce NOW service refused the request") + val ok = reply.readInt() == 1 + val body = reply.readString() ?: "{}" + if (ok) return JSONObject(body) + val error = JSONObject(body) + throw GfnError(error.optString("code").ifEmpty { "error" }, error.optString("message")) + } catch (e: RemoteException) { + binder = null + throw GfnError("core_stopped", "The GeForce NOW service stopped") + } finally { + data.recycle() + reply.recycle() + } + } + + private fun connect(context: Context): IBinder { + synchronized(lock) { + binder?.takeIf { it.isBinderAlive }?.let { return it } + val app = context.applicationContext + if (!bound) bound = app.bindService(Intent(app, GfnCoreService::class.java), connection, Context.BIND_AUTO_CREATE) + if (!bound) throw GfnError("core_stopped", "The GeForce NOW service could not start") + val deadline = System.currentTimeMillis() + 15_000 + while (binder?.isBinderAlive != true) { + val left = deadline - System.currentTimeMillis() + if (left <= 0) throw GfnError("timeout", "The GeForce NOW service did not start") + lock.wait(left) + } + return binder!! + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/gfn/GfnState.kt b/app/src/main/java/com/droiddeck/launcher/stores/gfn/GfnState.kt new file mode 100644 index 000000000..5ba75953b --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/gfn/GfnState.kt @@ -0,0 +1,367 @@ +package com.droiddeck.launcher.stores.gfn + +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.json.JSONObject + +data class GfnGame(val id: String, val variantId: String, val title: String, val store: String, + val imageUrl: String, val inLibrary: Boolean, val search: String) + +data class GfnStoreAccount(val provider: String, val label: String, val displayName: String, val connected: Boolean, + val status: String, val syncState: String, val canLink: Boolean, val canSync: Boolean) + +/** + * The GeForce NOW tab on the Stores page: NVIDIA sign-in, the store catalog, the account's library + * and its connected stores, all through OpenNOW's core. Kept here so it survives switching tabs. + */ +object GfnState { + sealed interface Phase { + data object Loading : Phase + data class SignedOut(val error: String? = null) : Phase + data class Code(val userCode: String, val url: String, val attemptId: String, val intervalMs: Long) : Phase + data class SignedIn(val user: String) : Phase + data class Failed(val message: String) : Phase + } + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + + var phase by mutableStateOf(Phase.Loading) + private set + private var started = false + private var signInJob: Job? = null + + var storeGames by mutableStateOf(emptyList()) + private set + var storeTotal by mutableStateOf(0) + private set + var storeLoading by mutableStateOf(false) + private set + var storeError by mutableStateOf(null) + private set + private var storeQuery = "" + private var storeNext = "" + private var storeMore = true + private var storeGeneration = 0 + private var storeJob: Job? = null + + var library by mutableStateOf?>(null) + private set + var libraryError by mutableStateOf(null) + private set + private var libraryJob: Job? = null + + var accounts by mutableStateOf?>(null) + private set + var accountsMessage by mutableStateOf(null) + private set + var accountsBusy by mutableStateOf(false) + private set + + /** Loads the sign-in state once; [force] checks again (Try again). */ + fun start(context: Context, force: Boolean = false) { + if (started && !force) return + started = true + val app = context.applicationContext + phase = Phase.Loading + scope.launch { phase = withContext(Dispatchers.IO) { loadSignedIn(app) } } + } + + fun signIn(context: Context) { + val app = context.applicationContext + signInJob?.cancel() + signInJob = scope.launch { + val code = withContext(Dispatchers.IO) { runCatching { startSignIn(app) } } + .getOrElse { phase = Phase.SignedOut(it.message); return@launch } + phase = code + phase = waitForSignIn(app, code) + } + } + + fun cancelSignIn() { + signInJob?.cancel() + phase = Phase.SignedOut() + } + + fun signOut(context: Context) { + val app = context.applicationContext + signInJob?.cancel() + scope.launch { + withContext(Dispatchers.IO) { runCatching { GfnCore.request(app, "auth.logout") } } + clearAccountData() + phase = Phase.SignedOut() + } + } + + private fun clearAccountData() { + storeJob?.cancel(); libraryJob?.cancel() + storeGames = emptyList(); storeTotal = 0; storeNext = ""; storeMore = true; storeError = null; storeLoading = false + library = null; libraryError = null + accounts = null; accountsMessage = null + } + + /** Shows the store for [query]: loads it when the query changed or nothing is loaded yet. */ + fun showStore(context: Context, query: String) { + if (query.trim() != storeQuery || (storeGames.isEmpty() && !storeLoading && storeError == null)) loadStore(context, query, reset = true) + } + + /** NVIDIA's signed-in store catalog, a page at a time; a new [query] starts over. */ + fun loadStore(context: Context, query: String, reset: Boolean) { + val text = query.trim() + if (!reset && (storeLoading || !storeMore || text != storeQuery)) return + val app = context.applicationContext + storeGeneration += 1 + val asked = storeGeneration + storeQuery = text + if (reset) { storeNext = ""; storeMore = true } + val cursor = storeNext + storeLoading = true + storeJob?.cancel() + storeJob = scope.launch { + if (reset && text.isNotEmpty()) delay(350) + val page = withContext(Dispatchers.IO) { runCatching { storePage(app, cursor, text) } } + if (asked != storeGeneration) return@launch + storeLoading = false + page.onSuccess { + storeError = null + val seen = if (reset) HashSet() else storeGames.map { game -> game.id }.toHashSet() + storeGames = (if (reset) emptyList() else storeGames) + it.games.filter { game -> seen.add(game.id) } + storeNext = it.next + storeMore = it.more + storeTotal = maxOf(it.total, storeGames.size) + }.onFailure { storeError = it.message ?: "The GeForce NOW store did not answer." } + } + } + + fun loadLibrary(context: Context, force: Boolean = false) { + if (!force && (library != null || libraryJob?.isActive == true)) return + val app = context.applicationContext + if (force) library = null + libraryError = null + libraryJob?.cancel() + libraryJob = scope.launch { + withContext(Dispatchers.IO) { runCatching { libraryGames(app) } } + .onSuccess { library = it.sortedBy { game -> game.title.lowercase() } } + .onFailure { libraryError = it.message ?: "Your GeForce NOW library could not be loaded." } + } + } + + fun loadAccounts(context: Context) { + val app = context.applicationContext + scope.launch { + withContext(Dispatchers.IO) { runCatching { storeAccounts(app) } } + .onSuccess { accounts = it } + .onFailure { accountsMessage = it.message ?: "Connected stores could not be loaded." } + } + } + + fun link(context: Context, account: GfnStoreAccount) = perform(context) { app -> linkStore(app, account) } + fun sync(context: Context, account: GfnStoreAccount) = perform(context) { app -> syncStore(app, account) } + fun unlink(context: Context, account: GfnStoreAccount) = perform(context) { app -> + withContext(Dispatchers.IO) { + GfnCore.request(app, "account.connections.unlink", JSONObject().put("provider", account.provider)) + .optString("message").ifEmpty { "${account.label} disconnected." } + } + } + + private fun perform(context: Context, action: suspend (Context) -> String) { + if (accountsBusy) return + val app = context.applicationContext + accountsBusy = true + scope.launch { + accountsMessage = runCatching { action(app) }.getOrElse { it.message ?: "That did not work. Try again." } + accountsBusy = false + loadAccounts(app) + loadLibrary(app, force = true) + } + } + + fun play(context: Context, game: GfnGame) { + context.startActivity(Intent(context, GfnPlayerActivity::class.java) + .apply { if (context !is android.app.Activity) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } + .putExtra(GfnPlayerActivity.EXTRA_APP_ID, game.id) + .putExtra(GfnPlayerActivity.EXTRA_VARIANT_ID, game.variantId) + .putExtra(GfnPlayerActivity.EXTRA_TITLE, game.title)) + } + + /** OpenNOW's own rule for which action a connected-store row offers. */ + fun actionFor(account: GfnStoreAccount): String = when { + account.canLink && (!account.connected || account.status == "expired" || + (account.syncState == "SYNC_DENIED" && account.provider != "STEAM")) -> "link" + account.canSync -> "sync" + account.connected && account.canLink -> "unlink" + else -> "none" + } + + private fun loadSignedIn(context: Context): Phase = runCatching { + val auth = GfnCore.request(context, "auth.session.get") + val user = auth.optJSONObject("session")?.optJSONObject("user") ?: auth.optJSONObject("user") + if (user == null || user.optString("userId").isEmpty()) return Phase.SignedOut() + Phase.SignedIn(user.optString("displayName").ifEmpty { "Signed in" }) + }.getOrElse { Phase.Failed(it.message ?: "GeForce NOW could not start.") } + + private fun startSignIn(context: Context): Phase.Code { + val challenge = GfnCore.request(context, "auth.device.start") + return Phase.Code( + userCode = challenge.optString("userCode"), + url = challenge.optString("verificationUriComplete").ifEmpty { challenge.optString("verificationUri") }, + attemptId = challenge.optString("attemptId"), + intervalMs = (challenge.optDouble("intervalSeconds", 5.0) * 1000).toLong().coerceAtLeast(1000)) + } + + private suspend fun waitForSignIn(context: Context, code: Phase.Code): Phase { + var interval = code.intervalMs + while (true) { + delay(interval) + val poll = withContext(Dispatchers.IO) { + runCatching { GfnCore.request(context, "auth.device.poll", JSONObject().put("attemptId", code.attemptId)) } + }.getOrElse { return Phase.SignedOut(it.message) } + when (poll.optString("status")) { + "authorized" -> { + withContext(Dispatchers.IO) { + runCatching { + GfnCore.request(context, "auth.device.complete", + JSONObject().put("attemptId", code.attemptId).put("staySignedIn", true)) + } + }.onFailure { return Phase.SignedOut(it.message) } + clearAccountData() + return withContext(Dispatchers.IO) { loadSignedIn(context) } + } + "pending", "slow_down" -> interval = poll.optLong("retryAfterMs", + (poll.optDouble("intervalSeconds", interval / 1000.0) * 1000).toLong()).coerceAtLeast(1000) + else -> return Phase.SignedOut(poll.optString("message").ifEmpty { "Sign-in did not finish. Try again." }) + } + } + } + + private class Page(val games: List, val next: String, val more: Boolean, val total: Int) + + /** Browsing reads the core's local copy of the catalog, which grows a page at a time; a search asks NVIDIA. */ + private fun storePage(context: Context, cursor: String, query: String): Page { + val params = JSONObject().put("limit", 40).put("cursor", cursor).put("searchQuery", query) + if (query.isEmpty()) params.put("genre", "").put("store", "").put("categoryId", "").put("refresh", false) + val result = GfnCore.request(context, if (query.isEmpty()) "catalog.store.local" else "catalog.store.list", params, timeoutMs = 60_000) + val games = gamesOf(result) + return Page(games, result.optString("nextCursor"), result.optBoolean("hasNextPage") && result.optString("nextCursor").isNotEmpty(), + result.optInt("totalCount", games.size)) + } + + /** Pages of 50 keep each answer well under the binder transaction limit. */ + private fun libraryGames(context: Context): List { + val games = LinkedHashMap() + val traversal = System.currentTimeMillis().toString() + var cursor = "" + var revision: Any? = null + var catalogContext = "" + repeat(100) { + val params = JSONObject().put("limit", 50).put("cursor", cursor).put("traversalId", traversal) + .put("catalogRevision", revision ?: JSONObject.NULL).put("catalogContext", catalogContext) + val page = GfnCore.request(context, "catalog.library.list", params, timeoutMs = 60_000) + gamesOf(page).forEach { games.putIfAbsent(it.id, it.copy(inLibrary = true)) } + revision = page.opt("catalogRevision") + catalogContext = page.optString("catalogContext") + cursor = page.optString("nextCursor") + if (!page.optBoolean("hasNextPage") || cursor.isEmpty()) return games.values.toList() + } + return games.values.toList() + } + + private fun gamesOf(result: JSONObject): List { + val list = result.optJSONArray("games") ?: return emptyList() + return (0 until list.length()).mapNotNull { index -> + val game = list.optJSONObject(index) ?: return@mapNotNull null + val variants = game.optJSONArray("variants") + val variant = variants?.optJSONObject(game.optInt("selectedVariantIndex", 0).coerceIn(0, (variants.length() - 1).coerceAtLeast(0))) + GfnGame(id = game.optString("id"), + variantId = variant?.optString("id").orEmpty().ifEmpty { game.optString("launchAppId") }, + title = game.optString("title"), + store = storeLabel(variant?.optString("store").orEmpty()), + imageUrl = game.optString("imageUrl"), + inLibrary = game.optBoolean("isInLibrary") || variant?.optBoolean("inLibrary") == true, + search = game.optString("searchText").ifEmpty { game.optString("title").lowercase() }) + }.filter { it.id.isNotEmpty() && it.variantId.isNotEmpty() } + } + + private fun storeLabel(store: String): String = when (store.uppercase()) { + "STEAM" -> "Steam" + "EPIC" -> "Epic" + "XBOX" -> "Xbox" + "BATTLENET" -> "Battle.net" + "UPLAY", "UBISOFT" -> "Ubisoft" + "GOG" -> "GOG" + "EA_APP", "EA", "ORIGIN" -> "EA" + "GAIJIN" -> "Gaijin" + else -> store.lowercase().replaceFirstChar { it.uppercase() } + } + + private fun storeAccounts(context: Context): List { + val list = GfnCore.request(context, "account.connections.list").optJSONArray("accounts") ?: return emptyList() + return (0 until list.length()).mapNotNull { index -> + val account = list.optJSONObject(index) ?: return@mapNotNull null + if (account.optString("provider") in setOf("", "UNKNOWN", "NONE", "NVIDIA")) return@mapNotNull null + GfnStoreAccount(provider = account.optString("provider"), label = account.optString("label").ifEmpty { account.optString("provider") }, + displayName = account.optString("displayName"), + connected = account.optBoolean("isConnected") || account.optString("status") == "connected", + status = account.optString("status"), syncState = account.optString("syncState"), + canLink = account.optBoolean("supportsLinking"), canSync = account.optBoolean("supportsSync")) + }.filter { it.canLink || it.canSync || it.connected } + } + + private suspend fun linkStore(context: Context, account: GfnStoreAccount): String { + val attempt = withContext(Dispatchers.IO) { + GfnCore.request(context, "account.connections.link.start", JSONObject().put("provider", account.provider)) + } + val url = attempt.optString("loginUrl") + if (url.isNotEmpty()) runCatching { + context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + } + repeat(150) { + delay(2_000) + val poll = withContext(Dispatchers.IO) { + GfnCore.request(context, "account.connections.link.poll", + JSONObject().put("attemptId", attempt.optString("attemptId")), timeoutMs = 10_000) + } + when (poll.optString("status")) { + "pending" -> {} + "complete" -> return "${account.label} connected." + else -> return poll.optString("message").ifEmpty { "${account.label} was not connected." } + } + } + return "Stopped waiting for ${account.label}. Finish signing in, then refresh." + } + + private suspend fun syncStore(context: Context, account: GfnStoreAccount): String = withContext(Dispatchers.IO) { + var state = GfnCore.request(context, "account.connections.sync", JSONObject().put("provider", account.provider)) + val operation = state.optString("operationId") + if (operation.isEmpty()) return@withContext state.optString("message").ifEmpty { "${account.label} synced." } + repeat(90) { + when (state.optString("phase")) { + "waiting_remote", "starting" -> { + delay(state.optLong("retryAfterMs", 2_000).coerceAtLeast(2_000)) + state = GfnCore.request(context, "account.connections.sync.status", JSONObject().put("operationId", operation)) + } + "refreshing_library" -> { + runCatching { libraryGames(context) } + runCatching { + GfnCore.request(context, "account.connections.sync.status", + JSONObject().put("operationId", operation).put("libraryRefreshed", true), timeoutMs = 10_000) + } + return@withContext "${account.label} synced. Your library is up to date." + } + "timed_out" -> return@withContext "The sync could not be confirmed. ${account.label} may still finish; refresh later." + else -> return@withContext "${account.label} could not finish the sync. Try again." + } + } + "Still waiting for ${account.label}; refresh later." + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/GfnPane.kt b/app/src/main/java/com/droiddeck/launcher/ui/GfnPane.kt new file mode 100644 index 000000000..1207d0b0d --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/GfnPane.kt @@ -0,0 +1,375 @@ +package com.droiddeck.launcher.ui + +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.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusGroup +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.Spacer +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.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Login +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +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.draw.drawBehind +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.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +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.dp +import androidx.compose.ui.unit.sp +import com.droiddeck.launcher.R +import com.droiddeck.launcher.stores.gfn.GfnGame +import com.droiddeck.launcher.stores.gfn.GfnState +import com.droiddeck.launcher.stores.gfn.GfnStoreAccount + +/** The GeForce NOW tab's chip key; it sits after the store tabs and is not a [com.droiddeck.launcher.stores.Store]. */ +internal const val GEFORCE_NOW = "geforce-now" + +internal val GfnGreen = Color(0xFF76B900) +internal val GfnColours = SourceColours(Color(0xFF1C2A0A), Color(0xFFCDEB8B), GfnGreen) + +internal fun gfnPalette(base: Palette): Palette = Palette( + base.id, base.label, base.detail, base.background, base.surface, base.surfaceVariant, base.line, base.line2, + base.onBackground, base.onSurfaceVariant, primary = GfnGreen, primary2 = Color(0xFF5A8F00), onPrimary = Color(0xFF0B0B0D), signal = GfnGreen, + good = base.good, error = base.error, +) + +@Composable +internal fun GfnTheme(content: @Composable () -> Unit) { + val base = LocalPalette.current + val p = remember(base) { gfnPalette(base) } + val scheme = MaterialTheme.colorScheme.copy(primary = p.primary, onPrimary = p.onPrimary) + CompositionLocalProvider(LocalPalette provides p) { MaterialTheme(colorScheme = scheme, typography = MaterialTheme.typography, content = content) } +} + +@Composable +internal fun GfnPane(height: Dp) { + val ctx = LocalContext.current + LaunchedEffect(Unit) { GfnState.start(ctx) } + when (val phase = GfnState.phase) { + GfnState.Phase.Loading -> Box(Modifier.fillMaxWidth().padding(top = 16.dp)) { + androidx.compose.material3.LinearProgressIndicator(Modifier.fillMaxWidth().height(3.dp)) + } + is GfnState.Phase.Failed -> Column(verticalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) { + Note(phase.message) + PrimaryButton(stringResource(R.string.gfn_try_again), main = true) { GfnState.start(ctx, force = true) } + } + is GfnState.Phase.SignedOut, is GfnState.Phase.Code -> Box(Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(top = 4.dp, bottom = 16.dp)) { + GfnSignInCard(phase, height) + } + is GfnState.Phase.SignedIn -> GfnStorefront(phase.user) + } +} + +@Composable +private fun GfnSignInCard(phase: GfnState.Phase, height: Dp) { + val ctx = LocalContext.current + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val narrow = LocalNarrowPane.current + val shape = RoundedCornerShape(20.dp) + Box( + Modifier.fillMaxWidth().heightIn(min = height.coerceAtLeast(0.dp)).clip(shape) + .background(Brush.linearGradient(listOf(GfnColours.fill, colors.background))).border(1.dp, pal.line, shape) + .drawBehind { + drawRect(Brush.radialGradient(listOf(GfnGreen.copy(alpha = 0.28f), Color.Transparent), center = Offset(size.width * 0.16f, size.height * 0.9f), radius = size.height * 0.8f)) + }, + ) { + Column( + verticalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier.align(Alignment.BottomStart).padding(start = if (narrow) 20.dp else 40.dp, bottom = if (narrow) 20.dp else 36.dp, end = 20.dp, top = 20.dp), + ) { + Rise(2) { + Text( + stringResource(R.string.gfn_name), fontSize = if (narrow) 30.sp else 42.sp, lineHeight = if (narrow) 34.sp else 46.sp, + fontWeight = FontWeight.Black, color = colors.onBackground, + ) + } + Text(stringResource(R.string.gfn_pitch), fontSize = 15.sp, color = colors.onBackground) + Text(stringResource(R.string.gfn_unofficial), fontSize = 12.sp, color = colors.onSurfaceVariant) + Spacer(Modifier.height(6.dp)) + when (phase) { + is GfnState.Phase.Code -> { + Text(stringResource(R.string.gfn_code_hint, phase.url), fontSize = 14.sp, color = colors.onBackground) + Text(phase.userCode, fontSize = 34.sp, fontWeight = FontWeight.Black, letterSpacing = 4.sp, color = GfnGreen) + Actions { + PrimaryButton(stringResource(R.string.gfn_open_page), main = true) { openUrl(ctx, phase.url) } + SecondaryButton(stringResource(R.string.common_cancel)) { GfnState.cancelSignIn() } + } + Text(stringResource(R.string.gfn_code_waiting), fontSize = 12.sp, color = colors.onSurfaceVariant) + } + else -> { + (phase as? GfnState.Phase.SignedOut)?.error?.let { Text(it, fontSize = 13.sp, color = colors.error) } + Rise(3) { + PrimaryButton(stringResource(R.string.stores_signin), main = true, large = true, icon = Icons.AutoMirrored.Filled.Login) { GfnState.signIn(ctx) } + } + } + } + } + } +} + +private const val MODE_STORE = 0 +private const val MODE_LIBRARY = 1 +private const val MODE_ACCOUNTS = 2 + +@Composable +private fun GfnStorefront(user: String) { + val ctx = LocalContext.current + var mode by rememberSaveable { mutableIntStateOf(MODE_STORE) } + var query by rememberSaveable { mutableStateOf("") } + val grid = rememberLazyGridState() + LaunchedEffect(mode) { + when (mode) { + MODE_LIBRARY -> GfnState.loadLibrary(ctx) + MODE_ACCOUNTS -> GfnState.loadAccounts(ctx) + } + grid.scrollToItem(0) + } + LaunchedEffect(Unit) { GfnState.loadLibrary(ctx) } + LaunchedEffect(mode, query) { if (mode == MODE_STORE) GfnState.showStore(ctx, query) } + val nearEnd by remember { + derivedStateOf { + val last = grid.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 + last >= grid.layoutInfo.totalItemsCount - 10 + } + } + LaunchedEffect(nearEnd, GfnState.storeGames.size, mode) { + if (mode == MODE_STORE && nearEnd && GfnState.storeGames.isNotEmpty()) GfnState.loadStore(ctx, query, reset = false) + } + val words = query.trim().lowercase().split(' ').filter { it.isNotEmpty() } + val library = GfnState.library + val libraryShown = remember(library, query) { library.orEmpty().filter { game -> words.all { game.search.contains(it) } } } + val full: androidx.compose.foundation.lazy.grid.LazyGridItemSpanScope.() -> GridItemSpan = { GridItemSpan(maxLineSpan) } + LazyVerticalGrid( + columns = GridCells.Adaptive(CardWidth), state = grid, modifier = Modifier.fillMaxSize().focusGroup(), + horizontalArrangement = Arrangement.spacedBy(10.dp), verticalArrangement = Arrangement.spacedBy(10.dp), + contentPadding = PaddingValues(top = 4.dp, bottom = 16.dp), + ) { + item(key = "profile", span = full) { Rise(1) { GfnProfile(user, library?.size, onRefresh = { + when (mode) { + MODE_STORE -> GfnState.loadStore(ctx, query, reset = true) + MODE_LIBRARY -> GfnState.loadLibrary(ctx, force = true) + else -> GfnState.loadAccounts(ctx) + } + }) } } + item(key = "modes", span = full) { + Rise(2) { + val pills: @Composable () -> Unit = { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { + GfnModePill(stringResource(R.string.gfn_mode_store), mode == MODE_STORE, "store") { mode = MODE_STORE; query = "" } + GfnModePill(stringResource(R.string.gfn_mode_library), mode == MODE_LIBRARY, "library") { mode = MODE_LIBRARY; query = "" } + GfnModePill(stringResource(R.string.gfn_mode_accounts), mode == MODE_ACCOUNTS, "accounts") { mode = MODE_ACCOUNTS; query = "" } + } + } + if (mode == MODE_ACCOUNTS) Column(Modifier.fillMaxWidth()) { + Box(Modifier.heightIn(min = 52.dp).padding(vertical = 4.dp), contentAlignment = Alignment.CenterStart) { pills() } + Box(Modifier.fillMaxWidth().height(1.dp).background(LocalPalette.current.line)) + } else ModeHeader( + stringResource(R.string.gfn_name), query, { query = it }, + placeholder = if (mode == MODE_STORE) stringResource(R.string.gfn_search_store, GfnState.storeTotal) + else stringResource(R.string.gfn_search_library, library?.size ?: 0), + lead = pills, + ) {} + } + } + when (mode) { + MODE_STORE -> { + GfnState.storeError?.let { line -> item(key = "error", span = full) { Note(line) } } + val games = GfnState.storeGames + items(games, key = { "gfn:${it.id}" }, contentType = { "game" }) { game -> GfnCard(game, first = game.id == games.first().id, showOwned = true) } + if (GfnState.storeLoading) item(key = "loading", span = full) { + if (games.isEmpty()) Note(stringResource(R.string.gfn_loading_store)) + else androidx.compose.material3.LinearProgressIndicator(Modifier.fillMaxWidth().height(3.dp)) + } else if (games.isEmpty() && GfnState.storeError == null) item(key = "empty", span = full) { GfnEmpty(stringResource(R.string.stores_nothing_matches)) } + } + MODE_LIBRARY -> { + GfnState.libraryError?.let { line -> item(key = "error", span = full) { Note(line) } } + when { + library == null && GfnState.libraryError == null -> item(key = "loading", span = full) { Note(stringResource(R.string.gfn_loading_library)) } + library?.isEmpty() == true -> item(key = "empty", span = full) { Note(stringResource(R.string.gfn_library_empty)) } + libraryShown.isEmpty() && library != null -> item(key = "nothing", span = full) { GfnEmpty(stringResource(R.string.stores_nothing_matches)) } + else -> items(libraryShown, key = { "gfn:${it.id}" }, contentType = { "game" }) { game -> + GfnCard(game, first = game.id == libraryShown.first().id, showOwned = false) + } + } + } + else -> { + item(key = "note", span = full) { + Text(stringResource(R.string.gfn_accounts_note), fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(horizontal = 2.dp)) + } + GfnState.accountsMessage?.let { line -> item(key = "message", span = full) { Note(line) } } + val accounts = GfnState.accounts + if (accounts == null) item(key = "loading", span = full) { androidx.compose.material3.LinearProgressIndicator(Modifier.fillMaxWidth().height(3.dp)) } + else items(accounts, key = { "account:${it.provider}" }, span = { full() }) { account -> GfnAccountRow(account) } + } + } + } +} + +@Composable +private fun GfnEmpty(text: String) { + Box(Modifier.padding(vertical = 32.dp).fillMaxWidth(), contentAlignment = Alignment.Center) { + Text(text, fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) + } +} + +@Composable +private fun GfnProfile(user: String, owned: Int?, onRefresh: () -> Unit) { + val ctx = LocalContext.current + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val shape = RoundedCornerShape(16.dp) + Column( + verticalArrangement = Arrangement.spacedBy(6.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), + ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) { + Box(contentAlignment = Alignment.Center, modifier = Modifier.size(36.dp).clip(CircleShape).background(GfnGreen)) { + Text(user.take(1).uppercase().ifEmpty { "G" }, fontSize = 16.sp, fontWeight = FontWeight.Black, color = Color.Black) + } + Column(Modifier.weight(1f)) { + Text(user, fontSize = 15.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text( + if (owned != null) stringResource(R.string.gfn_profile_line, owned) else stringResource(R.string.gfn_name), + fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, + ) + } + Actions { + SecondaryButton(stringResource(R.string.stores_refresh), compact = true) { onRefresh() } + ConfirmButton(stringResource(R.string.stores_signout_go), stringResource(R.string.gfn_signout_confirm), compact = true) { GfnState.signOut(ctx) } + } + } + Text(stringResource(R.string.gfn_unofficial), fontSize = 11.sp, color = colors.onSurfaceVariant, maxLines = 2) + } +} + +@Composable +private fun GfnModePill(label: String, on: Boolean, key: String, onClick: () -> Unit) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val src = remember { MutableInteractionSource() } + val hot = rememberHot(src) + val shape = RoundedCornerShape(99.dp) + Text( + label, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, maxLines = 1, + color = if (on) colors.onPrimary else if (hot) colors.onBackground else colors.onSurfaceVariant, + modifier = Modifier.paneItem("gfnmode:$key").clip(shape) + .background(if (on) pal.signal else Color.Transparent) + .glideBorder(hot, shape, pal.signal, if (on) Color.Transparent else pal.line) + .hoverable(src).clickable(role = Role.Tab, interactionSource = src, indication = LocalIndication.current, onClick = onClick) + .controllerConfirm(onClick = onClick) + .padding(horizontal = 14.dp, vertical = 8.dp), + ) +} + +@Composable +private fun GfnCard(game: GfnGame, first: Boolean, showOwned: Boolean) { + val ctx = LocalContext.current + 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 = "gfnCardScale") + val play = { GfnState.play(ctx, game) } + Column( + Modifier.fillMaxWidth().paneItem("card:gfn:${game.id}").then(if (first) Modifier.firstTile() 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 = play) + .controllerConfirm(onClick = play), + ) { + CardFace(game.title, game.imageUrl.ifEmpty { null }, null, null, hot) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(3.dp)) { + if (showOwned && game.inLibrary) Icon(Icons.Filled.Check, null, tint = GfnGreen, modifier = Modifier.size(11.dp)) + Text(game.store, fontSize = 9.sp, fontWeight = FontWeight.SemiBold, color = Color.White.copy(alpha = 0.75f), maxLines = 1) + } + } + } +} + +@Composable +private fun GfnAccountRow(account: GfnStoreAccount) { + val ctx = LocalContext.current + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val shape = RoundedCornerShape(14.dp) + val busy = GfnState.accountsBusy + Row( + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxWidth().clip(shape).background(colors.surface).border(1.dp, pal.line, shape).padding(horizontal = 14.dp, vertical = 10.dp), + ) { + Box(contentAlignment = Alignment.Center, modifier = Modifier.size(36.dp).clip(Shape12).background(if (account.connected) GfnColours.fill else colors.surfaceVariant)) { + Text(account.label.take(1).uppercase(), fontSize = 15.sp, fontWeight = FontWeight.Black, color = if (account.connected) GfnColours.ink else colors.onSurfaceVariant) + } + Column(Modifier.weight(1f)) { + Text(account.label, fontSize = 15.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text( + when { + account.status == "expired" -> stringResource(R.string.gfn_status_expired) + account.status == "sync_error" -> stringResource(R.string.gfn_status_sync_error) + account.connected && account.displayName.isNotEmpty() -> stringResource(R.string.gfn_status_connected_as, account.displayName) + account.connected -> stringResource(R.string.gfn_status_connected) + else -> stringResource(R.string.gfn_status_not_connected) + }, + fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, + ) + } + Box { + when (GfnState.actionFor(account)) { + "link" -> PrimaryButton(stringResource(if (account.connected) R.string.gfn_reconnect else R.string.gfn_connect), enabled = !busy, compact = true) { GfnState.link(ctx, account) } + "sync" -> SecondaryButton(stringResource(R.string.gfn_sync), enabled = !busy, compact = true) { GfnState.sync(ctx, account) } + "unlink" -> ConfirmButton(stringResource(R.string.gfn_disconnect), stringResource(R.string.gfn_disconnect_confirm), compact = true) { GfnState.unlink(ctx, account) } + else -> if (account.provider == "STEAM" && !account.connected) + Text(stringResource(R.string.gfn_steam_link), fontSize = 12.sp, color = colors.onSurfaceVariant) + } + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt index 116387bcd..e7f319070 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt @@ -93,7 +93,6 @@ import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.automirrored.filled.Login import androidx.compose.foundation.basicMarquee import androidx.compose.material.icons.filled.Settings -import androidx.compose.material.icons.outlined.Cloud import androidx.compose.material.icons.outlined.Download import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -198,7 +197,7 @@ internal fun StoresPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier LaunchedEffect(StoresState.openDownloads) { if (StoresState.openDownloads) { chip = DOWNLOADS; StoresState.openDownloads = false } } val store = Store.byId(chip) LaunchedEffect(tabs.toList()) { - if (chip != DOWNLOADS && Store.byId(chip) !in shownStores) chip = shownStores.firstOrNull()?.id ?: DOWNLOADS + if (chip != DOWNLOADS && chip != GEFORCE_NOW && Store.byId(chip) !in shownStores) chip = shownStores.firstOrNull()?.id ?: DOWNLOADS } val pane = PaneKey( chip, @@ -251,8 +250,7 @@ internal fun StoresPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier } val pal = LocalPalette.current fun cycleStore(step: Int) { - val ids = tabs.filter { it.shown }.map { it.store.id } - if (ids.isEmpty()) return + val ids = tabs.filter { it.shown }.map { it.store.id } + GEFORCE_NOW val at = ids.indexOf(chip) pickChip(ids[if (at < 0) (if (step > 0) 0 else ids.size - 1) else (at + step + ids.size) % ids.size]) } @@ -325,11 +323,12 @@ internal fun StoresPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier )), ) { Box(Modifier.fillMaxWidth().padding(bottom = 8.dp)) { - StoreTheme(Store.byId(focusedChip ?: chip)) { + StoreTheme(Store.byId(focusedChip ?: chip), gfn = (focusedChip ?: chip) == GEFORCE_NOW) { Rise(0) { StoreChips( chip, focusedChip, s.storeDownloadsActive, tabs, arranging, onStore = { st -> openModes(st) }, + onGfn = { if (chip != GEFORCE_NOW) pickChip(GEFORCE_NOW) else move("first") }, onDownloads = { if (chip != DOWNLOADS) pickChip(DOWNLOADS) else move("first") }, onFocusChip = { focusedChip = it }, onArrangeDone = { arranging = false }, @@ -365,13 +364,14 @@ internal fun StoresPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier CompositionLocalProvider(LocalFrontFocus provides if (outgoing) null else LocalFrontFocus.current) { val leavingNow = androidx.compose.runtime.rememberUpdatedState(outgoing) val st = Store.byId(k.chip) - StoreTheme(st) { + StoreTheme(st, gfn = k.chip == GEFORCE_NOW) { 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 == GEFORCE_NOW -> GfnPane(viewportH - 20.dp) 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)) { @@ -589,17 +589,17 @@ private fun RowScope.ArrangeStoreChip( @Composable private fun StoreChips( selected: String, focused: String?, active: Int, tabs: List, arranging: Boolean, - onStore: (Store) -> Unit, onDownloads: () -> Unit, onSettings: (Rect?) -> Unit, onFocusChip: (String?) -> Unit, + onStore: (Store) -> Unit, onGfn: () -> Unit, onDownloads: () -> Unit, onSettings: (Rect?) -> Unit, onFocusChip: (String?) -> Unit, onArrangeDone: () -> Unit, onMoveTab: (Store, Int) -> Unit, onToggleTab: (Store) -> Unit, ) { val pal = LocalPalette.current - val keys = tabs.filter { it.shown }.map { it.store.id } + DOWNLOADS + val keys = tabs.filter { it.shown }.map { it.store.id } + GEFORCE_NOW + DOWNLOADS val bounds = remember { androidx.compose.runtime.mutableStateMapOf() } val highlighted = focused ?: selected val inputMode = androidx.compose.ui.platform.LocalInputModeManager.current.inputMode val fillShown = inputMode != androidx.compose.ui.input.InputMode.Keyboard && !arranging val tint = androidx.compose.animation.animateColorAsState( - Store.byId(highlighted)?.let { sourceColours(it.id).dot } ?: pal.signal, Motion.tw(160), label = "chipTint", + Store.byId(highlighted)?.let { sourceColours(it.id).dot } ?: if (highlighted == GEFORCE_NOW) GfnGreen else pal.signal, Motion.tw(160), label = "chipTint", ) val gap = 8.dp val gapPx = with(androidx.compose.ui.platform.LocalDensity.current) { gap.toPx() } @@ -631,6 +631,17 @@ private fun StoreChips( } } } + if (!arranging) key(GEFORCE_NOW) { + val reveal = remember { BringIntoViewRequester() } + LaunchedEffect(selected) { if (selected == GEFORCE_NOW) reveal.bringIntoView() } + val signedIn = com.droiddeck.launcher.stores.gfn.GfnState.phase is com.droiddeck.launcher.stores.gfn.GfnState.Phase.SignedIn + StoreChip( + label = stringResource(R.string.gfn_name), on = selected == GEFORCE_NOW, under = fillShown && highlighted == GEFORCE_NOW, key = GEFORCE_NOW, mark = GfnGreen, + modifier = Modifier.bringIntoViewRequester(reveal).onGloballyPositioned { bounds[GEFORCE_NOW] = it.boundsInParent() }, + lead = { Box(Modifier.size(12.dp).background(GfnGreen.copy(alpha = if (signedIn) 1f else 0.35f), CircleShape)) }, + description = stringResource(R.string.gfn_name), onFocused = onFocusChip, + ) { onGfn() } + } } ChipGlide(keys.indexOf(highlighted), bounds[highlighted], tint, visible = fillShown && highlighted != DOWNLOADS) } @@ -651,12 +662,6 @@ private fun StoreChips( ) { onDownloads() } ChipGlide(keys.indexOf(DOWNLOADS), bounds[DOWNLOADS], tint, visible = fillShown && highlighted == DOWNLOADS) } - val gfnContext = LocalContext.current - StoreChip( - label = "GeForce NOW", on = false, under = false, key = "geforce-now", mark = pal.signal, modifier = Modifier, - lead = { Icon(Icons.Outlined.Cloud, null, modifier = Modifier.size(18.dp)) }, - description = "GeForce NOW", onFocused = onFocusChip, - ) { gfnContext.startActivity(android.content.Intent(gfnContext, com.droiddeck.launcher.stores.gfn.GfnActivity::class.java)) } var cogAt by remember { mutableStateOf(null) } Box(Modifier.onGloballyPositioned { cogAt = it.boundsInRoot() }) { IconChip(Icons.Filled.Settings, stringResource(R.string.stores_settings)) { onSettings(cogAt) } @@ -1166,7 +1171,8 @@ internal fun FlathubTheme(content: @Composable () -> Unit) { } @Composable -internal fun StoreTheme(store: Store?, content: @Composable () -> Unit) { +internal fun StoreTheme(store: Store?, gfn: Boolean = false, content: @Composable () -> Unit) { + if (gfn) return GfnTheme(content) val base = LocalPalette.current val p = remember(base, store) { storePalette(base, store) } val scheme = MaterialTheme.colorScheme.copy(primary = p.primary, onPrimary = p.onPrimary) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 840e26ebb..01ad78b44 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3226,4 +3226,34 @@ Cloud settings Xbox Cloud Gaming Game Pass + + GeForce NOW + Stream your games from NVIDIA\'s servers. + Unofficial client built on OpenNOW. NVIDIA does not support third-party GeForce NOW apps. + Open the sign-in page + On this device or another one, open %1$s and enter this code: + Waiting for you to sign in… + Try again + Store + My library + Connected stores + Search %1$d games + Search your %1$d games + Loading the GeForce NOW store… + Loading your library… + Your GeForce NOW library is empty. Connect your stores under Connected stores, then sync. + GeForce NOW · %1$d in library + Games in the stores you connect here show up in My library and can be streamed. Amazon and itch.io games are not on GeForce NOW. + Connect + Reconnect + Sync library + Disconnect + Press again to disconnect + Press again to sign out + Link Steam on NVIDIA\'s site + Connected + Connected as %1$s + Connection expired: reconnect + Last sync failed + Not connected