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.
This commit is contained in:
The412Banner committed 2026-10-26 18:25:14 +00:00
1 parent 41b065e89c
commit 332c4ea751
8 files changed
+905 -536

No files matched your search

+3 -5
View File
@@ -180,11 +180,9 @@
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|smallestScreenSize|density|navigation|uiMode"
android:theme="@style/AppTheme" />
<!-- GeForce NOW (OpenNOW's core and streamer): both screens share the :gfn process with the core child. -->
<activity android:name=".stores.gfn.GfnActivity" android:exported="false"
android:process=":gfn" android:launchMode="singleTop"
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|smallestScreenSize|density|navigation|uiMode"
android:theme="@style/AppTheme" />
<!-- GeForce NOW (OpenNOW's core and streamer): the core child and the player share the :gfn process;
the Stores page reaches the core through GfnCoreService. -->
<service android:name=".stores.gfn.GfnCoreService" android:exported="false" android:process=":gfn" />
<activity android:name=".stores.gfn.GfnPlayerActivity" android:exported="false"
android:process=":gfn" android:screenOrientation="sensorLandscape" android:hardwareAccelerated="true"
@@ -1,515 +0,0 @@
package com.droiddeck.launcher.stores.gfn
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.foundation.verticalScroll
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import com.droiddeck.launcher.ui.DroidDeckTheme
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONObject
/** GeForce NOW: sign in with an NVIDIA account, browse the games GeForce NOW offers, and play one. */
class GfnActivity : ComponentActivity() {
data class Game(val id: String, val variantId: String, val title: String, val store: String,
val imageUrl: String, val inLibrary: Boolean, val search: String)
private sealed interface Screen {
data object Loading : Screen
data class SignedOut(val error: String? = null) : Screen
data class Code(val userCode: String, val url: String, val attemptId: String, val intervalMs: Long) : Screen
data class Games(val user: String) : Screen
data class Failed(val message: String) : Screen
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent { DroidDeckTheme { Content() } }
}
@Composable
private fun Content() {
var screen by remember { mutableStateOf<Screen>(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<List<Game>?>(null) }
var error by remember { mutableStateOf<String?>(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<Game> {
val games = LinkedHashMap<String, Game>()
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<List<StoreAccount>?>(null) }
var message by remember { mutableStateOf<String?>(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<StoreAccount> {
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<Game>, 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<Game>()) }
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<String?>(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<Game> {
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." })
}
}
}
}
@@ -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<JSONObject>()
@@ -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!!
}
}
}
@@ -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>(Phase.Loading)
private set
private var started = false
private var signInJob: Job? = null
var storeGames by mutableStateOf(emptyList<GfnGame>())
private set
var storeTotal by mutableStateOf(0)
private set
var storeLoading by mutableStateOf(false)
private set
var storeError by mutableStateOf<String?>(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<List<GfnGame>?>(null)
private set
var libraryError by mutableStateOf<String?>(null)
private set
private var libraryJob: Job? = null
var accounts by mutableStateOf<List<GfnStoreAccount>?>(null)
private set
var accountsMessage by mutableStateOf<String?>(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<GfnGame>, 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<GfnGame> {
val games = LinkedHashMap<String, GfnGame>()
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<GfnGame> {
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<GfnStoreAccount> {
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."
}
}
@@ -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)
}
}
}
}
@@ -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<StoreTab>, 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<String, Rect>() }
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<Rect?>(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)
+30
View File
@@ -3226,4 +3226,34 @@
<string name="stores_xbox_cloud_settings">Cloud settings</string>
<string name="xbox_cloud_gaming" translatable="false">Xbox Cloud Gaming</string>
<string name="xbox_filter_game_pass" translatable="false">Game Pass</string>
<!-- GeForce NOW tab (OpenNOW) -->
<string name="gfn_name" translatable="false">GeForce NOW</string>
<string name="gfn_pitch">Stream your games from NVIDIA\'s servers.</string>
<string name="gfn_unofficial">Unofficial client built on OpenNOW. NVIDIA does not support third-party GeForce NOW apps.</string>
<string name="gfn_open_page">Open the sign-in page</string>
<string name="gfn_code_hint">On this device or another one, open %1$s and enter this code:</string>
<string name="gfn_code_waiting">Waiting for you to sign in…</string>
<string name="gfn_try_again">Try again</string>
<string name="gfn_mode_store">Store</string>
<string name="gfn_mode_library">My library</string>
<string name="gfn_mode_accounts">Connected stores</string>
<string name="gfn_search_store">Search %1$d games</string>
<string name="gfn_search_library">Search your %1$d games</string>
<string name="gfn_loading_store">Loading the GeForce NOW store…</string>
<string name="gfn_loading_library">Loading your library…</string>
<string name="gfn_library_empty">Your GeForce NOW library is empty. Connect your stores under Connected stores, then sync.</string>
<string name="gfn_profile_line">GeForce NOW · %1$d in library</string>
<string name="gfn_accounts_note">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.</string>
<string name="gfn_connect">Connect</string>
<string name="gfn_reconnect">Reconnect</string>
<string name="gfn_sync">Sync library</string>
<string name="gfn_disconnect">Disconnect</string>
<string name="gfn_disconnect_confirm">Press again to disconnect</string>
<string name="gfn_signout_confirm">Press again to sign out</string>
<string name="gfn_steam_link">Link Steam on NVIDIA\'s site</string>
<string name="gfn_status_connected">Connected</string>
<string name="gfn_status_connected_as">Connected as %1$s</string>
<string name="gfn_status_expired">Connection expired: reconnect</string>
<string name="gfn_status_sync_error">Last sync failed</string>
<string name="gfn_status_not_connected">Not connected</string>
</resources>