diff --git a/app/src/androidTest/java/com/papi/nova/ui/NovaGameDetailComposeTest.kt b/app/src/androidTest/java/com/papi/nova/ui/NovaGameDetailComposeTest.kt
index a94d3052..bdf939ae 100644
--- a/app/src/androidTest/java/com/papi/nova/ui/NovaGameDetailComposeTest.kt
+++ b/app/src/androidTest/java/com/papi/nova/ui/NovaGameDetailComposeTest.kt
@@ -7,6 +7,7 @@ import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.papi.nova.api.PolarisClientSettings
+import com.papi.nova.api.PolarisApiClient
import com.papi.nova.shared.polaris.model.PolarisGame
import com.papi.nova.ui.compose.NovaComposeTheme
import org.junit.Assert.assertTrue
@@ -113,7 +114,23 @@ class NovaGameDetailComposeTest {
iconAvailable = false,
iconPresentationKey = "",
iconLoader = {},
- coverLoader = {}
+ coverLoader = {},
+ destination = NovaGameDetailDestination.OVERVIEW,
+ steamDecision = null,
+ reviewExpanded = false,
+ // No host is reachable under test; the backdrop simply draws nothing.
+ apiClient = PolarisApiClient(
+ InstrumentationRegistry.getInstrumentation().targetContext,
+ "",
+ ),
+ sourceLabel = "Steam",
+ onDestination = {},
+ onSteamChoice = {},
+ // no session under test, so the primary action stays a launch
+ activeSession = null,
+ onResumeSession = {},
+ onEndSession = {},
+ onDismissDestination = {},
)
}
}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index cbd91979..d8df620b 100755
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -294,6 +294,7 @@
diff --git a/app/src/main/java/com/papi/nova/api/PolarisGameJsonAdapter.kt b/app/src/main/java/com/papi/nova/api/PolarisGameJsonAdapter.kt
index 214bf197..e51652c8 100644
--- a/app/src/main/java/com/papi/nova/api/PolarisGameJsonAdapter.kt
+++ b/app/src/main/java/com/papi/nova/api/PolarisGameJsonAdapter.kt
@@ -43,6 +43,23 @@ object PolarisGameJsonAdapter {
coverUrl = json.optString("cover_url", ""),
genres = fetchStringArray(json.optJSONArray("genres")),
lastLaunched = json.optLong("last_launched", 0),
+ beatTime = json.optJSONObject("beat_time")?.let { beat ->
+ PolarisGame.BeatTime(
+ mainSeconds = beat.optLong("main_seconds", 0).coerceAtLeast(0),
+ extrasSeconds = beat.optLong("extras_seconds", 0).coerceAtLeast(0),
+ completionistSeconds = beat.optLong("completionist_seconds", 0).coerceAtLeast(0),
+ matchedName = beat.optString("matched_name", ""),
+ url = beat.optString("url", ""),
+ cachedAt = beat.optLong("cached_at", 0).coerceAtLeast(0)
+ )
+ },
+ playTime = json.optJSONObject("play_time")?.let { played ->
+ PolarisGame.PlayTime(
+ seconds = played.optLong("seconds", 0).coerceAtLeast(0),
+ source = played.optString("source", ""),
+ readAt = played.optLong("read_at", 0).coerceAtLeast(0)
+ )
+ },
mangohud = json.optBoolean("mangohud", false),
hdrSupported = json.optBoolean("hdr_supported", false),
launchMode = launchMode,
diff --git a/app/src/main/java/com/papi/nova/ui/NovaArtworkStudio.kt b/app/src/main/java/com/papi/nova/ui/NovaArtworkStudio.kt
index ac5005e4..7be75ca9 100644
--- a/app/src/main/java/com/papi/nova/ui/NovaArtworkStudio.kt
+++ b/app/src/main/java/com/papi/nova/ui/NovaArtworkStudio.kt
@@ -288,10 +288,12 @@ fun NovaArtworkStudio(
choicePreviewLoader: (ImageView, PolarisArtworkChoice) -> Unit,
currentArtworkPresentationKey: (String) -> String,
currentArtworkLoader: (ImageView, String) -> Unit,
+ /** True when the studio is the destination rather than a row inside one. */
+ initiallyExpanded: Boolean = false,
) {
val colors = LocalNovaComposeColors.current
val surfaces = LocalNovaLibrarySurfaces.current
- var expanded by remember(initialQuery) { mutableStateOf(false) }
+ var expanded by remember(initialQuery) { mutableStateOf(initiallyExpanded) }
var query by remember(initialQuery) { mutableStateOf(initialQuery) }
val title = stringResource(R.string.nova_artwork_studio_title)
val summary = stringResource(R.string.nova_artwork_studio_summary)
diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt
index c07c712a..420914d7 100644
--- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt
+++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt
@@ -153,6 +153,17 @@ class NovaGameDetailActivity : NovaActivity() {
private val onGameUpdated: ((PolarisGame) -> Unit)? = { game -> updatedGame = game }
+ /** Resume and End need stream credentials this window does not carry, so it asks. */
+ private fun finishWithSessionRequest(request: String) {
+ setResult(
+ RESULT_OK,
+ Intent()
+ .putExtra(EXTRA_RESULT_SESSION, request)
+ .putExtra(EXTRA_RESULT_GAME, updatedGame?.let { PolarisGameJson.encode(it) }),
+ )
+ finish()
+ }
+
private val onRefreshArtwork: ((PolarisGame, (NovaArtworkMutationResult) -> Unit) -> Unit)? =
{ game, onResult -> artworkViewModel.refreshArtwork(game = game, onResult = onResult) }
@@ -176,6 +187,20 @@ class NovaGameDetailActivity : NovaActivity() {
/** Artwork and MangoHUD edits made here; handed back so the library can merge them. */
private var updatedGame: PolarisGame? = null
+ private var destination by mutableStateOf(NovaGameDetailDestination.OVERVIEW)
+
+ /**
+ * Set when Polaris reports desktop Steam active. It turns Launch mode into the
+ * three-way choice that used to be a bottom sheet raised over the content.
+ */
+ private var steamDecision by mutableStateOf(null)
+
+ /** The preflight review, expanded on the Overview rather than raised as an alert. */
+ private var reviewExpanded by mutableStateOf(false)
+
+ /** The host's session, when it is this game's. Null means nothing is running. */
+ private var activeSession by mutableStateOf(null)
+
override fun onCreate(savedInstanceState: Bundle?) {
NovaThemeManager.applyTheme(this)
super.onCreate(savedInstanceState)
@@ -206,6 +231,7 @@ class NovaGameDetailActivity : NovaActivity() {
this,
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
+ if (dismissActiveDetailDestination()) return
publishGameUpdate()
finish()
}
@@ -213,6 +239,40 @@ class NovaGameDetailActivity : NovaActivity() {
)
setUpDetail(game, apiClient)
+ refreshActiveSession(game)
+ }
+
+ /**
+ * Unwinds one level: an expanded review collapses, a destination returns to the
+ * Overview, and only then does back leave for the library.
+ */
+ private fun dismissActiveDetailDestination(): Boolean = when {
+ reviewExpanded -> {
+ reviewExpanded = false
+ true
+ }
+ destination != NovaGameDetailDestination.OVERVIEW -> {
+ destination = NovaGameDetailDestination.OVERVIEW
+ steamDecision = null
+ true
+ }
+ else -> false
+ }
+
+ /**
+ * Polaris reports one session at a time, so it only matters here when it is this
+ * game's. Matched on the UUID Polaris uses, falling back to the numeric app id.
+ */
+ private fun refreshActiveSession(game: PolarisGame) {
+ lifecycleScope.launch {
+ val session = withContext(Dispatchers.IO) {
+ runCatching { NovaLibraryActiveSessionUiState.from(apiClient.getSessionStatus()) }
+ .getOrNull()
+ }
+ activeSession = session?.takeIf {
+ it.gameUuid.equals(game.id, ignoreCase = true) || it.gameId == game.appId
+ }
+ }
}
/** Carries artwork or MangoHUD edits back even when the window closes without launching. */
@@ -355,11 +415,36 @@ class NovaGameDetailActivity : NovaActivity() {
allowedModes = allowedModes
)
currentGame = currentGame.copy(launchMode = updatedLaunchMode)
+ NovaLaunchModeOverrides.save(this@NovaGameDetailActivity, currentGame, mode)
refreshUiState()
optimizationState = NovaGameDetailOptimizationState()
loadOptimization(profilePreference, usesVirtualDisplay = mode == "virtual_display")
}
+ fun launchConfirmed(mirrorDesktop: Boolean, forcePrivateAfterSteamClose: Boolean = false) {
+ onLaunch?.invoke(
+ currentGame.copy(mangohud = mangoHudEnabled),
+ uiState.playUsesVirtualDisplay,
+ mirrorDesktop,
+ forcePrivateAfterSteamClose,
+ profilePreference,
+ optimizationState.rawOptimization
+ )
+ finish()
+ }
+
+ fun resetProfile() {
+ resetWorking = true
+ lifecycleScope.launch {
+ withContext(Dispatchers.IO) {
+ apiClient.clearOptimizerProfile(deviceName, currentGame.name)
+ }
+ optimizationState = NovaGameDetailOptimizationState()
+ loadOptimization(profilePreference)
+ resetWorking = false
+ }
+ }
+
setContentView(
ComposeView(this).apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnDetachedFromWindow)
@@ -407,47 +492,43 @@ class NovaGameDetailActivity : NovaActivity() {
coverContentDescription = getString(R.string.nova_a11y_game_cover),
onPrimaryLaunch = {
if (!uiState.playEnabled) return@NovaGameDetailContent
- fun launchConfirmed(mirrorDesktop: Boolean, forcePrivateAfterSteamClose: Boolean = false) {
- onLaunch?.invoke(
- currentGame.copy(mangohud = mangoHudEnabled),
- uiState.playUsesVirtualDisplay,
- mirrorDesktop,
- forcePrivateAfterSteamClose,
- profilePreference,
- optimizationState.rawOptimization
- )
- finish()
- }
- val desktopSteamDecision = NovaDesktopSteamLaunchDecision.from(
+ val decision = NovaDesktopSteamLaunchDecision.from(
uiState,
optimizationState.rawOptimization
)
- if (desktopSteamDecision.required) {
- showDesktopSteamLaunchDecision(
- decision = desktopSteamDecision,
- onPrivateStream = { launchConfirmed(mirrorDesktop = false, forcePrivateAfterSteamClose = false) },
- onMirrorDesktop = { launchConfirmed(mirrorDesktop = true, forcePrivateAfterSteamClose = false) },
- onForcePrivateAfterSteamClose = { launchConfirmed(mirrorDesktop = false, forcePrivateAfterSteamClose = true) }
- )
- } else if (optimizationState.reviewRequired) {
- showPreflightReview(
- optimizationState = optimizationState,
- onLaunchConfirmed = { launchConfirmed(false) },
- onRetryHighFps = { retryHighFpsTrial() },
- onResetProfile = {
- resetWorking = true
- lifecycleScope.launch {
- withContext(Dispatchers.IO) {
- apiClient.clearOptimizerProfile(deviceName, currentGame.name)
- }
- optimizationState = NovaGameDetailOptimizationState()
- loadOptimization(profilePreference)
- resetWorking = false
- }
- }
- )
- } else {
- launchConfirmed(false)
+ when {
+ // A choice of where to run belongs in the destination named that,
+ // not in a sheet raised over the artwork.
+ decision.required -> {
+ steamDecision = decision
+ destination = NovaGameDetailDestination.LAUNCH_MODE
+ }
+ // The review is a statement about the profile, and the status
+ // line is where the profile lives, so it expands in place.
+ optimizationState.reviewRequired && !reviewExpanded -> {
+ reviewExpanded = true
+ }
+ else -> launchConfirmed(false)
+ }
+ },
+ destination = destination,
+ steamDecision = steamDecision,
+ reviewExpanded = reviewExpanded,
+ apiClient = apiClient,
+ sourceLabel = currentGame.sourceLabel,
+ onDestination = { next -> destination = next },
+ onDismissDestination = { dismissActiveDetailDestination() },
+ activeSession = activeSession,
+ onResumeSession = { finishWithSessionRequest(RESULT_SESSION_RESUME) },
+ onEndSession = { finishWithSessionRequest(RESULT_SESSION_END) },
+ onSteamChoice = { choice ->
+ when (choice) {
+ NovaSteamLaunchChoice.PRIVATE_STREAM ->
+ launchConfirmed(mirrorDesktop = false, forcePrivateAfterSteamClose = false)
+ NovaSteamLaunchChoice.MIRROR_DESKTOP ->
+ launchConfirmed(mirrorDesktop = true, forcePrivateAfterSteamClose = false)
+ NovaSteamLaunchChoice.CLOSE_STEAM_THEN_PRIVATE ->
+ launchConfirmed(mirrorDesktop = false, forcePrivateAfterSteamClose = true)
}
},
onLaunchOptions = {
@@ -459,7 +540,13 @@ class NovaGameDetailActivity : NovaActivity() {
profileOptionsState = null
}
},
- onLaunchModeSelected = ::selectLaunchMode,
+ onLaunchModeSelected = { mode ->
+ // The concept: choosing sets the mode and returns to the Overview,
+ // whose readout reflects it. Staying put left the pill showing the
+ // previous mode until the window was opened again.
+ selectLaunchMode(mode)
+ destination = NovaGameDetailDestination.OVERVIEW
+ },
onLaunchOptionSelected = { option ->
fun launchSelected(mirrorDesktop: Boolean, forcePrivateAfterSteamClose: Boolean = false) {
val selectedLaunchOptimization = option.launchOptimization ?: optimizationState.rawOptimization
@@ -480,12 +567,10 @@ class NovaGameDetailActivity : NovaActivity() {
usesVirtualDisplay = option.usesVirtualDisplay
)
if (desktopSteamDecision.required) {
- showDesktopSteamLaunchDecision(
- decision = desktopSteamDecision,
- onPrivateStream = { launchSelected(mirrorDesktop = false, forcePrivateAfterSteamClose = false) },
- onMirrorDesktop = { launchSelected(mirrorDesktop = true, forcePrivateAfterSteamClose = false) },
- onForcePrivateAfterSteamClose = { launchSelected(mirrorDesktop = false, forcePrivateAfterSteamClose = true) }
- )
+ // Same destination the primary action routes to; picking an
+ // explicit option does not change where the choice belongs.
+ steamDecision = desktopSteamDecision
+ destination = NovaGameDetailDestination.LAUNCH_MODE
} else {
launchSelected(mirrorDesktop = false)
}
@@ -733,7 +818,8 @@ class NovaGameDetailActivity : NovaActivity() {
game = game,
defaultToVirtualDisplay = defaultToVirtualDisplay,
clientSettings = clientSettings,
- profilePreference = profilePreference
+ profilePreference = profilePreference,
+ launchModeOverride = NovaLaunchModeOverrides.load(this@NovaGameDetailActivity, game),
)
}
@@ -919,74 +1005,7 @@ class NovaGameDetailActivity : NovaActivity() {
)
}
- private fun showPreflightReview(
- optimizationState: NovaGameDetailOptimizationState,
- onLaunchConfirmed: () -> Unit,
- onRetryHighFps: () -> Unit,
- onResetProfile: () -> Unit
- ) {
- val reason = optimizationState.reviewReason.ifBlank { "fps_override" }
- val dialog = AlertDialog.Builder(this@NovaGameDetailActivity)
- .setTitle(R.string.nova_library_preflight_review_title)
- .setMessage(getString(R.string.nova_library_preflight_review_message, reason))
- .setPositiveButton(R.string.nova_library_preflight_launch) { _, _ -> onLaunchConfirmed() }
- .setNeutralButton(R.string.nova_library_retry_high_fps) { _, _ -> onRetryHighFps() }
- .setNegativeButton(R.string.nova_library_reset_game_profile) { _, _ -> onResetProfile() }
- .create()
- NovaSheetChrome.applyMenuOpacityToLegacyAlert(dialog)
- dialog.show()
- }
- private fun showDesktopSteamLaunchDecision(
- decision: NovaDesktopSteamLaunchDecision,
- onPrivateStream: () -> Unit,
- onMirrorDesktop: () -> Unit,
- onForcePrivateAfterSteamClose: () -> Unit
- ) {
- val sheet = BottomSheetDialog(this@NovaGameDetailActivity)
- val composeView = ComposeView(this@NovaGameDetailActivity).apply {
- setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnDetachedFromWindow)
- background = NovaSheetChrome.createSheetBackground(this@NovaGameDetailActivity)
- setContent {
- NovaComposeTheme {
- NovaDesktopSteamLaunchDecisionContent(
- title = stringResource(R.string.nova_desktop_steam_title),
- message = decision.reason.ifBlank {
- stringResource(R.string.nova_desktop_steam_message)
- },
- privateStreamLabel = stringResource(R.string.nova_desktop_steam_private_stream),
- privateStreamUnavailableReason = decision.privateStreamUnavailableReason,
- privateStreamEnabled = decision.privateStreamEnabled,
- mirrorDesktopLabel = stringResource(R.string.nova_desktop_steam_mirror_desktop),
- mirrorDesktopEnabled = decision.mirrorDesktopEnabled,
- mirrorDesktopCaption = stringResource(R.string.nova_desktop_steam_mirror_caption),
- forcePrivateLabel = decision.forcePrivateAfterSteamCloseLabel.ifBlank {
- stringResource(R.string.nova_desktop_steam_force_private)
- },
- forcePrivateEnabled = decision.forcePrivateAfterSteamCloseEnabled,
- forcePrivateCaption = stringResource(R.string.nova_desktop_steam_force_private_caption),
- cancelLabel = stringResource(R.string.nova_desktop_steam_cancel),
- onPrivateStream = {
- sheet.dismiss()
- onPrivateStream()
- },
- onMirrorDesktop = {
- sheet.dismiss()
- onMirrorDesktop()
- },
- onForcePrivateAfterSteamClose = {
- sheet.dismiss()
- onForcePrivateAfterSteamClose()
- },
- onCancel = { sheet.dismiss() }
- )
- }
- }
- }
- sheet.setContentView(composeView)
- sheet.setOnShowListener { expandBottomSheet(sheet, composeView) }
- sheet.show()
- }
private fun expandBottomSheet(bottomSheetDialog: BottomSheetDialog?, contentView: View) {
val sheet = bottomSheetDialog?.findViewById(com.google.android.material.R.id.design_bottom_sheet) ?: return
@@ -1206,7 +1225,7 @@ class NovaGameDetailActivity : NovaActivity() {
""
}
val sourceText = listOf(
- stateLabel.takeIf { it.isNotBlank() },
+ stateLabel.takeIf { it.isNotBlank() && stateLabel != titleLabel },
profileState?.optString("preference_label", "")?.takeIf { it.isNotBlank() },
lastResultText.takeIf { it.isNotBlank() },
sourceLabel.takeIf { it.isNotBlank() && sourceLabel != titleLabel },
@@ -1359,6 +1378,9 @@ class NovaGameDetailActivity : NovaActivity() {
const val EXTRA_DEFAULT_VIRTUAL_DISPLAY = "nova.detail.defaultVirtualDisplay"
const val EXTRA_RESULT_LAUNCH = "nova.detail.result.launch"
const val EXTRA_RESULT_LAUNCH_GAME = "nova.detail.result.launchGame"
+ const val EXTRA_RESULT_SESSION = "nova.detail.result.session"
+ const val RESULT_SESSION_RESUME = "resume"
+ const val RESULT_SESSION_END = "end"
const val EXTRA_RESULT_GAME = "nova.detail.result.game"
const val RESULT_KEY_VIRTUAL_DISPLAY = "virtualDisplay"
diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt
index 434f76cc..54e6560f 100644
--- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt
+++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt
@@ -1,23 +1,12 @@
package com.papi.nova.ui
-import android.app.Dialog
-import android.content.Context
-import android.content.res.Configuration
-import android.os.Bundle
-import android.text.format.DateUtils
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
import android.widget.ImageView
-import android.widget.ScrollView
-import android.widget.Toast
-import androidx.appcompat.app.AlertDialog
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.background
import androidx.compose.foundation.border
-import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectVerticalDragGestures
import androidx.compose.foundation.horizontalScroll
+import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
@@ -25,18 +14,15 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
-import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.aspectRatio
-import androidx.compose.foundation.layout.fillMaxHeight
+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.offset
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.safeContent
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
@@ -44,7 +30,6 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -57,13 +42,13 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.input.pointer.pointerInput
-import androidx.compose.ui.platform.ComposeView
-import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
@@ -72,41 +57,21 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
-import androidx.core.content.ContextCompat
-import androidx.core.widget.NestedScrollView
import androidx.lifecycle.Lifecycle
-import androidx.lifecycle.lifecycleScope
-import com.google.android.material.bottomsheet.BottomSheetBehavior
-import com.google.android.material.bottomsheet.BottomSheetDialog
-import com.google.android.material.bottomsheet.BottomSheetDialogFragment
-import com.papi.nova.LimeLog
import com.papi.nova.R
import com.papi.nova.api.PolarisApiClient
import com.papi.nova.api.PolarisArtworkChoice
import com.papi.nova.api.PolarisArtworkMatchCandidate
-import com.papi.nova.api.PolarisClientSettings
-import com.papi.nova.api.PolarisStreamDisplayMode
import com.papi.nova.shared.polaris.model.PolarisGame
-import com.papi.nova.manager.StreamSyncManager
-import com.papi.nova.preferences.PreferenceConfiguration
import com.papi.nova.ui.compose.LocalNovaComposeColors
import com.papi.nova.ui.compose.LocalNovaLibrarySurfaces
import com.papi.nova.ui.compose.LocalNovaMenuOpacityScale
import com.papi.nova.ui.compose.NovaActionButton
import com.papi.nova.ui.compose.NovaBadge
-import com.papi.nova.ui.compose.NovaComposeTheme
import com.papi.nova.ui.compose.NovaControllerHint
-import com.papi.nova.ui.compose.NovaControllerHintBar
import com.papi.nova.ui.compose.NovaFocusableCard
-import com.papi.nova.utils.DeviceUtils
-import kotlinx.coroutines.CancellationException
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
import org.json.JSONObject
-import java.util.Locale
-import kotlin.math.abs
-import kotlin.math.round
internal fun canPublishArtworkMutationUiForState(state: Lifecycle.State?): Boolean =
state?.isAtLeast(Lifecycle.State.CREATED) == true
@@ -213,7 +178,7 @@ data class NovaSteamLaunchModeOptionsState(
@Composable
-fun NovaGameDetailContent(
+internal fun NovaGameDetailContent(
uiState: NovaGameDetailUiState,
launchIntro: String,
recommendedBadge: String,
@@ -280,152 +245,199 @@ fun NovaGameDetailContent(
iconPresentationKey: String,
iconLoader: (ImageView) -> Unit,
iconContentDescription: String = "",
- coverLoader: (ImageView) -> Unit
+ coverLoader: (ImageView) -> Unit,
+ destination: NovaGameDetailDestination,
+ steamDecision: NovaDesktopSteamLaunchDecision?,
+ reviewExpanded: Boolean,
+ apiClient: PolarisApiClient,
+ sourceLabel: String,
+ onDestination: (NovaGameDetailDestination) -> Unit,
+ onSteamChoice: (NovaSteamLaunchChoice) -> Unit,
+ activeSession: NovaLibraryActiveSessionUiState?,
+ onResumeSession: () -> Unit,
+ onEndSession: () -> Unit,
+ onDismissDestination: () -> Unit,
) {
- val colors = LocalNovaComposeColors.current
- val surfaces = LocalNovaLibrarySurfaces.current
val verticalScroll = rememberScrollState()
val playFocusRequester = remember { FocusRequester() }
val detailsFocusRequester = remember { FocusRequester() }
- Column(
- modifier = modifier
- .fillMaxWidth()
- .fillMaxHeight()
- .clip(RoundedCornerShape(topStart = NovaSheetChrome.SHEET_CORNER_RADIUS_DP.dp, topEnd = NovaSheetChrome.SHEET_CORNER_RADIUS_DP.dp))
- .background(surfaces.panel)
- ) {
- NovaGameDetailScrollableContent(
- scrollState = verticalScroll,
- modifier = Modifier.weight(1f)
- ) {
- GameDetailsPanel(
- uiState = uiState,
- lastPlayedText = lastPlayedText,
- coverContentDescription = coverContentDescription,
- coverLoader = coverLoader,
- artworkState = artworkState,
- heroAvailable = heroAvailable,
- heroPresentationKey = heroPresentationKey,
- heroLoader = heroLoader,
- heroContentDescription = heroContentDescription,
- logoAvailable = logoAvailable,
- logoPresentationKey = logoPresentationKey,
- logoLoader = logoLoader,
- logoContentDescription = logoContentDescription,
- iconAvailable = iconAvailable,
- iconPresentationKey = iconPresentationKey,
- iconLoader = iconLoader,
- iconContentDescription = iconContentDescription,
- )
-
- LaunchControlsPanel(
- uiState = uiState,
- launchIntro = launchIntro,
- launchModeTitle = launchModeTitle,
- recommendedBadge = recommendedBadge,
- launchOptionsLabel = launchOptionsLabel,
- profilePreferenceLabel = profilePreferenceLabel,
- profileSummary = optimizationState.profileSummary,
- resetProfileLabel = resetProfileLabel,
- resetProfileWorking = resetProfileWorking,
- headlessModeLabel = headlessModeLabel,
- virtualDisplayModeLabel = virtualDisplayModeLabel,
- playFocusRequester = playFocusRequester,
- detailsFocusRequester = detailsFocusRequester,
- onLaunchOptions = onLaunchOptions,
- onLaunchModeSelected = onLaunchModeSelected,
- onProfilePreference = onProfilePreference,
- onRetryHighFps = onRetryHighFps,
- onResetProfile = onResetProfile
- )
+ Box(modifier = modifier.fillMaxSize()) {
+ NovaGameDetailOverview(
+ uiState = uiState,
+ apiClient = apiClient,
+ playLabel = playLabel,
+ lastPlayedText = lastPlayedText,
+ sourceLabel = sourceLabel,
+ optimizationState = optimizationState,
+ reviewExpanded = reviewExpanded,
+ showLaunchModeAction = uiState.showLaunchOptionsButton,
+ logoAvailable = logoAvailable,
+ logoPresentationKey = logoPresentationKey,
+ logoLoader = logoLoader,
+ logoContentDescription = logoContentDescription,
+ playFocusRequester = playFocusRequester,
+ onPrimaryLaunch = onPrimaryLaunch,
+ onRetryHighFps = onRetryHighFps,
+ onResetProfile = onResetProfile,
+ onDestination = onDestination,
+ activeSession = activeSession,
+ onResumeSession = onResumeSession,
+ onEndSession = onEndSession,
+ // While a destination is open the Overview is scenery. Without this, a d-pad
+ // press walks out of the panel onto a control dimmed behind the scrim.
+ modifier = if (destination == NovaGameDetailDestination.OVERVIEW) {
+ Modifier
+ } else {
+ Modifier.focusGroup().focusProperties { canFocus = false }
+ },
+ )
- launchOptionsState?.let {
- NovaLaunchOptionsSheet(
- state = it,
- onLaunch = onLaunchOptionSelected,
- onDismiss = onDismissLaunchOptions
- )
- }
+ when (destination) {
+ NovaGameDetailDestination.OVERVIEW -> Unit
- profileOptionsState?.let {
- NovaProfilePreferenceSheet(
- state = it,
- onSelected = onProfilePreferenceSelected,
- onDismiss = onDismissProfileOptions
- )
+ NovaGameDetailDestination.LAUNCH_MODE -> NovaGameDetailPanel(
+ eyebrow = stringResource(R.string.nova_library_launch_mode_title),
+ headline = stringResource(R.string.nova_game_detail_where_it_runs),
+ readout = if (steamDecision != null) {
+ stringResource(R.string.nova_desktop_steam_title)
+ } else {
+ optimizationState.profileSummary?.selectedLine.orEmpty()
+ },
+ scrollState = verticalScroll,
+ onDismiss = onDismissDestination,
+ ) {
+ val decision = steamDecision
+ if (decision != null) {
+ NovaDesktopSteamLaunchDecisionRows(
+ decision = decision,
+ onChoice = onSteamChoice,
+ )
+ } else {
+ LaunchControls(
+ uiState = uiState,
+ launchIntro = launchIntro,
+ launchModeTitle = launchModeTitle,
+ launchOptionsLabel = launchOptionsLabel,
+ profileSummary = optimizationState.profileSummary,
+ headlessModeLabel = headlessModeLabel,
+ virtualDisplayModeLabel = virtualDisplayModeLabel,
+ playFocusRequester = playFocusRequester,
+ detailsFocusRequester = detailsFocusRequester,
+ onLaunchOptions = onLaunchOptions,
+ onLaunchModeSelected = onLaunchModeSelected,
+ )
+ launchOptionsState?.let {
+ NovaLaunchOptionsSheet(
+ state = it,
+ onLaunch = onLaunchOptionSelected,
+ onDismiss = onDismissLaunchOptions
+ )
+ }
+ }
}
- SteamLaunchModeCard(
- visible = uiState.showSteamLaunchMode,
- label = steamLaunchLabel,
- modeLabel = steamLaunchModeLabel,
- caption = steamLaunchCaption,
- warning = uiState.steamLaunchWarning,
- onClick = onSteamLaunchMode
- )
+ NovaGameDetailDestination.TUNE -> NovaGameDetailPanel(
+ eyebrow = stringResource(R.string.nova_game_detail_tune),
+ headline = stringResource(
+ AutoQualityProfilePreferences.shortLabelRes(uiState.profilePreference),
+ ),
+ readout = listOf(
+ optimizationState.profileSummary?.selectedLine,
+ optimizationState.profileSummary?.freshnessLine,
+ ).filter { !it.isNullOrBlank() }.joinToString(" · "),
+ scrollState = verticalScroll,
+ onDismiss = onDismissDestination,
+ ) {
+ // A picker is what you are doing with the destination while it is
+ // open, not a row inside one of its groups, so it spans the body,
+ // above the groups rather than below the fold they push it past.
+ profileOptionsState?.let {
+ NovaProfilePreferenceSheet(
+ state = it,
+ onSelected = onProfilePreferenceSelected,
+ onDismiss = onDismissProfileOptions
+ )
+ }
+ steamLaunchOptionsState?.let { state ->
+ NovaSteamLaunchModeSheet(
+ state = state,
+ onSelected = onSteamLaunchModeSelected,
+ onDismiss = onDismissSteamLaunchModeOptions
+ )
+ }
- steamLaunchOptionsState?.let { state ->
- NovaSteamLaunchModeSheet(
- state = state,
- onSelected = onSteamLaunchModeSelected,
- onDismiss = onDismissSteamLaunchModeOptions
+ NovaGameDetailGroupLabel(stringResource(R.string.nova_game_detail_group_state))
+ // The concept leads State with the profile itself, and it is the only way
+ // into the preference picker now that launch mode no longer carries a
+ // second copy of Tune's controls.
+ NovaSteamChoiceRow(
+ label = stringResource(R.string.nova_game_detail_profile_label),
+ caption = stringResource(R.string.nova_game_detail_profile_caption),
+ enabled = true,
+ onClick = onProfilePreference,
+ value = stringResource(
+ AutoQualityProfilePreferences.shortLabelRes(uiState.profilePreference),
+ ),
)
- }
-
- if (mangoHudEnabled) {
- MangoHudPassiveStatus(
- label = mangoHudStatusLabel,
- caption = mangoHudStatusCaption,
- warning = mangoHudWarning
+ SteamLaunchModeCard(
+ visible = uiState.showSteamLaunchMode,
+ label = steamLaunchLabel,
+ modeLabel = steamLaunchModeLabel,
+ caption = steamLaunchCaption,
+ warning = uiState.steamLaunchWarning,
+ onClick = onSteamLaunchMode
)
+ if (mangoHudEnabled) {
+ MangoHudPassiveStatus(
+ label = mangoHudStatusLabel,
+ caption = mangoHudStatusCaption,
+ warning = mangoHudWarning
+ )
+ }
+ NovaGameDetailGroupLabel(stringResource(R.string.nova_game_detail_group_actions))
+ LaunchProfileSummaryActions(
+ summary = optimizationState.profileSummary,
+ resetProfileLabel = resetProfileLabel,
+ resetProfileWorking = resetProfileWorking,
+ onRetryHighFps = onRetryHighFps,
+ onResetProfile = onResetProfile,
+ )
+ NovaGameDetailGroupLabel(stringResource(R.string.nova_game_detail_group_insight))
+ optimizationState.ai?.let { InsightCard(card = it) }
+ optimizationState.stability?.let { InsightCard(card = it) }
}
- optimizationState.ai?.let {
- InsightCard(card = it)
- }
-
- optimizationState.stability?.let {
- InsightCard(card = it)
+ // The studio opens with a Row of weighted Columns, so it needs the window
+ // rather than the 438dp panel the other two destinations use.
+ NovaGameDetailDestination.ARTWORK -> NovaGameDetailFullScreen(
+ eyebrow = stringResource(R.string.nova_artwork_studio_title),
+ headline = uiState.game.name,
+ scrollState = verticalScroll,
+ onDismiss = onDismissDestination,
+ ) {
+ NovaArtworkStudio(
+ initiallyExpanded = true,
+ state = artworkState,
+ initialQuery = uiState.game.name,
+ onRefresh = onRefreshArtwork,
+ onSearch = onSearchArtwork,
+ onIdentitySelected = onIdentitySelected,
+ onChangeIdentity = onIdentityChange,
+ onKindSelected = onKindSelected,
+ onChoiceSelected = onChoiceSelected,
+ onReset = onStudioAction,
+ onApply = onApplyArtwork,
+ onCancel = onStudioAction,
+ onClear = onClearArtwork,
+ onTransform = onLogoTransform,
+ candidatePreviewLoader = candidatePreviewLoader,
+ choicePreviewLoader = choicePreviewLoader,
+ currentArtworkPresentationKey = currentArtworkPresentationKey,
+ currentArtworkLoader = currentArtworkLoader,
+ )
}
-
- NovaArtworkStudio(
- state = artworkState,
- initialQuery = uiState.game.name,
- onRefresh = onRefreshArtwork,
- onSearch = onSearchArtwork,
- onIdentitySelected = onIdentitySelected,
- onChangeIdentity = onIdentityChange,
- onKindSelected = onKindSelected,
- onChoiceSelected = onChoiceSelected,
- onReset = onStudioAction,
- onApply = onApplyArtwork,
- onCancel = onStudioAction,
- onClear = onClearArtwork,
- onTransform = onLogoTransform,
- candidatePreviewLoader = candidatePreviewLoader,
- choicePreviewLoader = choicePreviewLoader,
- currentArtworkPresentationKey = currentArtworkPresentationKey,
- currentArtworkLoader = currentArtworkLoader,
- )
-
- NovaControllerHintBar(
- hints = novaGameDetailControllerHints(),
- compact = true,
- modifier = Modifier
- .fillMaxWidth()
- .padding(start = 14.dp, end = 14.dp, top = 12.dp)
- )
}
-
- NovaGameDetailLaunchFooter(
- playLabel = playLabel,
- enabled = uiState.playEnabled,
- onPrimaryLaunch = onPrimaryLaunch,
- playFocusRequester = playFocusRequester,
- detailsFocusRequester = detailsFocusRequester,
- contentInsets = WindowInsets.safeContent.only(WindowInsetsSides.Bottom)
- )
}
}
@@ -964,58 +976,6 @@ private fun NovaGameDetailIdentity(
}
}
-@Composable
-private fun LaunchControlsPanel(
- uiState: NovaGameDetailUiState,
- launchIntro: String,
- launchModeTitle: String,
- recommendedBadge: String,
- launchOptionsLabel: String,
- profilePreferenceLabel: String,
- profileSummary: NovaLaunchProfileSummary?,
- resetProfileLabel: String,
- resetProfileWorking: Boolean,
- headlessModeLabel: String,
- virtualDisplayModeLabel: String,
- playFocusRequester: FocusRequester,
- detailsFocusRequester: FocusRequester,
- onLaunchOptions: () -> Unit,
- onLaunchModeSelected: (String) -> Unit,
- onProfilePreference: () -> Unit,
- onRetryHighFps: () -> Unit,
- onResetProfile: () -> Unit
-) {
- NovaDetailPanel(
- modifier = Modifier
- .fillMaxWidth()
- .padding(start = 14.dp, end = 14.dp, top = 10.dp),
- contentDescription = "Launch controls",
- accent = true,
- contentPadding = PaddingValues(12.dp)
- ) {
- LaunchControls(
- uiState = uiState,
- launchIntro = launchIntro,
- launchModeTitle = launchModeTitle,
- recommendedBadge = recommendedBadge,
- launchOptionsLabel = launchOptionsLabel,
- profilePreferenceLabel = profilePreferenceLabel,
- profileSummary = profileSummary,
- resetProfileLabel = resetProfileLabel,
- resetProfileWorking = resetProfileWorking,
- headlessModeLabel = headlessModeLabel,
- virtualDisplayModeLabel = virtualDisplayModeLabel,
- playFocusRequester = playFocusRequester,
- detailsFocusRequester = detailsFocusRequester,
- onLaunchOptions = onLaunchOptions,
- onLaunchModeSelected = onLaunchModeSelected,
- onProfilePreference = onProfilePreference,
- onRetryHighFps = onRetryHighFps,
- onResetProfile = onResetProfile
- )
- }
-}
-
@Composable
private fun MetadataBadges(game: PolarisGame) {
val horizontalScroll = rememberScrollState()
@@ -1058,59 +1018,25 @@ private fun LaunchControls(
uiState: NovaGameDetailUiState,
launchIntro: String,
launchModeTitle: String,
- recommendedBadge: String,
launchOptionsLabel: String,
- profilePreferenceLabel: String,
profileSummary: NovaLaunchProfileSummary?,
- resetProfileLabel: String,
- resetProfileWorking: Boolean,
headlessModeLabel: String,
virtualDisplayModeLabel: String,
playFocusRequester: FocusRequester,
detailsFocusRequester: FocusRequester,
onLaunchOptions: () -> Unit,
onLaunchModeSelected: (String) -> Unit,
- onProfilePreference: () -> Unit,
- onRetryHighFps: () -> Unit,
- onResetProfile: () -> Unit
) {
val colors = LocalNovaComposeColors.current
Column {
- Row(
- modifier = Modifier.fillMaxWidth(),
- verticalAlignment = Alignment.CenterVertically
- ) {
- Text(
- text = launchModeTitle,
- modifier = Modifier.weight(1f),
- color = colors.textPrimary,
- fontSize = 16.sp,
- fontWeight = FontWeight.SemiBold,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis
- )
- if (uiState.showRecommendedModeBadge) {
- Spacer(modifier = Modifier.width(8.dp))
- NovaBadge(
- text = recommendedBadge,
- color = colors.onAccent,
- backgroundColor = colors.accent.copy(alpha = 0.86f),
- borderColor = colors.accent,
- fontWeight = FontWeight.SemiBold,
- fontSize = 11.sp,
- contentPadding = PaddingValues(horizontal = 10.dp, vertical = 5.dp)
- )
- }
- }
-
Text(
text = launchIntro,
- modifier = Modifier.padding(top = 6.dp),
+ modifier = Modifier.padding(horizontal = NovaGameDetailInset),
color = if (uiState.virtualDisplayUnavailable) colors.warning else colors.textSecondary,
fontSize = 11.sp,
- lineHeight = 13.sp,
- maxLines = 2,
+ lineHeight = 14.sp,
+ maxLines = 4,
overflow = TextOverflow.Ellipsis
)
@@ -1144,94 +1070,49 @@ private fun LaunchControls(
}
if (uiState.showLaunchOptionsButton || uiState.showVirtualUnavailableHint) {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(top = 10.dp),
- horizontalArrangement = Arrangement.spacedBy(8.dp)
- ) {
- LaunchModeChoicePill(
- label = headlessModeLabel,
- status = when {
- uiState.playMode == "headless" -> "Selected"
- uiState.recommendedMode == "headless" && uiState.headlessAllowed -> "Recommended"
- uiState.headlessAllowed -> "Available"
- else -> "Unavailable"
- },
- recommended = uiState.recommendedMode == "headless" && uiState.headlessAllowed,
- selected = uiState.playMode == "headless",
- unavailable = !uiState.headlessAllowed,
- onClick = { onLaunchModeSelected("headless") },
- modifier = Modifier.weight(1f)
- )
- LaunchModeChoicePill(
- label = virtualDisplayModeLabel,
- status = when {
- uiState.virtualDisplayUnavailable -> "Unavailable"
- uiState.playMode == "virtual_display" -> "Selected"
- uiState.recommendedMode == "virtual_display" && uiState.virtualDisplayAllowed -> "Recommended"
- uiState.virtualDisplayAllowed -> "Available"
- else -> "Unavailable"
- },
- recommended = uiState.recommendedMode == "virtual_display" && uiState.virtualDisplayAllowed,
- selected = uiState.playMode == "virtual_display",
- unavailable = uiState.virtualDisplayUnavailable || !uiState.virtualDisplayAllowed,
- onClick = { onLaunchModeSelected("virtual_display") },
- modifier = Modifier.weight(1f)
- )
- }
- }
-
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(top = 9.dp),
- horizontalArrangement = Arrangement.spacedBy(8.dp)
- ) {
- if (uiState.showLaunchOptionsButton) {
- NovaActionButton(
- text = launchOptionsLabel,
- onClick = onLaunchOptions,
- modifier = Modifier.weight(1f),
- contentDescription = launchOptionsLabel,
- minHeight = 42.dp,
- cornerRadius = 10.dp,
- fontSize = 12.sp,
- contentPadding = PaddingValues(horizontal = 10.dp, vertical = 8.dp)
- )
- }
- NovaActionButton(
- text = profilePreferenceLabel,
- onClick = onProfilePreference,
- modifier = if (uiState.showLaunchOptionsButton) Modifier.weight(1f) else Modifier.fillMaxWidth(),
- contentDescription = profilePreferenceLabel,
- minHeight = 42.dp,
- cornerRadius = 10.dp,
- fontSize = 12.sp,
- contentPadding = PaddingValues(horizontal = 10.dp, vertical = 8.dp)
+ // The two modes are a list of choices, so they read as rows like the rest of
+ // the drawer, each carrying its standing as the value.
+ NovaSteamChoiceRow(
+ label = headlessModeLabel,
+ caption = stringResource(R.string.nova_game_detail_headless_caption),
+ enabled = uiState.headlessAllowed,
+ onClick = { onLaunchModeSelected("headless") },
+ value = when {
+ uiState.playMode == "headless" -> "Selected"
+ uiState.hasRecommendation && uiState.recommendedMode == "headless" &&
+ uiState.headlessAllowed -> "Recommended"
+ uiState.headlessAllowed -> "Available"
+ else -> "Unavailable"
+ },
+ )
+ NovaSteamChoiceRow(
+ label = virtualDisplayModeLabel,
+ caption = if (uiState.showVirtualUnavailableHint) {
+ uiState.virtualDisplayUnavailableReason
+ } else {
+ stringResource(R.string.nova_game_detail_virtual_caption)
+ },
+ enabled = uiState.virtualDisplayAllowed && !uiState.virtualDisplayUnavailable,
+ onClick = { onLaunchModeSelected("virtual_display") },
+ value = when {
+ uiState.virtualDisplayUnavailable -> "Unavailable"
+ uiState.playMode == "virtual_display" -> "Selected"
+ uiState.hasRecommendation && uiState.recommendedMode == "virtual_display" &&
+ uiState.virtualDisplayAllowed -> "Recommended"
+ uiState.virtualDisplayAllowed -> "Available"
+ else -> "Unavailable"
+ },
)
}
- profileSummary?.let {
- LaunchProfileSummaryInline(
- summary = it,
- onRetryHighFps = onRetryHighFps
+ if (uiState.showLaunchOptionsButton) {
+ NovaSteamChoiceRow(
+ label = launchOptionsLabel,
+ caption = stringResource(R.string.nova_game_detail_more_settings_caption),
+ enabled = true,
+ onClick = onLaunchOptions,
)
}
-
- NovaActionButton(
- text = resetProfileLabel,
- onClick = onResetProfile,
- modifier = Modifier
- .fillMaxWidth()
- .padding(top = 8.dp),
- enabled = !resetProfileWorking,
- contentDescription = resetProfileLabel,
- minHeight = 36.dp,
- cornerRadius = 10.dp,
- fontSize = 11.sp,
- contentPadding = PaddingValues(horizontal = 9.dp, vertical = 7.dp)
- )
}
}
@@ -1262,6 +1143,7 @@ internal fun LaunchProfilePrimaryNotice(
Column(
modifier = Modifier
.fillMaxWidth()
+ .padding(horizontal = NovaGameDetailInset)
.padding(top = 8.dp)
.clip(RoundedCornerShape(12.dp))
.background(toneColor.copy(alpha = 0.14f))
@@ -1274,9 +1156,12 @@ internal fun LaunchProfilePrimaryNotice(
) {
NovaBadge(
text = badgeLabel,
- color = toneColor,
- backgroundColor = surfaces.control.copy(alpha = 0.72f * LocalNovaMenuOpacityScale.current),
- borderColor = toneColor.copy(alpha = 0.35f),
+ // A badge is a surface, not media: translucent control over a ground
+ // already tinted with this tone put amber on amber. Opaque tone, with
+ // ink picked from the tone itself, reads in every theme.
+ color = if (toneColor.luminance() > 0.5f) Color.Black else Color.White,
+ backgroundColor = toneColor,
+ borderColor = Color.Transparent,
fontWeight = FontWeight.SemiBold
)
Text(
@@ -1331,122 +1216,6 @@ internal fun LaunchProfilePrimaryNotice(
}
}
-@Composable
-private fun LaunchProfileSummaryInline(
- summary: NovaLaunchProfileSummary,
- onRetryHighFps: () -> Unit
-) {
- val colors = LocalNovaComposeColors.current
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .padding(top = 10.dp)
- .semantics { contentDescription = "Launch profile summary" }
- ) {
- Box(
- modifier = Modifier
- .fillMaxWidth()
- .padding(bottom = 8.dp)
- .heightIn(min = 1.dp, max = 1.dp)
- .background(colors.divider.copy(alpha = 0.55f))
- )
- Row(
- modifier = Modifier.fillMaxWidth(),
- verticalAlignment = Alignment.CenterVertically
- ) {
- Column(modifier = Modifier.weight(1f)) {
- Text(
- text = "Launch Profile",
- color = colors.accent,
- fontSize = 12.sp,
- fontWeight = FontWeight.SemiBold,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis
- )
- ProfileSummaryText(summary.selectedLine, topPadding = 4)
- ProfileSummaryText(summary.requestedLine)
- ProfileSummaryText(summary.limitingLine)
- ProfileSummaryText(summary.reasonLine)
- }
- }
-
- if (summary.historyLines.isNotEmpty()) {
- Text(
- text = summary.historyLines.first(),
- modifier = Modifier.padding(top = 4.dp),
- color = colors.textMuted,
- fontSize = 10.sp,
- lineHeight = 13.sp,
- maxLines = 2,
- overflow = TextOverflow.Ellipsis
- )
- } else {
- ProfileSummaryText(summary.freshnessLine)
- }
-
- if (summary.showRetryHighFps) {
- NovaActionButton(
- text = summary.retryHighFpsLabel,
- onClick = onRetryHighFps,
- modifier = Modifier
- .fillMaxWidth()
- .padding(top = 8.dp),
- contentDescription = summary.retryHighFpsLabel,
- minHeight = 36.dp,
- cornerRadius = 8.dp,
- fontSize = 11.sp,
- contentPadding = PaddingValues(horizontal = 10.dp, vertical = 7.dp)
- )
- }
- }
-}
-
-@Composable
-private fun LaunchModeChoicePill(
- label: String,
- status: String,
- recommended: Boolean,
- selected: Boolean,
- unavailable: Boolean,
- onClick: () -> Unit,
- modifier: Modifier = Modifier
-) {
- val colors = LocalNovaComposeColors.current
- val statusColor = when {
- unavailable -> colors.warning
- selected || recommended -> colors.accent
- else -> colors.textMuted
- }
-
- NovaFocusableCard(
- modifier = modifier.heightIn(min = 52.dp),
- onClick = onClick,
- enabled = !unavailable,
- contentDescription = "$label. $status",
- contentPadding = PaddingValues(horizontal = 10.dp, vertical = 8.dp)
- ) {
- Column {
- Text(
- text = label,
- color = colors.textPrimary,
- fontSize = 12.sp,
- fontWeight = FontWeight.SemiBold,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis
- )
- Text(
- text = status,
- modifier = Modifier.padding(top = 3.dp),
- color = statusColor,
- fontSize = 10.sp,
- fontWeight = FontWeight.SemiBold,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis
- )
- }
- }
-}
-
@Composable
private fun ProfileSummaryText(text: String, topPadding: Int = 3) {
if (text.isBlank()) return
@@ -1688,40 +1457,13 @@ private fun SteamLaunchModeCard(
) {
if (!visible) return
- val colors = LocalNovaComposeColors.current
- NovaFocusableCard(
- modifier = Modifier
- .fillMaxWidth()
- .padding(start = 14.dp, end = 14.dp, top = 10.dp)
- .heightIn(min = 58.dp),
+ NovaSteamChoiceRow(
+ label = label,
+ caption = caption,
+ enabled = true,
onClick = onClick,
- contentDescription = "$label. $modeLabel. $caption",
- contentPadding = PaddingValues(start = 12.dp, top = 9.dp, end = 12.dp, bottom = 9.dp)
- ) {
- Row(
- modifier = Modifier.fillMaxWidth(),
- verticalAlignment = Alignment.CenterVertically
- ) {
- Column(modifier = Modifier.weight(1f)) {
- Text(
- text = label,
- color = colors.textPrimary,
- fontSize = 12.sp,
- fontWeight = FontWeight.SemiBold
- )
- Text(
- text = caption,
- modifier = Modifier.padding(top = 2.dp),
- color = if (warning) colors.warning else colors.textSecondary,
- fontSize = 9.sp,
- lineHeight = 12.sp,
- maxLines = 2,
- overflow = TextOverflow.Ellipsis
- )
- }
- NovaBadge(text = modeLabel, color = if (warning) colors.warning else colors.textSecondary)
- }
- }
+ value = modeLabel,
+ )
}
@Composable
@@ -1730,61 +1472,44 @@ private fun MangoHudPassiveStatus(
caption: String,
warning: Boolean
) {
- val colors = LocalNovaComposeColors.current
- val surfaces = LocalNovaLibrarySurfaces.current
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(start = 14.dp, end = 14.dp, top = 8.dp)
- .semantics { contentDescription = "$label. $caption" },
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(8.dp)
- ) {
- NovaBadge(
- text = label,
- color = if (warning) colors.warning else colors.textSecondary,
- backgroundColor = surfaces.control.copy(alpha = 0.56f * LocalNovaMenuOpacityScale.current),
- borderColor = if (warning) colors.warning.copy(alpha = 0.44f) else surfaces.tileBorder,
- fontSize = 10.sp,
- contentPadding = PaddingValues(horizontal = 9.dp, vertical = 4.dp)
- )
- Text(
- text = caption,
- modifier = Modifier.weight(1f),
- color = colors.textMuted,
- fontSize = 10.sp,
- lineHeight = 13.sp,
- maxLines = 2,
- overflow = TextOverflow.Ellipsis
- )
- }
+ // A readout, not an action: same row, no chevron to imply otherwise.
+ NovaSteamChoiceRow(
+ label = label,
+ caption = caption,
+ enabled = !warning,
+ )
}
@Composable
private fun InsightCard(card: NovaGameDetailInsightCard) {
val colors = LocalNovaComposeColors.current
- NovaDetailPanel(
+ Box(
modifier = Modifier
.fillMaxWidth()
- .padding(start = 14.dp, end = 14.dp, top = 10.dp),
- accent = !card.isWarning,
- warning = card.isWarning,
- contentPadding = PaddingValues(12.dp)
+ .padding(horizontal = NovaGameDetailInset)
+ .padding(top = 12.dp, bottom = 2.dp),
) {
Column {
- Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(
+ text = card.label,
+ color = if (card.isWarning) colors.warning else colors.accent,
+ fontSize = if (card.isWarning) 13.sp else 14.sp,
+ fontWeight = FontWeight.SemiBold,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ if (card.source.isNotBlank()) {
+ // Six facts joined by separators is a metadata line, not a tag; in a chip
+ // it could only ellipsise, so it wraps under the title instead.
Text(
- text = card.label,
- color = if (card.isWarning) colors.warning else colors.accent,
- fontSize = if (card.isWarning) 13.sp else 14.sp,
- fontWeight = FontWeight.SemiBold,
- maxLines = 1,
+ text = card.source,
+ modifier = Modifier.padding(top = 2.dp),
+ color = colors.textMuted,
+ fontSize = 10.sp,
+ lineHeight = 13.sp,
+ maxLines = 2,
overflow = TextOverflow.Ellipsis
)
- if (card.source.isNotBlank()) {
- Spacer(modifier = Modifier.width(8.dp))
- NovaBadge(text = card.source, color = colors.textMuted)
- }
}
Text(
text = card.settings,
@@ -1803,7 +1528,7 @@ private fun InsightCard(card: NovaGameDetailInsightCard) {
color = colors.textMuted,
fontSize = 10.sp,
lineHeight = 13.sp,
- maxLines = 3,
+ maxLines = 5,
overflow = TextOverflow.Ellipsis
)
}
diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt
new file mode 100644
index 00000000..5a84c9fd
--- /dev/null
+++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt
@@ -0,0 +1,613 @@
+package com.papi.nova.ui
+
+import androidx.compose.foundation.ScrollState
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.focusGroup
+import androidx.compose.foundation.focusable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.WindowInsetsSides
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.only
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.safeContent
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.windowInsetsPadding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.LocalTextStyle
+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.setValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.drawBehind
+import androidx.compose.ui.draw.drawWithContent
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.BlendMode
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.composed
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.graphics.CompositingStrategy
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.graphics.luminance
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.em
+import androidx.compose.ui.unit.sp
+import kotlinx.coroutines.delay
+import com.papi.nova.R
+import com.papi.nova.ui.compose.LocalNovaComposeColors
+import com.papi.nova.ui.compose.LocalNovaLibrarySurfaces
+import com.papi.nova.ui.compose.NovaControllerHint
+import com.papi.nova.ui.compose.NovaControllerHintBar
+
+/** The three ways a launch can go when Polaris reports desktop Steam active. */
+internal enum class NovaSteamLaunchChoice {
+ PRIVATE_STREAM,
+ MIRROR_DESKTOP,
+ CLOSE_STEAM_THEN_PRIVATE,
+}
+
+/**
+ * A drill-in that sits beside the game rather than on top of it. The header is pinned and
+ * the body scrolls, so focus drives the scroll rather than the reverse.
+ */
+@Composable
+internal fun NovaGameDetailPanel(
+ eyebrow: String,
+ headline: String,
+ readout: String,
+ scrollState: ScrollState,
+ onDismiss: () -> Unit,
+ content: @Composable () -> Unit,
+) {
+ val colors = LocalNovaComposeColors.current
+ val surfaces = LocalNovaLibrarySurfaces.current
+
+ BoxWithConstraints(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(NovaGameDetailScrim.copy(alpha = NOVA_DETAIL_SCRIM_ALPHA))
+ // The dimmed area beside the panel is the game you came from, so tapping it
+ // is the same gesture as pressing back.
+ .novaDismissOnTap(onDismiss)
+ .testTag("nova-game-detail-scrim"),
+ ) {
+ // The panel exists so the game stays visible beside what you are changing. In
+ // portrait there is nothing to sit beside, so it takes the window instead of
+ // squeezing a phone-width column inside a phone.
+ val widthFraction = if (maxHeight > maxWidth) 1f else NOVA_DETAIL_PANEL_WIDTH_FRACTION
+ val shortViewport = maxHeight < NOVA_DETAIL_SHORT_VIEWPORT
+ Column(
+ modifier = Modifier
+ .align(Alignment.CenterEnd)
+ .fillMaxHeight()
+ .fillMaxWidth(widthFraction)
+ // Translucent, so the game reads underneath and the panel is a layer over
+ // it rather than another screen. Separation comes from the outside scrim.
+ .background(colors.window.copy(alpha = NOVA_DETAIL_PANEL_ALPHA))
+ .background(surfaces.panel)
+ // Taps inside the panel are not taps outside it.
+ .novaDismissOnTap {}
+ // Vertical only. A cutout must not eat text, but it need not stop a row
+ // background from reaching the edge it is drawn against.
+ .windowInsetsPadding(WindowInsets.safeContent.only(WindowInsetsSides.Vertical))
+ .padding(vertical = if (shortViewport) 10.dp else 20.dp)
+ .testTag("nova-game-detail-panel"),
+ ) {
+ NovaGameDetailDestinationHeader(
+ eyebrow = eyebrow,
+ headline = headline,
+ readout = readout,
+ compact = shortViewport,
+ onDismiss = onDismiss,
+ )
+ Column(
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxWidth()
+ .novaFadeAtCut()
+ .novaHoldsFirstFocus()
+ .verticalScroll(scrollState),
+ content = { content() },
+ )
+ NovaGameDetailDestinationHints()
+ }
+ }
+}
+
+/**
+ * Takes focus for its first focusable child once it has been laid out. Without this a
+ * destination opens with focus left behind on the Overview, so the d-pad has nothing to
+ * move. The delay is the same one the library uses: the request only lands after layout.
+ */
+@Composable
+private fun Modifier.novaHoldsFirstFocus(): Modifier {
+ val requester = remember { FocusRequester() }
+ LaunchedEffect(Unit) {
+ delay(NOVA_DETAIL_FOCUS_SETTLE_MS)
+ runCatching { requester.requestFocus() }
+ }
+ return focusRequester(requester).focusGroup()
+}
+
+/**
+ * Dissolves the last band of a scrolling body, so what passes under the hint bar reads
+ * as continuing rather than as clipped. It erases content alpha instead of painting a
+ * ground: the panel is translucent, and a solid band would stripe window colour across
+ * the artwork showing through it.
+ */
+private fun Modifier.novaFadeAtCut(): Modifier = this
+ .graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen }
+ .drawWithContent {
+ drawContent()
+ val fade = NOVA_DETAIL_BOTTOM_FADE.toPx().coerceAtMost(size.height)
+ drawRect(
+ brush = Brush.verticalGradient(
+ colors = listOf(Color.Black, Color.Transparent),
+ startY = size.height - fade,
+ endY = size.height,
+ ),
+ topLeft = Offset(0f, size.height - fade),
+ size = Size(size.width, fade),
+ blendMode = BlendMode.DstIn,
+ )
+ }
+
+/** A tap target that swallows the gesture, with no ripple to imply a button. */
+private fun Modifier.novaDismissOnTap(onDismiss: () -> Unit): Modifier = composed {
+ clickable(
+ interactionSource = remember { MutableInteractionSource() },
+ indication = null,
+ onClick = onDismiss,
+ )
+}
+
+/**
+ * A drill-in that needs the window. Used by Artwork, whose studio lays itself out as a
+ * Row of weighted Columns and cannot fold into a panel.
+ */
+@Composable
+internal fun NovaGameDetailFullScreen(
+ eyebrow: String,
+ headline: String,
+ scrollState: ScrollState,
+ onDismiss: () -> Unit,
+ content: @Composable () -> Unit,
+) {
+ val colors = LocalNovaComposeColors.current
+ val surfaces = LocalNovaLibrarySurfaces.current
+ BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
+ val shortViewport = maxHeight < NOVA_DETAIL_SHORT_VIEWPORT
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ // Solid: there is no outside here, so translucency would only print the
+ // Overview through the studio rather than reveal anything new.
+ .background(colors.window)
+ .background(surfaces.panel)
+ .windowInsetsPadding(WindowInsets.safeContent)
+ .padding(
+ horizontal = NovaGameDetailInset,
+ vertical = if (shortViewport) 10.dp else 20.dp,
+ )
+ .testTag("nova-game-detail-fullscreen"),
+ ) {
+ NovaGameDetailDestinationHeader(
+ eyebrow = eyebrow,
+ headline = headline,
+ readout = "",
+ compact = shortViewport,
+ onDismiss = onDismiss,
+ )
+ Column(
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxWidth()
+ .novaFadeAtCut()
+ .novaHoldsFirstFocus()
+ .verticalScroll(scrollState),
+ content = { content() },
+ )
+ NovaGameDetailDestinationHints()
+ }
+ }
+}
+
+/** Every destination says how to act and how to get back. */
+@Composable
+private fun NovaGameDetailDestinationHints() {
+ NovaControllerHintBar(
+ hints = listOf(
+ NovaControllerHint(
+ key = stringResource(R.string.nova_controller_hint_a),
+ label = stringResource(R.string.nova_controller_hint_select),
+ ),
+ NovaControllerHint(
+ key = stringResource(R.string.nova_controller_hint_b),
+ label = stringResource(R.string.nova_controller_hint_back),
+ ),
+ ),
+ compact = true,
+ modifier = Modifier
+ .fillMaxWidth()
+ .windowInsetsPadding(WindowInsets.safeContent.only(WindowInsetsSides.Horizontal))
+ .padding(horizontal = NovaGameDetailInset)
+ .padding(top = 10.dp),
+ )
+}
+
+@Composable
+private fun NovaGameDetailDestinationHeader(
+ eyebrow: String,
+ headline: String,
+ readout: String,
+ compact: Boolean = false,
+ onDismiss: () -> Unit = {},
+) {
+ val colors = LocalNovaComposeColors.current
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier
+ .fillMaxWidth()
+ .windowInsetsPadding(WindowInsets.safeContent.only(WindowInsetsSides.Horizontal))
+ .padding(horizontal = NovaGameDetailInset)
+ .padding(bottom = if (compact) 6.dp else 14.dp),
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ if (!compact) {
+ Text(
+ text = eyebrow,
+ color = colors.textMuted,
+ fontSize = 10.sp,
+ fontWeight = FontWeight.Bold,
+ letterSpacing = 0.22.em,
+ )
+ }
+ Text(
+ text = if (compact) "$eyebrow · $headline" else headline,
+ color = colors.textPrimary,
+ fontSize = if (compact) 17.sp else 27.sp,
+ fontWeight = FontWeight.Bold,
+ maxLines = if (compact) 1 else 2,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.padding(top = if (compact) 0.dp else 3.dp),
+ )
+ if (readout.isNotBlank() && !compact) {
+ Text(
+ text = readout,
+ color = colors.textSecondary,
+ fontSize = 11.sp,
+ letterSpacing = 0.10.em,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.padding(top = 5.dp),
+ )
+ }
+ }
+ // Portrait and the studio have no outside to tap, so the way out is always here.
+ NovaGameDetailCloseControl(onDismiss)
+ }
+}
+
+/** The touch equivalent of back, for the destinations that fill the window. */
+@Composable
+private fun NovaGameDetailCloseControl(onDismiss: () -> Unit) {
+ val colors = LocalNovaComposeColors.current
+ val surfaces = LocalNovaLibrarySurfaces.current
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier
+ .padding(start = 12.dp)
+ .clip(RoundedCornerShape(NovaGameDetailCornerRadius))
+ .background(surfaces.control)
+ .border(1.dp, colors.divider.copy(alpha = 0.6f), RoundedCornerShape(NovaGameDetailCornerRadius))
+ .novaDismissOnTap(onDismiss)
+ .padding(horizontal = 12.dp, vertical = 7.dp)
+ .testTag("nova-game-detail-close"),
+ ) {
+ Text(
+ text = stringResource(R.string.nova_game_detail_close),
+ color = colors.textSecondary,
+ fontSize = 11.sp,
+ fontWeight = FontWeight.SemiBold,
+ )
+ }
+}
+
+/**
+ * Divides what you read from what you do. The sheet presented both as one list, so a
+ * readout like "MangoHUD: On" sat in the same shape as "Reset profile" — one is a
+ * statement, the other has consequences.
+ */
+@Composable
+internal fun NovaGameDetailGroupLabel(text: String) {
+ val colors = LocalNovaComposeColors.current
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(10.dp),
+ modifier = Modifier
+ .fillMaxWidth()
+ .windowInsetsPadding(WindowInsets.safeContent.only(WindowInsetsSides.Horizontal))
+ .padding(horizontal = NovaGameDetailInset)
+ .padding(top = 16.dp, bottom = 6.dp),
+ ) {
+ Text(
+ text = text.uppercase(),
+ color = colors.textMuted,
+ fontSize = 8.sp,
+ fontWeight = FontWeight.Bold,
+ letterSpacing = 0.22.em,
+ )
+ Box(
+ modifier = Modifier
+ .weight(1f)
+ .height(1.dp)
+ .background(colors.divider.copy(alpha = 0.5f)),
+ )
+ }
+}
+
+/** Retry and reset: the two things in Tune that change something rather than report it. */
+@Composable
+internal fun LaunchProfileSummaryActions(
+ summary: NovaLaunchProfileSummary?,
+ resetProfileLabel: String,
+ resetProfileWorking: Boolean,
+ onRetryHighFps: () -> Unit,
+ onResetProfile: () -> Unit,
+) {
+ Column(
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ if (summary?.showRetryHighFps == true) {
+ NovaSteamChoiceRow(
+ label = summary.retryHighFpsLabel.ifBlank {
+ stringResource(R.string.nova_library_retry_high_fps)
+ },
+ caption = "",
+ enabled = true,
+ onClick = onRetryHighFps,
+ )
+ }
+ NovaSteamChoiceRow(
+ label = resetProfileLabel,
+ caption = "",
+ enabled = !resetProfileWorking,
+ onClick = onResetProfile,
+ )
+ }
+}
+
+/**
+ * The desktop-Steam choice, as rows in Launch mode rather than a sheet over the artwork.
+ * A blocked option stays visible and inert: hiding it loses the reason it is blocked,
+ * which is the part worth reading.
+ */
+@Composable
+internal fun NovaDesktopSteamLaunchDecisionRows(
+ decision: NovaDesktopSteamLaunchDecision,
+ onChoice: (NovaSteamLaunchChoice) -> Unit,
+) {
+ val colors = LocalNovaComposeColors.current
+
+ Column(
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ modifier = Modifier.fillMaxWidth().testTag("nova-game-detail-steam-decision"),
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(NovaGameDetailCornerRadius))
+ .background(colors.warning.copy(alpha = 0.13f))
+ .border(
+ 1.dp,
+ colors.warning.copy(alpha = 0.46f),
+ RoundedCornerShape(NovaGameDetailCornerRadius),
+ )
+ .padding(horizontal = 12.dp, vertical = 10.dp),
+ ) {
+ Text(
+ text = stringResource(R.string.nova_desktop_steam_title),
+ color = colors.textPrimary,
+ fontSize = 12.sp,
+ fontWeight = FontWeight.SemiBold,
+ )
+ Text(
+ text = decision.reason.ifBlank {
+ stringResource(R.string.nova_desktop_steam_message)
+ },
+ color = colors.textSecondary,
+ fontSize = 11.sp,
+ lineHeight = 15.sp,
+ modifier = Modifier.padding(top = 4.dp),
+ )
+ }
+
+ NovaSteamChoiceRow(
+ label = stringResource(R.string.nova_desktop_steam_private_stream),
+ caption = decision.privateStreamUnavailableReason,
+ enabled = decision.privateStreamEnabled,
+ onClick = { onChoice(NovaSteamLaunchChoice.PRIVATE_STREAM) },
+ )
+ if (decision.forcePrivateAfterSteamCloseEnabled) {
+ NovaSteamChoiceRow(
+ label = decision.forcePrivateAfterSteamCloseLabel.ifBlank {
+ stringResource(R.string.nova_desktop_steam_force_private)
+ },
+ caption = stringResource(R.string.nova_desktop_steam_force_private_caption),
+ enabled = true,
+ onClick = { onChoice(NovaSteamLaunchChoice.CLOSE_STEAM_THEN_PRIVATE) },
+ )
+ }
+ NovaSteamChoiceRow(
+ label = stringResource(R.string.nova_desktop_steam_mirror_desktop),
+ caption = stringResource(R.string.nova_desktop_steam_mirror_caption),
+ enabled = decision.mirrorDesktopEnabled,
+ onClick = { onChoice(NovaSteamLaunchChoice.MIRROR_DESKTOP) },
+ )
+ }
+}
+
+/**
+ * One row of a drawer. Full bleed rather than a card: the panel already supplies the
+ * inset, so a rounded box inside it only adds air. The value sits at the right in
+ * tabular figures, and focus is an inset bar and a tint, so the row never moves.
+ */
+@Composable
+internal fun NovaSteamChoiceRow(
+ label: String,
+ caption: String,
+ enabled: Boolean,
+ onClick: (() -> Unit)? = null,
+ value: String = "",
+) {
+ val colors = LocalNovaComposeColors.current
+ var focused by remember { mutableStateOf(false) }
+ val actionable = onClick != null && enabled
+ val accentBar = colors.accent
+ val hairline = colors.divider.copy(alpha = 0.45f)
+ val barWidth = NOVA_DETAIL_ROW_FOCUS_BAR
+ // The accent is light on a dark surface and dark on a light one, so the same alpha
+ // is a whisper in one theme and an inverted block in the other. Scale it by the
+ // polarity; the bar, not the fill, is what says this row has focus.
+ //
+ // Polarity comes from the text rather than colors.window, because under Portable
+ // Chrome the panel takes its lightness from surfaces.panel layered over the window,
+ // so the window is the wrong ground to ask and the tint stayed at full strength.
+ val tint = colors.accent.copy(
+ alpha = if (colors.textPrimary.luminance() < 0.5f) 0.07f else 0.16f,
+ )
+
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier
+ .fillMaxWidth()
+ .heightIn(min = NOVA_DETAIL_ROW_MIN_HEIGHT)
+ .onFocusChanged { focused = it.isFocused || it.hasFocus }
+ .then(
+ if (actionable) {
+ Modifier.clickable(role = Role.Button) { onClick?.invoke() }
+ } else {
+ Modifier
+ }
+ )
+ // Explicit, like every other focusable in the app: clickable alone did not
+ // register the row as a focus target and the d-pad had nothing to reach.
+ .focusable(enabled = actionable)
+ .background(if (focused && actionable) tint else Color.Transparent)
+ .drawBehind {
+ if (focused && actionable) {
+ drawRect(color = accentBar, size = Size(barWidth.toPx(), size.height))
+ }
+ drawRect(
+ color = hairline,
+ topLeft = Offset(0f, size.height - 1f),
+ size = Size(size.width, 1f),
+ )
+ }
+ .windowInsetsPadding(WindowInsets.safeContent.only(WindowInsetsSides.Horizontal))
+ .padding(start = NovaGameDetailInset, end = NovaGameDetailInset, top = 9.dp, bottom = 9.dp)
+ .semantics { contentDescription = if (value.isBlank()) label else "$label. $value" },
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = label,
+ color = if (enabled) colors.textPrimary else colors.textMuted,
+ fontSize = 15.sp,
+ fontWeight = FontWeight.SemiBold,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ if (caption.isNotBlank()) {
+ Text(
+ text = caption,
+ color = colors.textMuted,
+ fontSize = 11.sp,
+ lineHeight = 14.sp,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.padding(top = 2.dp),
+ )
+ }
+ }
+ if (value.isNotBlank()) {
+ Text(
+ text = value,
+ color = if (enabled) colors.textSecondary else colors.textMuted,
+ fontSize = 15.sp,
+ fontWeight = FontWeight.Medium,
+ // a value read against other values, so the digits line up
+ style = LocalTextStyle.current.copy(fontFeatureSettings = "tnum"),
+ maxLines = 1,
+ modifier = Modifier.padding(start = 12.dp),
+ )
+ }
+ if (actionable) {
+ Text(
+ text = "\u203a",
+ color = colors.textMuted,
+ fontSize = 17.sp,
+ modifier = Modifier.padding(start = 10.dp, end = 4.dp),
+ )
+ }
+ }
+}
+
+/** 438dp of an 832dp landscape shell, as drawn. */
+private const val NOVA_DETAIL_PANEL_WIDTH_FRACTION = 0.53f
+
+/** Every row is at least a full action's worth of height. */
+private val NOVA_DETAIL_ROW_MIN_HEIGHT = 48.dp
+
+/** The focused row grows a bar at its edge instead of a border that moves it. */
+private val NOVA_DETAIL_ROW_FOCUS_BAR = 3.dp
+
+/** The body dissolves over this much before the hint bar, marking the cut. */
+private val NOVA_DETAIL_BOTTOM_FADE = 52.dp
+
+/**
+ * A scrim is a shadow, not a surface, so it does not follow the theme. Painting it in
+ * the window colour turned into a white veil under Portable Chrome.
+ */
+private val NovaGameDetailScrim = Color.Black
+
+/** Enough of the game stays visible for the panel to read as a layer over it. */
+private const val NOVA_DETAIL_SCRIM_ALPHA = 0.58f
+
+/** Translucent enough to show artwork, opaque enough to keep body text legible. */
+private const val NOVA_DETAIL_PANEL_ALPHA = 0.80f
+
+/** Long enough for the body to be laid out, so the focus request has a target. */
+private const val NOVA_DETAIL_FOCUS_SETTLE_MS = 75L
+
+/** Below this a phone in landscape has no height to spare for chrome. */
+private val NOVA_DETAIL_SHORT_VIEWPORT = 500.dp
diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt
new file mode 100644
index 00000000..e67b641d
--- /dev/null
+++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt
@@ -0,0 +1,882 @@
+package com.papi.nova.ui
+
+import android.widget.ImageView
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.focusable
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxWithConstraints
+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.WindowInsets
+import androidx.compose.foundation.layout.WindowInsetsSides
+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.heightIn
+import androidx.compose.foundation.layout.only
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.safeContent
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.sizeIn
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.windowInsetsPadding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.LocalTextStyle
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.key
+import androidx.compose.ui.platform.LocalUriHandler
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.drawWithContent
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.CornerRadius
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.graphics.lerp
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.em
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.viewinterop.AndroidView
+import com.papi.nova.R
+import com.papi.nova.api.PolarisApiClient
+import com.papi.nova.shared.polaris.model.PolarisGame
+import com.papi.nova.ui.compose.LocalNovaComposeColors
+import com.papi.nova.ui.compose.LocalNovaLibrarySurfaces
+import com.papi.nova.ui.compose.NovaActionButton
+import com.papi.nova.ui.compose.NovaControllerHint
+import com.papi.nova.ui.compose.NovaControllerHintBar
+
+/** Where the detail window currently is. Back unwinds one level before leaving. */
+internal enum class NovaGameDetailDestination { OVERVIEW, LAUNCH_MODE, TUNE, ARTWORK }
+
+/** Content insets shared by the Overview and the destinations that sit beside it. */
+internal val NovaGameDetailInset = 28.dp
+internal val NovaGameDetailFloor = 58.dp
+
+/** Every focusable control clears the accessible target floor. */
+internal val NovaGameDetailActionHeight = 48.dp
+
+/** Matches the library's surface radius; the sharp edge was a deliberate choice there. */
+internal val NovaGameDetailCornerRadius = 8.dp
+
+/**
+ * The landing screen of the detail window.
+ *
+ * The artwork is the subject and nothing sits on it in a card: the content block rests on
+ * the scrim floor in one reading order — who the game is, a hairline, what pressing the
+ * primary action will do, then what you can do. Layout is complete before the artwork
+ * arrives, so a slow or failed load changes the backdrop and moves nothing.
+ */
+@Composable
+internal fun NovaGameDetailOverview(
+ uiState: NovaGameDetailUiState,
+ apiClient: PolarisApiClient,
+ playLabel: String,
+ lastPlayedText: String?,
+ sourceLabel: String,
+ optimizationState: NovaGameDetailOptimizationState,
+ reviewExpanded: Boolean,
+ showLaunchModeAction: Boolean,
+ logoAvailable: Boolean,
+ logoPresentationKey: String,
+ logoLoader: (ImageView) -> Unit,
+ logoContentDescription: String,
+ playFocusRequester: FocusRequester,
+ onPrimaryLaunch: () -> Unit,
+ onRetryHighFps: () -> Unit,
+ onResetProfile: () -> Unit,
+ onDestination: (NovaGameDetailDestination) -> Unit,
+ activeSession: NovaLibraryActiveSessionUiState?,
+ onResumeSession: () -> Unit,
+ onEndSession: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val colors = LocalNovaComposeColors.current
+ val game = uiState.game
+
+ BoxWithConstraints(modifier = modifier.fillMaxSize().testTag("nova-game-detail-overview")) {
+ val portrait = maxHeight > maxWidth
+
+ if (portrait) {
+ // A 3:1 hero cropped to a phone's aspect shows a slice, not a subject, so it
+ // gets a 16:9 band and dissolves into the ground rather than filling behind.
+ NovaLibraryCinematicBackdrop(
+ game = game,
+ apiClient = apiClient,
+ strength = 1f,
+ modifier = Modifier
+ .align(Alignment.TopStart)
+ .fillMaxWidth()
+ .aspectRatio(16f / 9f)
+ .novaFadeToGround(colors.window),
+ )
+ } else {
+ NovaLibraryCinematicBackdrop(game = game, apiClient = apiClient, strength = 1f)
+ }
+
+ Column(
+ modifier = Modifier
+ .align(if (portrait) Alignment.TopStart else Alignment.BottomStart)
+ .fillMaxWidth()
+ .then(if (portrait) Modifier.padding(top = 176.dp) else Modifier)
+ .windowInsetsPadding(WindowInsets.safeContent)
+ .padding(start = NovaGameDetailInset, end = NovaGameDetailInset, bottom = 10.dp),
+ ) {
+ NovaGameDetailTitle(
+ game = game,
+ logoAvailable = logoAvailable,
+ logoPresentationKey = logoPresentationKey,
+ logoLoader = logoLoader,
+ logoContentDescription = logoContentDescription,
+ )
+
+ Text(
+ text = novaGameDetailIdentityLine(sourceLabel, lastPlayedText, game).uppercase(),
+ color = colors.textSecondary,
+ fontSize = 11.sp,
+ fontWeight = FontWeight.SemiBold,
+ letterSpacing = 0.17.em,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.padding(top = 11.dp),
+ )
+
+ // The hairline divides names from numbers: identity above, machine state below.
+ Box(
+ modifier = Modifier
+ .padding(top = 11.dp)
+ .width(330.dp)
+ .height(1.dp)
+ .background(
+ Brush.horizontalGradient(
+ colorStops = arrayOf(
+ 0.0f to colors.accent,
+ 0.30f to colors.accent.copy(alpha = 0.44f),
+ 0.62f to colors.textMuted.copy(alpha = 0.16f),
+ 1.0f to Color.Transparent,
+ ),
+ ),
+ ),
+ )
+
+ // A duration is a number, so it sits below the hairline with the rest of them.
+ // The concept's gauge needs How Long To Beat to have something to measure
+ // against; until that exists it draws the played figure alone, and a game no
+ // launcher owns draws nothing rather than claiming nobody has played it.
+ NovaGameDetailBeatGauge(
+ gameName = uiState.game.name,
+ playTime = uiState.game.playTime,
+ beatTime = uiState.game.beatTime,
+ )
+
+ NovaGameDetailStatusLine(
+ uiState = uiState,
+ optimizationState = optimizationState,
+ modifier = Modifier.padding(top = 11.dp),
+ )
+
+ if (reviewExpanded) {
+ LaunchProfileReviewNotice(
+ optimizationState = optimizationState,
+ modifier = Modifier.padding(top = 10.dp),
+ )
+ }
+
+ NovaGameDetailActions(
+ stacked = portrait,
+ uiState = uiState,
+ optimizationState = optimizationState,
+ playLabel = playLabel,
+ reviewExpanded = reviewExpanded,
+ showLaunchModeAction = showLaunchModeAction,
+ playFocusRequester = playFocusRequester,
+ onPrimaryLaunch = onPrimaryLaunch,
+ onRetryHighFps = onRetryHighFps,
+ onResetProfile = onResetProfile,
+ onDestination = onDestination,
+ activeSession = activeSession,
+ onResumeSession = onResumeSession,
+ onEndSession = onEndSession,
+ modifier = Modifier.padding(top = 16.dp),
+ )
+
+ if (!portrait) {
+ NovaGameDetailFooter(modifier = Modifier.fillMaxWidth().padding(top = 14.dp))
+ }
+ }
+
+ // On a tall screen the floor belongs at the bottom, not trailing the actions.
+ if (portrait) {
+ NovaGameDetailFooter(
+ modifier = Modifier
+ .align(Alignment.BottomStart)
+ .fillMaxWidth()
+ .windowInsetsPadding(WindowInsets.safeContent)
+ .padding(horizontal = NovaGameDetailInset, vertical = 10.dp),
+ )
+ }
+
+ }
+}
+
+/**
+ * The floor: what the buttons do on the left, the Polaris mark on the right. Borderless,
+ * because a bordered container here would be one more box on a screen whose point is that
+ * nothing sits on the artwork in a box.
+ */
+@Composable
+private fun NovaGameDetailFooter(modifier: Modifier = Modifier) {
+ val colors = LocalNovaComposeColors.current
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = modifier.testTag("nova-game-detail-footer"),
+ ) {
+ novaGameDetailOverviewHints().forEach { hint ->
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(6.dp),
+ modifier = Modifier.padding(end = 18.dp),
+ ) {
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier
+ .size(20.dp)
+ .clip(RoundedCornerShape(percent = 50))
+ .background(colors.accent.copy(alpha = 0.22f)),
+ ) {
+ Text(hint.key, color = colors.textPrimary, fontSize = 9.sp, fontWeight = FontWeight.Bold)
+ }
+ Text(hint.label, color = colors.textSecondary, fontSize = 11.sp)
+ }
+ }
+ Spacer(modifier = Modifier.weight(1f))
+ Text(
+ text = stringResource(R.string.nova_polaris_wordmark),
+ color = colors.textMuted,
+ fontSize = 9.sp,
+ fontWeight = FontWeight.Bold,
+ letterSpacing = 0.20.em,
+ )
+ }
+}
+
+/**
+ * Curated logo artwork replaces the title outright when it is ready at first composition.
+ * A logo that arrives later is ignored: swapping a settled title for one is a visible jump,
+ * and the title is never wrong.
+ */
+@Composable
+private fun NovaGameDetailTitle(
+ game: PolarisGame,
+ logoAvailable: Boolean,
+ logoPresentationKey: String,
+ logoLoader: (ImageView) -> Unit,
+ logoContentDescription: String,
+) {
+ if (logoAvailable) {
+ key(logoPresentationKey) {
+ AndroidView(
+ factory = { context ->
+ ImageView(context).apply {
+ scaleType = ImageView.ScaleType.FIT_START
+ contentDescription = logoContentDescription
+ logoLoader(this)
+ }
+ },
+ modifier = Modifier
+ .sizeIn(maxWidth = 200.dp, maxHeight = 64.dp)
+ .semantics { contentDescription = logoContentDescription }
+ .testTag("nova-game-detail-logo"),
+ )
+ }
+ } else {
+ Text(
+ text = game.name,
+ color = LocalNovaComposeColors.current.textPrimary,
+ fontSize = 38.sp,
+ fontWeight = FontWeight.Bold,
+ letterSpacing = (-0.03).em,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.testTag("nova-game-detail-title"),
+ )
+ }
+}
+
+/**
+ * What pressing the primary action will do, read as an instrument line rather than a chip:
+ * an indicator lamp, then mode and profile set with tabular figures.
+ */
+@Composable
+private fun NovaGameDetailStatusLine(
+ uiState: NovaGameDetailUiState,
+ optimizationState: NovaGameDetailOptimizationState,
+ modifier: Modifier = Modifier,
+) {
+ val colors = LocalNovaComposeColors.current
+ val summary = optimizationState.profileSummary
+ val limited = optimizationState.reviewRequired ||
+ summary?.noticeTone == NovaLaunchProfileNoticeTone.WARNING
+ val lamp = when {
+ limited -> colors.warning
+ summary == null -> colors.textMuted
+ else -> colors.accent
+ }
+
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(9.dp),
+ modifier = modifier.testTag("nova-game-detail-status"),
+ ) {
+ Box(modifier = Modifier.size(7.dp).clip(RoundedCornerShape(percent = 50)).background(lamp))
+ Text(
+ text = novaGameDetailStatusText(uiState, summary).uppercase(),
+ color = colors.textPrimary,
+ fontSize = 11.sp,
+ fontWeight = FontWeight.SemiBold,
+ letterSpacing = 0.11.em,
+ // these are measurements, so the digits line up rather than dance
+ style = LocalTextStyle.current.copy(
+ fontFeatureSettings = "tnum",
+ ),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+}
+
+/**
+ * The action lane. Play holds first focus; the rail is gated so it never grows a node that
+ * leads nowhere. While a review is expanded the lane becomes the review's own choices —
+ * the three buttons of the alert this replaces.
+ */
+@Composable
+private fun NovaGameDetailActions(
+ stacked: Boolean,
+ uiState: NovaGameDetailUiState,
+ optimizationState: NovaGameDetailOptimizationState,
+ playLabel: String,
+ reviewExpanded: Boolean,
+ showLaunchModeAction: Boolean,
+ playFocusRequester: FocusRequester,
+ onPrimaryLaunch: () -> Unit,
+ onRetryHighFps: () -> Unit,
+ onResetProfile: () -> Unit,
+ onDestination: (NovaGameDetailDestination) -> Unit,
+ activeSession: NovaLibraryActiveSessionUiState?,
+ onResumeSession: () -> Unit,
+ onEndSession: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val lane: @Composable (@Composable () -> Unit) -> Unit = { content ->
+ if (stacked) {
+ Column(
+ verticalArrangement = Arrangement.spacedBy(10.dp),
+ modifier = modifier.fillMaxWidth(),
+ content = { content() },
+ )
+ } else {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(10.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = modifier,
+ content = { content() },
+ )
+ }
+ }
+
+ // Stacked buttons sized to their own labels look ragged; in a column they share a width.
+ val itemWidth: Modifier = if (stacked) Modifier.fillMaxWidth() else Modifier
+
+ lane {
+ // Precedence: someone else's session, then yours, then the ordinary launch.
+ // Launching over a session another device owns would take their display.
+ NovaGameDetailAction(
+ text = when {
+ activeSession?.watchOnly == true -> stringResource(R.string.nova_game_detail_watch)
+ activeSession != null -> stringResource(R.string.nova_game_detail_resume)
+ else -> playLabel
+ },
+ onClick = if (activeSession != null) onResumeSession else onPrimaryLaunch,
+ enabled = uiState.playEnabled || activeSession != null,
+ primary = activeSession?.watchOnly != true,
+ glyph = stringResource(R.string.nova_controller_hint_a),
+ modifier = itemWidth
+ .focusRequester(playFocusRequester)
+ .testTag("nova-game-detail-primary"),
+ )
+
+ if (activeSession != null && !activeSession.watchOnly) {
+ NovaGameDetailAction(
+ text = stringResource(R.string.nova_game_detail_end_session),
+ onClick = onEndSession,
+ mark = "◼",
+ modifier = itemWidth,
+ )
+ }
+
+ if (reviewExpanded) {
+ if (optimizationState.profileSummary?.showRetryHighFps == true) {
+ NovaGameDetailAction(
+ text = stringResource(R.string.nova_library_retry_high_fps),
+ onClick = onRetryHighFps,
+ mark = "\u25B2",
+ modifier = itemWidth,
+ )
+ }
+ NovaGameDetailAction(
+ text = stringResource(R.string.nova_library_reset_game_profile),
+ onClick = onResetProfile,
+ mark = "\u21BA",
+ modifier = itemWidth,
+ )
+ } else {
+ if (showLaunchModeAction) {
+ NovaGameDetailAction(
+ text = stringResource(R.string.nova_library_launch_mode_title),
+ onClick = { onDestination(NovaGameDetailDestination.LAUNCH_MODE) },
+ mark = "\u229E",
+ modifier = itemWidth,
+ )
+ }
+ NovaGameDetailAction(
+ text = stringResource(R.string.nova_game_detail_tune),
+ onClick = { onDestination(NovaGameDetailDestination.TUNE) },
+ mark = "\u2699",
+ modifier = itemWidth,
+ )
+ NovaGameDetailAction(
+ text = stringResource(R.string.nova_artwork_studio_title),
+ onClick = { onDestination(NovaGameDetailDestination.ARTWORK) },
+ mark = "\u25C8",
+ modifier = itemWidth,
+ )
+ }
+ }
+}
+
+/**
+ * How long this has been played, against how long it takes.
+ *
+ * Drawn as the concept draws it: the played figure bright at the left, the three
+ * estimates dimmer at the right and labelled so a number means something, and a bar
+ * whose full width is the completionist figure with the other two cut through it as
+ * notches — ground-coloured with a light ring, overhanging top and bottom, so they read
+ * as gaps in the bar rather than marks on top of it.
+ *
+ * The partial cases are the concept's own answers, because a gauge that guesses is worse
+ * than one that says less:
+ *
+ * - both bar, notches, figure and estimates
+ * - played only the figure alone; there is nothing to measure it against
+ * - estimate only "Not started" over an empty track
+ * - neither nothing, and the hairline above still separates identity from state
+ * - past the end the bar caps and the figure keeps counting, which is the true number
+ */
+@Composable
+private fun NovaGameDetailBeatGauge(
+ gameName: String,
+ playTime: PolarisGame.PlayTime?,
+ beatTime: PolarisGame.BeatTime?,
+) {
+ val colors = LocalNovaComposeColors.current
+ val surfaces = LocalNovaLibrarySurfaces.current
+ val uriHandler = LocalUriHandler.current
+
+ val playedSeconds = playTime?.seconds?.takeIf { it > 0 } ?: 0L
+ val fullWidthSeconds = beatTime?.longestSeconds?.takeIf { it > 0 } ?: 0L
+ if (playedSeconds <= 0L && fullWidthSeconds <= 0L) {
+ return
+ }
+
+ val figures = LocalTextStyle.current.copy(fontFeatureSettings = "tnum")
+ var estimateFocused by remember { mutableStateOf(false) }
+
+ Column(modifier = Modifier.padding(top = 10.dp).testTag("nova-game-detail-played")) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.width(NOVA_GAUGE_WIDTH),
+ ) {
+ Text(
+ text = if (playedSeconds > 0L) {
+ val minutes = playedSeconds / 60
+ if (minutes >= 60) {
+ stringResource(R.string.nova_game_detail_played_hours, minutes / 60)
+ } else {
+ stringResource(R.string.nova_game_detail_played_minutes, minutes)
+ }
+ } else {
+ stringResource(R.string.nova_game_detail_not_started)
+ }.uppercase(),
+ color = colors.textPrimary,
+ fontSize = 10.sp,
+ fontWeight = FontWeight.Bold,
+ letterSpacing = 0.12.em,
+ maxLines = 1,
+ style = figures,
+ )
+
+ Spacer(modifier = Modifier.weight(1f))
+
+ val parts = listOfNotNull(
+ beatTime?.mainSeconds?.takeIf { it > 0 }
+ ?.let { stringResource(R.string.nova_game_detail_beat_main, it / 3600) },
+ beatTime?.extrasSeconds?.takeIf { it > 0 }
+ ?.let { stringResource(R.string.nova_game_detail_beat_extras, it / 3600) },
+ beatTime?.completionistSeconds?.takeIf { it > 0 }
+ ?.let { stringResource(R.string.nova_game_detail_beat_complete, it / 3600) },
+ )
+
+ if (parts.isNotEmpty()) {
+ val page = beatTime?.url.orEmpty()
+ val linked = page.isNotBlank()
+ val ring = if (estimateFocused && linked) colors.accent else Color.Transparent
+
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(6.dp),
+ modifier = Modifier
+ .clip(RoundedCornerShape(NOVA_GAUGE_CHIP_RADIUS))
+ .background(
+ if (estimateFocused && linked) {
+ colors.accent.copy(alpha = 0.14f)
+ } else {
+ Color.Transparent
+ }
+ )
+ .border(1.dp, ring, RoundedCornerShape(NOVA_GAUGE_CHIP_RADIUS))
+ .then(
+ // A page makes this a control; without one it is a readout and
+ // has no business in the focus lane.
+ if (linked) {
+ Modifier
+ .onFocusChanged { estimateFocused = it.isFocused || it.hasFocus }
+ .clickable(role = Role.Button) { uriHandler.openUri(page) }
+ .focusable()
+ } else {
+ Modifier
+ }
+ )
+ .padding(horizontal = 7.dp, vertical = 3.dp),
+ ) {
+ Text(
+ text = parts.joinToString(" · ").uppercase(),
+ color = colors.textSecondary,
+ fontSize = 10.sp,
+ fontWeight = FontWeight.Medium,
+ letterSpacing = 0.10.em,
+ maxLines = 1,
+ style = figures,
+ )
+ if (linked) {
+ Text(
+ text = "\u2197",
+ color = if (estimateFocused) colors.accent else colors.textMuted,
+ fontSize = 10.sp,
+ )
+ }
+ }
+ }
+ }
+
+ if (fullWidthSeconds > 0L) {
+ val track = colors.textPrimary.copy(alpha = 0.10f)
+ val fillEnd = colors.accent
+ val fillStart = lerp(colors.accent, Color.White, 0.28f)
+ val notchInk = colors.window
+ val notchRing = colors.textPrimary.copy(alpha = 0.34f)
+ val played = (playedSeconds.toFloat() / fullWidthSeconds.toFloat()).coerceIn(0f, 1f)
+ val notches = listOfNotNull(
+ beatTime?.mainSeconds?.takeIf { it > 0 && it < fullWidthSeconds },
+ beatTime?.extrasSeconds?.takeIf { it > 0 && it < fullWidthSeconds },
+ ).map { it.toFloat() / fullWidthSeconds.toFloat() }
+
+ Canvas(
+ modifier = Modifier
+ .padding(top = 6.dp)
+ .width(NOVA_GAUGE_WIDTH)
+ .height(NOVA_GAUGE_BAR + NOVA_GAUGE_NOTCH_OVERHANG * 2),
+ ) {
+ val barTop = NOVA_GAUGE_NOTCH_OVERHANG.toPx()
+ val barHeight = NOVA_GAUGE_BAR.toPx()
+ val radius = CornerRadius(barHeight / 2f, barHeight / 2f)
+
+ drawRoundRect(
+ color = track,
+ topLeft = Offset(0f, barTop),
+ size = Size(size.width, barHeight),
+ cornerRadius = radius,
+ )
+ if (played > 0f) {
+ drawRoundRect(
+ brush = Brush.horizontalGradient(listOf(fillStart, fillEnd)),
+ topLeft = Offset(0f, barTop),
+ size = Size(size.width * played, barHeight),
+ cornerRadius = radius,
+ )
+ }
+
+ val notchWidth = NOVA_GAUGE_NOTCH.toPx()
+ val ringWidth = notchWidth + 2f
+ notches.forEach { fraction ->
+ val centre = size.width * fraction
+ // The ring first, so the notch reads as a gap cut through the bar.
+ drawRoundRect(
+ color = notchRing,
+ topLeft = Offset(centre - ringWidth / 2f, 0f),
+ size = Size(ringWidth, size.height),
+ cornerRadius = CornerRadius(ringWidth / 2f, ringWidth / 2f),
+ )
+ drawRoundRect(
+ color = notchInk,
+ topLeft = Offset(centre - notchWidth / 2f, 1f),
+ size = Size(notchWidth, size.height - 2f),
+ cornerRadius = CornerRadius(notchWidth / 2f, notchWidth / 2f),
+ )
+ }
+ }
+ }
+
+ // A fuzzy match that went wrong looks exactly like one that went right, so the
+ // name it actually found is shown whenever it is not plainly the same game.
+ val matched = beatTime?.matchedName.orEmpty()
+ if (matched.isNotBlank() && novaSameTitle(matched, gameName).not()) {
+ Text(
+ text = stringResource(R.string.nova_game_detail_matched_as, matched),
+ color = colors.textMuted,
+ fontSize = 10.sp,
+ letterSpacing = 0.06.em,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier
+ .padding(top = 5.dp)
+ .width(NOVA_GAUGE_WIDTH),
+ )
+ }
+ }
+}
+
+/**
+ * Whether two titles are the same game as far as a reader cares.
+ *
+ * Punctuation and case disagree constantly between a launcher and a catalogue, and
+ * saying so every time would bury the cases that matter under noise.
+ */
+private fun novaSameTitle(left: String, right: String): Boolean {
+ fun fold(value: String) = buildString {
+ value.forEach { if (it.isLetterOrDigit()) append(it.lowercaseChar()) }
+ }
+ return fold(left) == fold(right)
+}
+
+/** Matches the hairline above it, so the two read as one column. */
+private val NOVA_GAUGE_WIDTH = 330.dp
+private val NOVA_GAUGE_BAR = 4.dp
+private val NOVA_GAUGE_NOTCH = 1.5.dp
+/** The notches overhang the bar, which is what makes them read as cuts through it. */
+private val NOVA_GAUGE_NOTCH_OVERHANG = 2.dp
+private val NOVA_GAUGE_CHIP_RADIUS = 5.dp
+
+/** Dissolves the hero band into the window colour instead of cutting against it. */
+private fun Modifier.novaFadeToGround(ground: Color): Modifier = drawWithContent {
+ drawContent()
+ drawRect(
+ brush = Brush.verticalGradient(
+ colorStops = arrayOf(
+ 0.62f to Color.Transparent,
+ 1.0f to ground,
+ ),
+ ),
+ )
+}
+
+/**
+ * One action in the lane. The primary carries the button it is bound to and an accent
+ * gradient; the rest are quiet, hairline-bordered and marked. Focus is a ring and a
+ * tint — never a scale or an offset, which is the contract the poster cards settled on.
+ */
+@Composable
+private fun NovaGameDetailAction(
+ text: String,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true,
+ primary: Boolean = false,
+ glyph: String? = null,
+ mark: String? = null,
+) {
+ val colors = LocalNovaComposeColors.current
+ val surfaces = LocalNovaLibrarySurfaces.current
+ val interactionSource = remember { MutableInteractionSource() }
+ var focused by remember { mutableStateOf(false) }
+ val shape = RoundedCornerShape(NovaGameDetailCornerRadius)
+
+ val background = if (primary && enabled) {
+ Brush.linearGradient(
+ listOf(
+ colors.accent,
+ lerp(colors.accent, Color.White, 0.28f),
+ lerp(colors.accent, Color.White, 0.62f),
+ ),
+ )
+ } else {
+ SolidColor(surfaces.control.copy(alpha = 1f))
+ }
+ val label = when {
+ primary && enabled -> colors.onAccent
+ enabled -> colors.textPrimary
+ else -> colors.textMuted
+ }
+
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(9.dp),
+ modifier = modifier
+ .heightIn(min = NovaGameDetailActionHeight)
+ .clip(shape)
+ .background(background, shape)
+ .border(
+ width = if (focused) 2.dp else 1.dp,
+ color = if (focused) colors.accent else surfaces.tileBorder,
+ shape = shape,
+ )
+ .onFocusChanged { focused = it.isFocused }
+ .clickable(
+ enabled = enabled,
+ interactionSource = interactionSource,
+ indication = null,
+ onClick = onClick,
+ )
+ .semantics { contentDescription = text }
+ .padding(horizontal = 16.dp, vertical = 10.dp),
+ ) {
+ if (glyph != null) {
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier
+ .size(20.dp)
+ .clip(RoundedCornerShape(percent = 50))
+ .background(colors.window.copy(alpha = 0.88f)),
+ ) {
+ Text(
+ text = glyph,
+ color = colors.textPrimary,
+ fontSize = 10.sp,
+ fontWeight = FontWeight.Bold,
+ )
+ }
+ }
+ if (mark != null) {
+ Text(text = mark, color = label.copy(alpha = 0.62f), fontSize = 13.sp)
+ }
+ Text(
+ text = text,
+ color = label,
+ fontSize = 14.sp,
+ fontWeight = FontWeight.SemiBold,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+}
+
+@Composable
+private fun LaunchProfileReviewNotice(
+ optimizationState: NovaGameDetailOptimizationState,
+ modifier: Modifier = Modifier,
+) {
+ val colors = LocalNovaComposeColors.current
+ val summary = optimizationState.profileSummary
+ val detail = listOf(
+ summary?.noticeDetail,
+ summary?.noticeRecommendation,
+ summary?.reasonLine,
+ ).firstOrNull { !it.isNullOrBlank() }
+ ?: stringResource(
+ R.string.nova_library_preflight_review_message,
+ optimizationState.reviewReason.ifBlank { "fps_override" },
+ )
+
+ Column(
+ modifier = modifier
+ .sizeIn(maxWidth = 520.dp)
+ .clip(RoundedCornerShape(NovaGameDetailCornerRadius))
+ .background(colors.warning.copy(alpha = 0.13f))
+ .border(1.dp, colors.warning.copy(alpha = 0.46f), RoundedCornerShape(NovaGameDetailCornerRadius))
+ .padding(horizontal = 12.dp, vertical = 10.dp)
+ .testTag("nova-game-detail-review"),
+ ) {
+ Text(
+ text = stringResource(R.string.nova_library_preflight_review_title),
+ color = colors.textPrimary,
+ fontSize = 12.sp,
+ fontWeight = FontWeight.SemiBold,
+ )
+ Text(
+ text = detail,
+ color = colors.textSecondary,
+ fontSize = 11.sp,
+ lineHeight = 15.sp,
+ modifier = Modifier.padding(top = 5.dp),
+ )
+ }
+}
+
+/** Source, when it was last played, and its primary genre — who the game is. */
+private fun novaGameDetailIdentityLine(
+ sourceLabel: String,
+ lastPlayedText: String?,
+ game: PolarisGame,
+): String = listOf(sourceLabel, lastPlayedText, game.genres.firstOrNull())
+ .filter { !it.isNullOrBlank() }
+ .joinToString(" · ")
+
+/** Mode, profile and freshness — what the primary action will do. */
+private fun novaGameDetailStatusText(
+ uiState: NovaGameDetailUiState,
+ summary: NovaLaunchProfileSummary?,
+): String {
+ return listOf(
+ uiState.hostStreamDisplayModeLabel.takeIf { uiState.playUsesVirtualDisplay },
+ summary?.selectedLine,
+ summary?.limitingLine?.takeIf { it.isNotBlank() } ?: summary?.freshnessLine,
+ ).filter { !it.isNullOrBlank() }.joinToString(" · ")
+}
+
+/**
+ * B unwinds a level, X reaches Tune. A is not repeated here: the primary action already
+ * carries it, and this window exists to remove duplication.
+ */
+@Composable
+private fun novaGameDetailOverviewHints(): List = listOf(
+ NovaControllerHint(
+ key = stringResource(R.string.nova_controller_hint_b),
+ label = stringResource(R.string.nova_controller_hint_close),
+ ),
+)
diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailUiState.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailUiState.kt
index a86ef644..c5f8c1e8 100644
--- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailUiState.kt
+++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailUiState.kt
@@ -11,6 +11,8 @@ data class NovaGameDetailUiState(
val launchChoice: PolarisGame.LaunchModeChoice,
val preferredMode: String,
val recommendedMode: String,
+ /** Whether anything actually recommended it, rather than it echoing the preference. */
+ val hasRecommendation: Boolean,
val headlessAllowed: Boolean,
val virtualDisplayAllowed: Boolean,
val virtualDisplayUnavailable: Boolean,
@@ -43,10 +45,22 @@ data class NovaGameDetailUiState(
game: PolarisGame,
defaultToVirtualDisplay: Boolean,
clientSettings: PolarisClientSettings?,
- profilePreference: String
+ profilePreference: String,
+ /** A mode chosen for this game on this client, which outranks the host default. */
+ launchModeOverride: String? = null,
): NovaGameDetailUiState {
val choice = game.resolveLaunchModeChoice(defaultToVirtualDisplay, clientSettings)
+ // Only a deliberate choice reaches here, so it answers before the host does.
+ // The contract's preferredMode stays below the host, as it means the app's own
+ // default rather than anyone's decision.
+ val chosen = launchModeOverride
+ ?.takeIf { it.isNotBlank() }
+ ?.let { PolarisGame.resolveLaunchMode(it, choice.headlessAllowed, choice.virtualDisplayAllowed) }
val playMode = when {
+ chosen == "virtual_display" && choice.virtualDisplayAllowed &&
+ !choice.virtualDisplayUnavailable -> "virtual_display"
+ chosen == "headless" && choice.headlessAllowed -> "headless"
+
choice.recommendedMode == "virtual_display" && choice.virtualDisplayAllowed -> "virtual_display"
choice.recommendedMode == "headless" && choice.headlessAllowed -> "headless"
choice.headlessAllowed -> "headless"
@@ -84,6 +98,10 @@ data class NovaGameDetailUiState(
launchChoice = choice,
preferredMode = choice.preferredMode,
recommendedMode = choice.recommendedMode,
+ // The resolver falls back to the preference when neither the host nor
+ // the contract recommends anything, so ask whether either did.
+ hasRecommendation = choice.hostDefaultMode.isNotBlank() ||
+ !game.launchMode?.recommendedMode.isNullOrBlank(),
headlessAllowed = choice.headlessAllowed,
virtualDisplayAllowed = choice.virtualDisplayAllowed,
virtualDisplayUnavailable = choice.virtualDisplayUnavailable,
diff --git a/app/src/main/java/com/papi/nova/ui/NovaLaunchModeOverrides.kt b/app/src/main/java/com/papi/nova/ui/NovaLaunchModeOverrides.kt
new file mode 100644
index 00000000..3db15b84
--- /dev/null
+++ b/app/src/main/java/com/papi/nova/ui/NovaLaunchModeOverrides.kt
@@ -0,0 +1,34 @@
+package com.papi.nova.ui
+
+import android.content.Context
+import com.papi.nova.shared.polaris.model.PolarisGame
+
+/**
+ * Where the launch mode you choose for a game is remembered.
+ *
+ * It cannot live in the game's launch contract: preferredMode there means the app's own
+ * default and is deliberately outranked by the host's configured display mode, so a
+ * choice written into it is resolved away. This is a separate, higher answer to the same
+ * question — what this client should do for this game — and it is only ever written by
+ * choosing in the Launch Mode destination.
+ */
+object NovaLaunchModeOverrides {
+
+ private const val PREFS_NAME = "nova_prefs"
+ private const val KEY_PREFIX = "launch_mode_override_"
+
+ private fun key(game: PolarisGame): String =
+ KEY_PREFIX + game.id.ifBlank { game.appId.toString() }
+
+ fun load(context: Context, game: PolarisGame): String? =
+ context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+ .getString(key(game), null)
+ ?.takeIf { it.isNotBlank() }
+
+ fun save(context: Context, game: PolarisGame, mode: String) {
+ context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+ .edit()
+ .putString(key(game), mode)
+ .apply()
+ }
+}
diff --git a/app/src/main/java/com/papi/nova/ui/NovaLibraryActivity.kt b/app/src/main/java/com/papi/nova/ui/NovaLibraryActivity.kt
index ddcd916e..bdeb2ead 100644
--- a/app/src/main/java/com/papi/nova/ui/NovaLibraryActivity.kt
+++ b/app/src/main/java/com/papi/nova/ui/NovaLibraryActivity.kt
@@ -712,6 +712,13 @@ class NovaLibraryActivity : NovaActivity() {
return consumed
}
+ private fun queryActiveSessionAsync(onResult: (NovaLibraryActiveSessionUiState?) -> Unit) {
+ lifecycleScope.launch {
+ val session = withContext(Dispatchers.IO) { runCatching { queryActiveSession() }.getOrNull() }
+ onResult(session)
+ }
+ }
+
private fun queryActiveSession(): NovaLibraryActiveSessionUiState? {
return NovaLibraryActiveSessionUiState.from(apiClient.getSessionStatus())
}
@@ -797,6 +804,14 @@ class NovaLibraryActivity : NovaActivity() {
data.getStringExtra(NovaGameDetailActivity.EXTRA_RESULT_GAME)
?.let { PolarisGameJson.decode(it) }
?.let { updated -> allGames = allGames.map { if (it.id == updated.id) updated else it } }
+ when (data.getStringExtra(NovaGameDetailActivity.EXTRA_RESULT_SESSION)) {
+ // The window saw the session but cannot act on it: resuming and ending both
+ // need stream credentials that live here.
+ NovaGameDetailActivity.RESULT_SESSION_RESUME ->
+ queryActiveSessionAsync { session -> session?.let { resumeActiveSession(it) } }
+ NovaGameDetailActivity.RESULT_SESSION_END ->
+ queryActiveSessionAsync { session -> session?.let { endActiveSession(it) } }
+ }
val launch = data.getStringExtra(NovaGameDetailActivity.EXTRA_RESULT_LAUNCH) ?: return
val request = try {
@@ -1287,6 +1302,7 @@ class NovaLibraryActivity : NovaActivity() {
null -> Unit
}
},
+ onOpenDetail = model.hero.game?.let { game -> { onOpenDetail(game) } },
onGameFocused = onGameFocused
)
}
@@ -1365,6 +1381,7 @@ class NovaLibraryActivity : NovaActivity() {
null -> Unit
}
},
+ onOpenDetail = model.hero.game?.let { game -> { onOpenDetail(game) } },
onGameFocused = onGameFocused
)
}
@@ -1520,6 +1537,12 @@ class NovaLibraryActivity : NovaActivity() {
apiClient: PolarisApiClient,
onPrimaryAction: () -> Unit,
onSecondaryAction: (() -> Unit)? = null,
+ /**
+ * The card opens the game; the buttons do the thing. Without this the running
+ * game was the one entry whose detail could not be reached at all, because the
+ * whole card resumed and the grid omits it while a session is live.
+ */
+ onOpenDetail: (() -> Unit)? = null,
onGameFocused: (PolarisGame) -> Unit
) {
val colors = LocalNovaComposeColors.current
@@ -1562,7 +1585,7 @@ class NovaLibraryActivity : NovaActivity() {
.onFocusChanged {
focused = it.isFocused || it.hasFocus
}
- .combinedClickable(onClick = onPrimaryAction)
+ .combinedClickable(onClick = onOpenDetail ?: onPrimaryAction)
.focusable()
.padding(if (compact) 8.dp else 16.dp),
horizontalArrangement = Arrangement.spacedBy(if (compact) 6.dp else 16.dp),
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 151b520e..9c400298 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -262,6 +262,28 @@
Version and client identity.
%1$s
System
+ Resume
+ Watch
+ End session
+ Tune
+ Where it runs
+ State
+ Actions
+ Insight
+ Close
+ Not started
+ Estimate for %1$s
+ Main %1$d h
+ Extras %1$d h
+ 100%% %1$d h
+ %1$d h played
+ %1$d min played
+ Streams without touching the physical desktop
+ A dedicated display on the host, at your resolution
+ Resolution, bitrate and codec for this launch
+ Optimization profile
+ How Polaris picks quality for this game
+ ✦ POLARIS
A
B
X
diff --git a/app/src/test/java/com/papi/nova/api/PolarisApiClientParsingTest.kt b/app/src/test/java/com/papi/nova/api/PolarisApiClientParsingTest.kt
index 7b10f013..a37dbd38 100644
--- a/app/src/test/java/com/papi/nova/api/PolarisApiClientParsingTest.kt
+++ b/app/src/test/java/com/papi/nova/api/PolarisApiClientParsingTest.kt
@@ -991,4 +991,26 @@ class PolarisApiClientParsingTest {
assertEquals("big-picture", body.getString("mode"))
}
+ @Test
+ fun playTimeIsCarriedFromTheHostAndAbsentIsNotZero() {
+ val played = PolarisGameJsonAdapter.fromJson(
+ JSONObject(
+ """{id:abc,name:Control,play_time:{seconds:143520,source:steam,read_at:1754470000}}"""
+ )
+ )
+ assertEquals(143520L, played.playTime?.seconds)
+ assertEquals("steam", played.playTime?.source)
+
+ // No launcher owns the answer: null, so the gauge can be omitted rather than
+ // drawn claiming nobody has played it.
+ val unowned = PolarisGameJsonAdapter.fromJson(JSONObject("""{id:abc,name:Control}"""))
+ assertNull(unowned.playTime)
+
+ // Owned, but never played, is a different answer and keeps its object.
+ val untouched = PolarisGameJsonAdapter.fromJson(
+ JSONObject("""{id:abc,name:Control,play_time:{seconds:0,source:steam}}""")
+ )
+ assertNotNull(untouched.playTime)
+ assertEquals(0L, untouched.playTime?.seconds)
+ }
}
diff --git a/app/src/test/java/com/papi/nova/ui/NovaArtworkStudioSourceGuardTest.kt b/app/src/test/java/com/papi/nova/ui/NovaArtworkStudioSourceGuardTest.kt
index af2e2a7d..1b7fb68f 100644
--- a/app/src/test/java/com/papi/nova/ui/NovaArtworkStudioSourceGuardTest.kt
+++ b/app/src/test/java/com/papi/nova/ui/NovaArtworkStudioSourceGuardTest.kt
@@ -81,7 +81,9 @@ class NovaArtworkStudioSourceGuardTest {
@Test
fun detailSheetUsesArtworkStudioInsteadOfThePosterCentricFixMatchFlow() {
val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") +
- readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt")
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt")
val studio = readSource("src/main/java/com/papi/nova/ui/NovaArtworkStudio.kt")
val library = readSource("src/main/java/com/papi/nova/ui/NovaLibraryActivity.kt")
val updater = readSource("src/main/java/com/papi/nova/ui/NovaArtworkLibraryUpdater.kt")
@@ -120,7 +122,9 @@ class NovaArtworkStudioSourceGuardTest {
@Test
fun studioMutationsAreOwnedByRetainedViewModelAndUiCallbacksAreLifecycleGated() {
val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") +
- readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt")
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt")
val library = readSource("src/main/java/com/papi/nova/ui/NovaLibraryActivity.kt")
val updater = readSource("src/main/java/com/papi/nova/ui/NovaArtworkLibraryUpdater.kt")
@@ -189,7 +193,9 @@ class NovaArtworkStudioSourceGuardTest {
@Test
fun gameDetailsPreservesCachedHeroAvailabilityAndStablePresentationIdentity() {
val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") +
- readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt")
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt")
val contentCall = detail.section(
"NovaGameDetailContent(",
"\n loadOptimization(profilePreference)",
@@ -244,7 +250,9 @@ class NovaArtworkStudioSourceGuardTest {
fun applyIsOneExplicitAtomicMutationAndFailureRequiresRefetch() {
val studio = readSource("src/main/java/com/papi/nova/ui/NovaArtworkStudio.kt")
val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") +
- readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt")
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt")
val updater = readSource("src/main/java/com/papi/nova/ui/NovaArtworkLibraryUpdater.kt")
val stateReducer = studio.section(
"fun reduce(action: NovaArtworkStudioAction)",
@@ -309,7 +317,9 @@ class NovaArtworkStudioSourceGuardTest {
fun resetAndCancelDiscardDraftLocallyWithoutServerMutation() {
val studio = readSource("src/main/java/com/papi/nova/ui/NovaArtworkStudio.kt")
val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") +
- readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt")
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt")
val resetButton = studio.section(
"text = stringResource(R.string.nova_artwork_studio_reset)",
"text = stringResource(R.string.nova_artwork_studio_apply)",
@@ -334,7 +344,9 @@ class NovaArtworkStudioSourceGuardTest {
@Test
fun ordinaryDetailAndLibraryLoadingCannotStartChoiceOrProviderMutationTraffic() {
val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") +
- readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt")
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt")
val library = readSource("src/main/java/com/papi/nova/ui/NovaLibraryActivity.kt")
val detailLoad = detail.section(
"private fun loadArtworkState(",
@@ -351,7 +363,9 @@ class NovaArtworkStudioSourceGuardTest {
@Test
fun studioRefreshUsesTheRetainedPerGameMutationCoordinator() {
val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") +
- readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt")
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt")
val library = readSource("src/main/java/com/papi/nova/ui/NovaLibraryActivity.kt")
val updater = readSource("src/main/java/com/papi/nova/ui/NovaArtworkLibraryUpdater.kt")
diff --git a/app/src/test/java/com/papi/nova/ui/NovaComposeSourceGuardTest.kt b/app/src/test/java/com/papi/nova/ui/NovaComposeSourceGuardTest.kt
index 5bfae711..469cebc2 100644
--- a/app/src/test/java/com/papi/nova/ui/NovaComposeSourceGuardTest.kt
+++ b/app/src/test/java/com/papi/nova/ui/NovaComposeSourceGuardTest.kt
@@ -575,9 +575,9 @@ class NovaComposeSourceGuardTest {
hero.contains("if (!compact && hero.badges.isNotEmpty())")
)
assertTrue(
- "hero card itself should activate the same primary action when D-pad focus lands on the container",
- hero.contains(".combinedClickable(onClick = onPrimaryAction)") &&
- hero.indexOf(".combinedClickable(onClick = onPrimaryAction)") in 0 until hero.indexOf(".focusable()")
+ "hero card opens the game and falls back to the primary action, so a running game is still reachable while the grid omits it",
+ hero.contains(".combinedClickable(onClick = onOpenDetail ?: onPrimaryAction)") &&
+ hero.indexOf(".combinedClickable(onClick = onOpenDetail ?: onPrimaryAction)") in 0 until hero.indexOf(".focusable()")
)
assertTrue(
"hero focus should update the focused backdrop/focus restore model for D-pad users",
@@ -1036,17 +1036,12 @@ class NovaComposeSourceGuardTest {
val detail = readNovaGameDetail()
val detailsPanel = detail.section(
"private fun GameDetailsPanel(",
- "@Composable\nprivate fun LaunchControlsPanel("
+ "@Composable\nprivate fun LaunchControls("
)
val launchFooter = detail.section(
"internal fun NovaGameDetailLaunchFooter(",
"@Composable\nprivate fun NovaDetailPanel("
)
- val launchModePill = detail.section(
- "private fun LaunchModeChoicePill(",
- "@Composable\nprivate fun ProfileSummaryText("
- )
-
assertTrue(
"Retroid landscape first paint should keep either Hero or Poster identity inside the compact launch header ceiling",
detailsPanel.contains(".heightIn(min = 136.dp)") &&
@@ -1066,104 +1061,49 @@ class NovaComposeSourceGuardTest {
assertTrue(
"pinned primary launch and mode choice controls should stay compact enough for Retroid landscape",
launchFooter.contains("minHeight = 48.dp") &&
- launchModePill.contains("modifier = modifier.heightIn(min = 52.dp)")
+ detail.contains("NOVA_DETAIL_ROW_MIN_HEIGHT = 48.dp")
)
}
@Test
fun gameDetailLaunchControlsPrioritizePrimaryPlayFocus() {
val detail = readNovaGameDetail()
- val launchControls = detail.section(
- "private fun LaunchControls(",
- "@Composable\nprivate fun LaunchProfileSummaryInline("
- )
- val launchFooter = detail.section(
- "internal fun NovaGameDetailLaunchFooter(",
- "@Composable\nprivate fun NovaDetailPanel("
- )
- val sheetContent = detail.section(
- "fun NovaGameDetailContent(",
- "@Composable\nprivate fun NovaGameDetailScrollableContent("
+ val overview = detail.section(
+ "internal fun NovaGameDetailOverview(",
+ "private fun NovaGameDetailTitle("
)
- val scrollableContent = detail.section(
- "private fun NovaGameDetailScrollableContent(",
- "@Composable\nprivate fun novaGameDetailControllerHints("
+ val actions = detail.section(
+ "private fun NovaGameDetailActions(",
+ "private fun NovaGameDetailAction("
)
- val scrollBody = sheetContent.blockStartingAt("NovaGameDetailScrollableContent(")
assertTrue(
- "game detail should focus the pinned primary play action when the sheet opens",
- sheetContent.contains("val playFocusRequester = remember { FocusRequester() }") &&
- launchFooter.contains("playFocusRequester.requestFocus()") &&
- launchFooter.contains(".focusRequester(playFocusRequester)")
- )
- assertTrue(
- "game detail should preserve an explicit controller focus route between pinned Play and More details",
- sheetContent.contains("val detailsFocusRequester = remember { FocusRequester() }") &&
- launchControls.contains("detailsFocusRequester = detailsFocusRequester") &&
- launchControls.contains("playFocusRequester = playFocusRequester") &&
- launchFooter.contains(".focusProperties { up = detailsFocusRequester }") &&
- launchControls.contains(".focusProperties { down = playFocusRequester }")
+ "the primary action holds first focus, and nothing scrolls above it",
+ detail.contains("val playFocusRequester = remember { FocusRequester() }") &&
+ actions.contains(".focusRequester(playFocusRequester)") &&
+ actions.contains("primary = activeSession?.watchOnly != true") &&
+ !overview.contains("verticalScroll")
)
assertTrue(
- "the pinned footer should keep Play as a full-width primary action above the safe-content bottom inset",
- launchFooter.section("text = playLabel", "enabled = enabled")
- .contains(".fillMaxWidth()\n .focusRequester(playFocusRequester)") &&
- launchFooter.contains(".windowInsetsPadding(contentInsets)") &&
- launchFooter.contains(".padding(start = 14.dp, end = 14.dp, top = 4.dp, bottom = 18.dp)") &&
- sheetContent.contains("contentInsets = WindowInsets.safeContent.only(WindowInsetsSides.Bottom)")
+ "the action lane is one row, so a D-pad walk never leaves it",
+ actions.contains("Row(") &&
+ actions.contains("horizontalArrangement = Arrangement.spacedBy(10.dp)")
)
assertTrue(
- "the game-detail root must remain bounded while the detail body owns the remaining height and vertical scrolling",
- sheetContent.contains(".fillMaxHeight()") &&
- sheetContent.contains("modifier = Modifier.weight(1f)") &&
- scrollableContent.contains(".verticalScroll(scrollState)")
+ "every focusable action clears the accessible target floor",
+ detail.contains("internal val NovaGameDetailActionHeight = 48.dp") &&
+ detail.contains("heightIn(min = NovaGameDetailActionHeight)")
)
assertTrue(
- "the pinned Play footer must be composed after and outside the scrollable detail lambda",
- scrollBody.contains("GameDetailsPanel(") &&
- scrollBody.contains("LaunchControlsPanel(") &&
- scrollBody.contains("NovaControllerHintBar(") &&
- !scrollBody.contains("NovaGameDetailLaunchFooter(") &&
- sheetContent.indexOf("NovaGameDetailLaunchFooter(") >
- sheetContent.indexOf(scrollBody) + scrollBody.length
+ "the rail is gated rather than decorative: Launch mode appears only when there is a real choice, and a review replaces the rail with its own answers",
+ actions.contains("if (showLaunchModeAction)") &&
+ actions.contains("if (reviewExpanded)") &&
+ actions.contains("onRetryHighFps") &&
+ actions.contains("onResetProfile")
)
assertFalse(
- "the primary launch button must not remain inside the vertically scrolling launch-controls body",
- launchControls.contains("text = playLabel") ||
- launchControls.contains("onClick = onPrimaryLaunch")
- )
- assertTrue(
- "game detail should keep host/render limits in the scrolling body above the pinned launch action",
- launchControls.contains("LaunchProfilePrimaryNotice(") &&
- sheetContent.indexOf("LaunchControlsPanel(") < sheetContent.indexOf("NovaGameDetailLaunchFooter(")
- )
- assertTrue(
- "Heads up should explain evidence and the recommended next launch instead of showing one vague limited-by line",
- launchControls.contains("text = summary.noticeDetail") &&
- launchControls.contains("text = summary.noticeRecommendation")
- )
- assertTrue(
- "Heads up should remain visible when only the new evidence or recommendation fields are available",
- launchControls.contains("val hasNoticeContent = listOf(") &&
- launchControls.contains("summary.noticeDetail") &&
- launchControls.contains("summary.noticeRecommendation") &&
- launchControls.contains("if (!hasNoticeContent) return") &&
- launchControls.contains("text = notice.ifBlank { \"Launch profile adjusted\" }")
- )
- assertTrue(
- "near-target performance should use an explicit healthy status tone instead of warning styling",
- launchControls.contains("summary.noticeTone == NovaLaunchProfileNoticeTone.HEALTHY") &&
- launchControls.contains("val badgeLabel = if (isHealthy) summary.noticeLabel else \"Heads up\"") &&
- launchControls.contains("colorResource(R.color.nova_success)")
- )
- assertTrue(
- "Heads up should stay compact until the player opens the DPAD-friendly detail control",
- launchControls.contains("var noticeExpanded by remember(") &&
- launchControls.contains("text = if (noticeExpanded) \"Hide details\" else \"More details\"") &&
- launchControls.contains("stateDescription = if (noticeExpanded) \"Expanded\" else \"Collapsed\"") &&
- launchControls.contains("if (noticeExpanded && summary.noticeDetail.isNotBlank())") &&
- launchControls.contains("if (noticeExpanded && summary.noticeRecommendation.isNotBlank())")
+ "the primary launch button must not sit inside a scrolling body",
+ actions.contains("verticalScroll")
)
}
@@ -1172,27 +1112,40 @@ class NovaComposeSourceGuardTest {
val detail = readNovaGameDetail()
val launchControls = detail.section(
"private fun LaunchControls(",
- "@Composable\nprivate fun LaunchProfileSummaryInline("
+ "@Composable\ninternal fun LaunchProfilePrimaryNotice("
)
assertTrue(
- "Headless/Virtual should be directly selectable from the detail sheet before launching",
- launchControls.contains("LaunchModeChoicePill(") &&
+ "Headless/Virtual should be directly selectable from the destination, as rows carrying their standing",
+ launchControls.contains("NovaSteamChoiceRow(") &&
launchControls.contains("onClick = { onLaunchModeSelected(\"headless\") }") &&
- launchControls.contains("onClick = { onLaunchModeSelected(\"virtual_display\") }")
+ launchControls.contains("onClick = { onLaunchModeSelected(\"virtual_display\") }") &&
+ !launchControls.contains("LaunchModeChoicePill(")
)
assertTrue(
"Launch Options should remain a secondary path after inline Headless/Virtual choices",
detail.contains("private fun showLaunchOptions(") &&
detail.contains("onLaunchOptions = {") &&
- launchControls.contains("text = launchOptionsLabel") &&
- launchControls.indexOf("text = launchOptionsLabel") > launchControls.indexOf("LaunchModeChoicePill(")
+ launchControls.contains("label = launchOptionsLabel") &&
+ launchControls.indexOf("label = headlessModeLabel") in
+ 0 until launchControls.indexOf("label = launchOptionsLabel")
+ )
+ assertTrue(
+ "launch mode should answer where it runs and leave the profile alone: preference, summary and reset are Tune's",
+ !launchControls.contains("profilePreferenceLabel") &&
+ !launchControls.contains("LaunchProfileSummaryInline(") &&
+ !launchControls.contains("resetProfileLabel")
+ )
+ assertTrue(
+ "Tune should hold the profile controls launch mode gave up, and stay the way into the preference picker",
+ detail.contains("onClick = onProfilePreference,") &&
+ detail.contains("LaunchProfileSummaryActions(") &&
+ detail.contains("R.string.nova_game_detail_group_actions")
)
assertTrue(
- "non-duplicative tuning should remain available separately from launch mode selection",
- launchControls.contains("text = profilePreferenceLabel") &&
- launchControls.indexOf("text = profilePreferenceLabel") > launchControls.indexOf("LaunchModeChoicePill(") &&
- launchControls.split("LaunchProfileSummaryInline(").size == 2
+ "launch mode should name itself once: the destination header already does, and the pills say which is recommended",
+ !launchControls.contains("text = launchModeTitle") &&
+ !launchControls.contains("text = recommendedBadge")
)
}
@@ -1212,7 +1165,8 @@ class NovaComposeSourceGuardTest {
"when MangoHUD is already enabled, the drawer should show only a passive status after launch controls",
sheetContent.contains("if (mangoHudEnabled) {") &&
sheetContent.contains("MangoHudPassiveStatus(") &&
- sheetContent.indexOf("MangoHudPassiveStatus(") > sheetContent.indexOf("LaunchControlsPanel(")
+ sheetContent.indexOf("LaunchControls(") in
+ 0 until sheetContent.indexOf("MangoHudPassiveStatus(")
)
}
@@ -1221,7 +1175,7 @@ class NovaComposeSourceGuardTest {
val source = readNovaGameDetail()
val detailsPanel = source.section(
"private fun GameDetailsPanel(",
- "@Composable\nprivate fun LaunchControlsPanel("
+ "@Composable\nprivate fun LaunchControls("
)
assertTrue(
@@ -1239,52 +1193,32 @@ class NovaComposeSourceGuardTest {
@Test
fun gameDetailUsesHeroBackdropLogoTransformIconIdentityAndPosterFallback() {
val source = readNovaGameDetail()
- val sheetContent = source.section(
- "fun NovaGameDetailContent(",
- "@Composable\nprivate fun NovaGameDetailScrollableContent("
+ val overview = source.section(
+ "internal fun NovaGameDetailOverview(",
+ "private fun NovaGameDetailTitle("
)
- val detailsPanel = source.section(
- "private fun GameDetailsPanel(",
- "@Composable\nprivate fun LaunchControlsPanel("
+ val title = source.section(
+ "private fun NovaGameDetailTitle(",
+ "private fun NovaGameDetailStatusLine("
)
assertTrue(
- "detail content should route revision-aware Hero, Logo, and Icon presentation into the identity panel",
- sheetContent.contains("heroAvailable = heroAvailable") &&
- sheetContent.contains("heroPresentationKey = heroPresentationKey") &&
- sheetContent.contains("heroLoader = heroLoader") &&
- sheetContent.contains("logoPresentationKey = logoPresentationKey") &&
- sheetContent.contains("iconPresentationKey = iconPresentationKey")
+ "the hero should be the full-bleed backdrop rather than a 136dp panel thumbnail, reusing the library's own backdrop so hero-to-poster fallback and the theme scrims come with it",
+ overview.contains("NovaLibraryCinematicBackdrop(") &&
+ overview.contains("strength = 1f") &&
+ !overview.contains(".height(136.dp)")
)
assertTrue(
- "a real manifest Hero should become the compact detail backdrop",
- detailsPanel.contains("if (heroAvailable)") &&
- detailsPanel.contains("NovaGameDetailHero(") &&
- detailsPanel.contains("key(heroPresentationKey)") &&
- detailsPanel.contains("heroLoader(this)") &&
- detailsPanel.contains(".height(136.dp)")
+ "curated logo artwork should become the title treatment at real size, still keyed by presentation revision",
+ title.contains("if (logoAvailable)") &&
+ title.contains("key(logoPresentationKey)") &&
+ title.contains("logoLoader(this)") &&
+ title.contains("maxWidth = 200.dp, maxHeight = 64.dp")
)
assertTrue(
- "the manifest Logo should be revision-aware and use the saved normalized transform over Hero",
- detailsPanel.contains("BoxWithConstraints(") &&
- detailsPanel.contains("key(logoPresentationKey)") &&
- detailsPanel.contains("logoLoader(this)") &&
- detailsPanel.contains("offset(x = logoOffsetX, y = logoOffsetY)") &&
- detailsPanel.contains("scaleX = artworkState.logoScale") &&
- detailsPanel.contains("scaleY = artworkState.logoScale")
- )
- assertTrue(
- "the manifest Icon should provide a compact revision-aware identity mark beside the title",
- detailsPanel.contains("if (iconAvailable)") &&
- detailsPanel.contains("key(iconPresentationKey)") &&
- detailsPanel.contains("iconLoader(this)") &&
- detailsPanel.contains("text = game.name")
- )
- assertTrue(
- "Poster must remain the no-Hero fallback instead of disappearing from detail presentation",
- detailsPanel.contains("NovaGameDetailPosterFallback(") &&
- detailsPanel.contains("key(PolarisApiClient.artworkPresentationKey(game, PolarisGame.ARTWORK_KIND_POSTER))") &&
- detailsPanel.contains("coverLoader(this)")
+ "a game with no curated logo falls back to its name, and the fallback is a title rather than a poster card",
+ title.contains("text = game.name") &&
+ title.contains("nova-game-detail-title")
)
}
@@ -1295,14 +1229,22 @@ class NovaComposeSourceGuardTest {
"fun NovaGameDetailContent(",
"@Composable\nprivate fun NovaGameDetailScrollableContent("
)
- val artworkIndex = content.indexOf("NovaArtworkStudio(")
- val stabilityIndex = content.indexOf("optimizationState.stability?.let")
- val hintIndex = content.indexOf("NovaControllerHintBar(")
- assertTrue("artwork preferences should follow functional and insight content", artworkIndex > stabilityIndex)
- assertTrue("artwork preferences should remain above the controller hint footer", artworkIndex in 1 until hintIndex)
+ assertTrue(
+ "artwork curation should be its own destination, and a full-screen one: the studio lays itself out as a Row of weighted Columns and cannot fold into a side panel",
+ content.contains("NovaGameDetailDestination.ARTWORK -> NovaGameDetailFullScreen(") &&
+ content.contains("NovaArtworkStudio(")
+ )
val panel = readSource("src/main/java/com/papi/nova/ui/NovaArtworkStudio.kt")
- assertTrue("artwork preferences should start collapsed", panel.contains("var expanded by remember(initialQuery) { mutableStateOf(false) }"))
+ assertTrue(
+ "artwork preferences should start collapsed wherever the studio is one row among many",
+ panel.contains("initiallyExpanded: Boolean = false") &&
+ panel.contains("var expanded by remember(initialQuery) { mutableStateOf(initiallyExpanded) }")
+ )
+ assertTrue(
+ "the destination that is nothing but the studio should open it, not cost a tap and leave the window empty",
+ content.contains("NovaArtworkStudio(\n initiallyExpanded = true,")
+ )
assertTrue("artwork header should toggle expansion", panel.contains("clickable { expanded = !expanded }") && panel.contains("if (expanded)"))
assertTrue("Studio should show persisted identity and composition beside the live draft", panel.contains("R.string.nova_artwork_current_match") && panel.contains("R.string.nova_artwork_current_composition") && panel.contains("R.string.nova_artwork_live_preview"))
assertTrue("Studio should render Poster, Hero, Logo, and Icon composition layers", NovaArtworkKinds.ALL.all { kind -> panel.contains("kind = NovaArtworkKinds.${kind.uppercase()}") })
@@ -1751,11 +1693,11 @@ class NovaComposeSourceGuardTest {
libraryScreen.contains("controllerHintBarLandscapeStartPadding")
)
assertTrue(
- "game detail sheet should keep the shared hint bar with explicit horizontal and bottom padding inside the scrollable sheet",
- detailContent.contains("NovaControllerHintBar(") &&
- detailContent.contains("hints = novaGameDetailControllerHints()") &&
- detailContent.contains(".padding(start = 14.dp, end = 14.dp, top = 12.dp)") &&
- detailContent.contains(".padding(bottom = 16.dp)")
+ "the game detail window keeps the shared hint model; the Overview paints it borderless on the artwork while destinations keep the reusable bar",
+ detail.contains("List") &&
+ detail.contains("novaGameDetailOverviewHints()") &&
+ detail.contains("NovaControllerHintBar(") &&
+ detail.contains("nova_controller_hint_back")
)
assertTrue(
"settings should keep the main rows weighted above the shared hint bar instead of letting rows consume and clip the bottom controls",
@@ -2749,7 +2691,9 @@ class NovaComposeSourceGuardTest {
private fun readNovaGameDetail(): String =
readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") +
- readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt")
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt")
private fun readNovaSettingsScreen(): String =
readSource("src/main/java/com/papi/nova/preferences/NovaSettingsScreen.kt")
@@ -2761,7 +2705,7 @@ class NovaComposeSourceGuardTest {
fun gameDetailLaunchOptionsUseActionableModeState() {
val launchControls = readNovaGameDetail().section(
"private fun LaunchControls(",
- "@Composable\nprivate fun LaunchModeChoicePill("
+ "@Composable\ninternal fun LaunchProfilePrimaryNotice("
)
assertTrue(launchControls.contains("uiState.showLaunchOptionsButton"))
@@ -2772,7 +2716,9 @@ class NovaComposeSourceGuardTest {
@Test
fun gameDetailLaunchOptionsAvoidRawAppCompatAlertDialogButtons() {
val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") +
- readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt")
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt")
val launchOptions = detail.section(
"private fun showLaunchOptions(",
"private fun optionLabel("
@@ -2789,7 +2735,9 @@ class NovaComposeSourceGuardTest {
@Test
fun gameDetailProfilePreferenceAvoidsRawAppCompatAlertDialogButtons() {
val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") +
- readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt")
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt")
val profileOptions = detail.section(
"private fun showProfilePreferenceOptions(",
"private fun steamLaunchModeOptionsState("
diff --git a/app/src/test/java/com/papi/nova/ui/NovaLaunchSourceGuardTest.kt b/app/src/test/java/com/papi/nova/ui/NovaLaunchSourceGuardTest.kt
index 3188ef34..afd3858a 100644
--- a/app/src/test/java/com/papi/nova/ui/NovaLaunchSourceGuardTest.kt
+++ b/app/src/test/java/com/papi/nova/ui/NovaLaunchSourceGuardTest.kt
@@ -10,14 +10,15 @@ class NovaLaunchSourceGuardTest {
@Test
fun gameDetailLaunchUsesSelectedMangoHudState() {
- val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") +
- readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt")
- val primaryLaunch = detail.section("onPrimaryLaunch = {", "},\n onLaunchModeSelected")
- val launchModeSelection = detail.section("fun selectLaunchMode(", "setContentView(")
+ val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt")
+ // launchConfirmed is shared by the primary action, the option picker and the
+ // expanded review, so the MangoHUD state is asserted where they all pass through.
+ val launchConfirmed = detail.section("fun launchConfirmed(", "fun resetProfile(")
+ val launchModeSelection = detail.section("fun selectLaunchMode(", "fun launchConfirmed(")
assertTrue(
- "primary Play should pass the selected MangoHUD state into the launch request",
- primaryLaunch.contains("currentGame.copy(mangohud = mangoHudEnabled)")
+ "every launch path should pass the selected MangoHUD state into the launch request",
+ launchConfirmed.contains("currentGame.copy(mangohud = mangoHudEnabled)")
)
assertTrue(
"inline mode selection should keep the selected MangoHUD state in preview/preflight state",
@@ -45,38 +46,40 @@ class NovaLaunchSourceGuardTest {
@Test
fun desktopSteamDecisionSheetUsesNovaGlassAndExplicitMirrorDesktopPlumbing() {
val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") +
- readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt")
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt")
val serverHelper = readSource("src/main/java/com/papi/nova/utils/ServerHelper.kt")
val game = readSource("src/main/java/com/papi/nova/Game.kt")
val streamConfiguration = readSource("src/main/java/com/papi/nova/nvstream/StreamConfiguration.kt")
val nvHttp = readSource("src/main/java/com/papi/nova/nvstream/http/NvHTTP.kt")
- val chrome = readSource("src/main/java/com/papi/nova/ui/NovaSheetChrome.kt")
- val decisionSheet = detail.section(
- "private fun showDesktopSteamLaunchDecision(",
- "private fun modeLabel("
- )
- val launchOptions = detail.section(
- "onLaunchOptionSelected = { option ->",
- "onDismissLaunchOptions ="
+ val decisionRows = detail.section(
+ "internal fun NovaDesktopSteamLaunchDecisionRows(",
+ "internal fun NovaSteamChoiceRow("
)
assertTrue(
- "desktop Steam active policy should open a Nova-themed Compose bottom sheet, not a legacy square AlertDialog",
- decisionSheet.contains("BottomSheetDialog(this@NovaGameDetailActivity)") &&
- decisionSheet.contains("NovaDesktopSteamLaunchDecisionContent(") &&
- !decisionSheet.contains("AlertDialog.Builder")
+ "the desktop Steam choice belongs in the Launch mode destination, not a sheet or an alert raised over the artwork",
+ detail.contains("destination = NovaGameDetailDestination.LAUNCH_MODE") &&
+ detail.contains("steamDecision = desktopSteamDecision") &&
+ !detail.contains("BottomSheetDialog(") &&
+ !detail.contains("AlertDialog.Builder")
+ )
+ assertTrue(
+ "the decision should offer explicit Private Stream, Mirror Desktop, and close-Steam paths",
+ decisionRows.contains("nova_desktop_steam_private_stream") &&
+ decisionRows.contains("nova_desktop_steam_mirror_desktop") &&
+ decisionRows.contains("NovaSteamLaunchChoice.CLOSE_STEAM_THEN_PRIVATE")
)
assertTrue(
- "decision sheet should offer explicit Private Stream, Mirror Desktop, and Cancel actions",
- decisionSheet.contains("nova_desktop_steam_private_stream") &&
- decisionSheet.contains("nova_desktop_steam_mirror_desktop") &&
- decisionSheet.contains("nova_desktop_steam_cancel")
+ "a blocked option stays visible and inert, because the reason it is blocked is the useful part",
+ decisionRows.contains("enabled = decision.privateStreamEnabled") &&
+ decisionRows.contains("caption = decision.privateStreamUnavailableReason")
)
assertTrue(
"Launch Options must not bypass desktop-Steam safety for selected private headless launches",
- launchOptions.contains("usesVirtualDisplay = option.usesVirtualDisplay") &&
- launchOptions.contains("showDesktopSteamLaunchDecision(") &&
- launchOptions.contains("onForcePrivateAfterSteamClose = { launchSelected(mirrorDesktop = false, forcePrivateAfterSteamClose = true) }")
+ detail.contains("usesVirtualDisplay = option.usesVirtualDisplay") &&
+ detail.contains("steamDecision = desktopSteamDecision")
)
assertTrue(
"Mirror Desktop must be carried as an explicit launch override through the stream launch path",
@@ -88,15 +91,16 @@ class NovaLaunchSourceGuardTest {
streamConfiguration.contains("fun setMirrorDesktop(enable: Boolean)") &&
streamConfiguration.contains("fun setForcePrivateAfterSteamClose(enable: Boolean)") &&
nvHttp.contains("&mirrorDesktop=") &&
- nvHttp.contains("&launchMode=mirror_desktop") &&
- serverHelper.contains("forcePrivateAfterSteamClose = forcePrivateAfterSteamClose")
+ nvHttp.contains("&launchMode=mirror_desktop")
)
}
@Test
fun launchFailureAndDesktopSteamActionsUseNovaThemedFlow() {
val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") +
- readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt")
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt")
val serverHelper = readSource("src/main/java/com/papi/nova/utils/ServerHelper.kt")
val game = readSource("src/main/java/com/papi/nova/Game.kt")
val nvHttp = readSource("src/main/java/com/papi/nova/nvstream/http/NvHTTP.kt")
@@ -161,15 +165,22 @@ class NovaLaunchSourceGuardTest {
@Test
fun composeBottomSheetsUseThemeAwareGlassHostInsteadOfStaticOldThemeInset() {
- val gameDetailSheet = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") +
- readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt")
+ val gameDetail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt")
val syncSheet = readSource("src/main/java/com/papi/nova/ui/NovaPolarisSyncSheet.kt")
assertTrue(
- "The desktop Steam sheet hosted by the game detail window must clear/style the Material host with shared Nova glass chrome so bottom/nav inset gaps do not show old static blue chrome",
- gameDetailSheet.contains("NovaSheetChrome.applyBottomSheetChrome(bottomSheetDialog, contentView)") &&
- gameDetailSheet.contains("NovaSheetChrome.createSheetBackground(this@NovaGameDetailActivity)") &&
- !gameDetailSheet.contains("sheet.setBackgroundResource(sheetBackgroundRes())")
+ "the game detail window hosts no bottom sheet and no legacy alert at all, so there is no Material host left to restyle",
+ !gameDetail.contains("BottomSheetDialog(") &&
+ !gameDetail.contains("AlertDialog.Builder") &&
+ !gameDetail.contains("sheet.setBackgroundResource")
+ )
+ assertTrue(
+ "a destination is a surface, not media: its glass must sit on an opaque ground or the artwork reads straight through it",
+ gameDetail.contains(".background(colors.window)\n .background(surfaces.panel)") ||
+ gameDetail.contains(".background(colors.window)\n .background(surfaces.panel)")
)
assertTrue(
"Polaris sync sheet must use the same theme-aware host chrome instead of static nova_sheet_bg inset background",
@@ -181,7 +192,9 @@ class NovaLaunchSourceGuardTest {
@Test
fun virtualLaunchPreflightUsesHostVirtualDisplayContractConstants() {
val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") +
- readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt")
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt")
val trampoline = readSource("src/main/java/com/papi/nova/ShortcutTrampoline.kt")
val displayMode = readSource("src/main/java/com/papi/nova/api/PolarisStreamDisplayMode.kt")
@@ -343,7 +356,9 @@ class NovaLaunchSourceGuardTest {
@Test
fun displayPlannerAndPostSessionReportStayControllerFirstAndLowNoise() {
val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") +
- readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt")
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt")
val quickContent = readSource("src/main/java/com/papi/nova/ui/NovaQuickMenuContent.kt")
val planner = readSource("src/main/java/com/papi/nova/ui/NovaDisplayResolutionPlanner.kt")
val optionSheet = detail.section(
@@ -489,7 +504,9 @@ class NovaLaunchSourceGuardTest {
@Test
fun virtualDisplayUnavailableCopyUsesHostVirtualDisplayLanguageAndReason() {
val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") +
- readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt")
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt")
val strings = readSource("src/main/res/values/strings.xml")
assertTrue(strings.contains("nova_library_virtual_display_unavailable_title") && strings.contains("Host Virtual Display is not ready"))
@@ -625,11 +642,10 @@ class NovaLaunchSourceGuardTest {
@Test
fun gameDetailPreflightPreservesExplicitPolarisNonVirtualMode() {
- val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") +
- readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt")
+ val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt")
val preflight = detail.section(
"private fun syncLaunchPreflightSettings(",
- "private fun showPreflightReview("
+ "private fun modeLabel("
)
assertTrue(
@@ -659,7 +675,9 @@ class NovaLaunchSourceGuardTest {
@Test
fun steamLaunchSelectionDoesNotDismissGameDetailOrStartStream() {
val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") +
- readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt")
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt")
val selection = detail.section(
"onSteamLaunchModeSelected = { selected ->",
"},\n onDismissSteamLaunchModeOptions"
@@ -674,7 +692,9 @@ class NovaLaunchSourceGuardTest {
fun steamLaunchModeUpdateConfirmsHostModeAndStaysInline() {
val api = readSource("src/main/java/com/papi/nova/api/PolarisApiClient.kt")
val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") +
- readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt")
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt")
assertTrue(api.contains("fun setSteamLaunchMode(gameId: String, mode: String): String?"))
assertTrue(api.contains("json.optString(\"mode\", normalizedMode)"))
diff --git a/app/src/test/java/com/papi/nova/ui/NovaThemeResourcesTest.kt b/app/src/test/java/com/papi/nova/ui/NovaThemeResourcesTest.kt
index 78c82273..44cb4a3f 100644
--- a/app/src/test/java/com/papi/nova/ui/NovaThemeResourcesTest.kt
+++ b/app/src/test/java/com/papi/nova/ui/NovaThemeResourcesTest.kt
@@ -184,7 +184,9 @@ class NovaThemeResourcesTest {
val pcView = File("src/main/java/com/papi/nova/PcView.kt").readText()
val appView = File("src/main/java/com/papi/nova/AppView.kt").readText()
val gameDetail = File("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt").readText() +
- File("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt").readText()
+ File("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt").readText() +
+ File("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt").readText() +
+ File("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt").readText()
val polarisSync = File("src/main/java/com/papi/nova/ui/NovaPolarisSyncSheet.kt").readText()
val library = File("src/main/java/com/papi/nova/ui/NovaLibraryActivity.kt").readText()
val contextSheet = File("src/main/res/layout/nova_app_context_sheet.xml").readText()
@@ -257,7 +259,9 @@ class NovaThemeResourcesTest {
val sheetChrome = File("src/main/java/com/papi/nova/ui/NovaSheetChrome.kt").readText()
val composeTheme = File("src/main/java/com/papi/nova/ui/compose/NovaComposeTheme.kt").readText()
val gameDetail = File("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt").readText() +
- File("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt").readText()
+ File("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt").readText() +
+ File("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt").readText() +
+ File("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt").readText()
assertTrue("native sheet chrome must expose a named glass alpha contract", sheetChrome.contains("SHEET_GLASS_ALPHA"))
assertTrue("native sheet backgrounds should preserve theme color while applying the absolute outer opacity", sheetChrome.contains("ColorUtils.setAlphaComponent") && sheetChrome.contains("NovaMenuPreferences.outerSurfaceAlpha"))
@@ -390,7 +394,9 @@ class NovaThemeResourcesTest {
val lifecycle = File("src/main/java/com/papi/nova/ui/NovaStreamOverlayContent.kt").readText()
val library = File("src/main/java/com/papi/nova/ui/NovaLibraryActivity.kt").readText()
val gameDetail = File("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt").readText() +
- File("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt").readText()
+ File("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt").readText() +
+ File("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt").readText() +
+ File("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt").readText()
val settings = File("src/main/java/com/papi/nova/preferences/NovaSettingsScreen.kt").readText()
val focusComponents = File("src/main/java/com/papi/nova/ui/compose/NovaFocusComponents.kt").readText()
val settingsViewModel = File("src/main/java/com/papi/nova/preferences/NovaSettingsViewModel.kt").readText()
@@ -453,14 +459,19 @@ class NovaThemeResourcesTest {
@Test
fun requiredNativeAlertsUseSharedOpacityAndBlurChrome() {
val gameDetail = File("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt").readText() +
- File("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt").readText()
+ File("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt").readText() +
+ File("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt").readText() +
+ File("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt").readText()
val legacySlider = File("src/main/java/com/papi/nova/preferences/SeekBarPreference.kt").readText()
val sessionDialog = File("src/main/java/com/papi/nova/utils/Dialog.kt").readText()
val preflight = gameDetail
.substringAfter("private fun showPreflightReview(")
.substringBefore("private fun showDesktopSteamLaunchDecision(")
- assertTrue("game-detail preflight should use shared literal-opacity alert chrome", preflight.contains("NovaSheetChrome.applyMenuOpacityToLegacyAlert"))
+ assertTrue(
+ "the game detail window raises no legacy alert at all: the preflight review expands the status line in place instead",
+ !preflight.contains("AlertDialog.Builder") && preflight.contains("reviewExpanded")
+ )
assertTrue("legacy sliders, including Menu & Drawer Opacity, should use shared literal-opacity alert chrome", legacySlider.contains("NovaSheetChrome.applyMenuOpacityToLegacyAlert(createdDialog)"))
assertTrue("session termination/error alerts should use shared literal-opacity alert chrome", sessionDialog.contains("NovaSheetChrome.applyMenuOpacityToLegacyAlert(createdAlert)"))
}
@@ -547,7 +558,9 @@ class NovaThemeResourcesTest {
val pcView = File("src/main/java/com/papi/nova/PcView.kt").readText()
val appView = File("src/main/java/com/papi/nova/AppView.kt").readText()
val gameDetail = File("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt").readText() +
- File("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt").readText()
+ File("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt").readText() +
+ File("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt").readText() +
+ File("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt").readText()
val manager = File("src/main/java/com/papi/nova/ui/NovaThemeManager.kt").readText()
val particles = File("src/main/java/com/papi/nova/ui/SpaceParticleView.kt").readText()
diff --git a/shared/polaris/model/src/commonMain/kotlin/com/papi/nova/shared/polaris/model/PolarisGame.kt b/shared/polaris/model/src/commonMain/kotlin/com/papi/nova/shared/polaris/model/PolarisGame.kt
index c6e6eb6c..9fdc2a0f 100644
--- a/shared/polaris/model/src/commonMain/kotlin/com/papi/nova/shared/polaris/model/PolarisGame.kt
+++ b/shared/polaris/model/src/commonMain/kotlin/com/papi/nova/shared/polaris/model/PolarisGame.kt
@@ -26,7 +26,20 @@ data class PolarisGame(
@SerialName("launch_mode") val launchMode: LaunchModeContract? = null,
@SerialName("steam_launch") val steamLaunch: SteamLaunchContract? = null,
@SerialName("display_planner") val displayPlanner: DisplayPlannerContract? = null,
- @SerialName("artwork") val artwork: ArtworkManifest? = null
+ @SerialName("artwork") val artwork: ArtworkManifest? = null,
+ /**
+ * How long the owning launcher says this has been played, or null when none can say.
+ *
+ * Null rather than zero: a game nobody has played and a game no launcher owns are
+ * different answers, and only one of them should read "Not started".
+ *
+ * Last in the list on purpose. Sixteen places build this positionally, so a field
+ * inserted beside lastLaunched where it reads best would silently shift every one of
+ * them. Serialisation is by name, so position costs nothing here.
+ */
+ @SerialName("play_time") val playTime: PlayTime? = null,
+ /** Completion estimates, or null when the host's dataset has nothing for this game. */
+ @SerialName("beat_time") val beatTime: BeatTime? = null
) {
@Serializable
data class ArtworkManifest(
@@ -41,6 +54,34 @@ data class PolarisGame(
fun asset(kind: String): ArtworkAsset? = assets.asset(kind)
}
+ /**
+ * What the host's dataset says about finishing this game.
+ *
+ * Every figure is optional on its own: a catalogue that knows the main story but not
+ * the completionist run should say so rather than pad the gap with a zero.
+ */
+ @Serializable
+ data class BeatTime(
+ @SerialName("main_seconds") val mainSeconds: Long = 0,
+ @SerialName("extras_seconds") val extrasSeconds: Long = 0,
+ @SerialName("completionist_seconds") val completionistSeconds: Long = 0,
+ @SerialName("matched_name") val matchedName: String = "",
+ @SerialName("url") val url: String = "",
+ @SerialName("cached_at") val cachedAt: Long = 0
+ ) {
+ /** The bar's full width, falling back through what is actually known. */
+ val longestSeconds: Long
+ get() = maxOf(completionistSeconds, extrasSeconds, mainSeconds)
+ }
+
+ /** What a launcher says about time spent, normalised to seconds before it travels. */
+ @Serializable
+ data class PlayTime(
+ @SerialName("seconds") val seconds: Long = 0,
+ @SerialName("source") val source: String = "",
+ @SerialName("read_at") val readAt: Long = 0
+ )
+
@Serializable
data class ArtworkMatch(
@SerialName("source") val source: String = "",