mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-12 19:41:10 +00:00
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:
1 parent
41b065e89c
commit
332c4ea751
8 files changed
+905
-536
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user