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