From d1f7e16208a47121db64617b903a0064943183c5 Mon Sep 17 00:00:00 2001
From: papi <20916260+papi-ux@users.noreply.github.com>
Date: Wed, 5 Aug 2026 17:01:31 -0400
Subject: [PATCH] feat(nova): open game detail in its own window instead of a
bottom sheet
Opening a game raised a BottomSheetDialogFragment of just over 3,000 lines that
mixed sheet plumbing with the Compose content it hosted. This gives the detail its
own Activity and splits the file along the seam that was already there.
The Compose half moves verbatim into NovaGameDetailContent.kt so the ordering the
source guards assert is unchanged. The fragment half becomes NovaGameDetailActivity,
which keeps the constructor lambdas the sheet was built with as properties of the
same name and shape, so the 500-line body that used them needed context and
lifecycle substitutions rather than a rewrite.
The window returns the launch it chose rather than performing it, so the stream
starts from the library after the window is gone. Two payloads travel back, mirroring
the two paths the sheet already had: artwork edits merge into the library list, and
the launched game goes to the launch. Passing only an id and looking the game back up
would have dropped the MangoHUD choice made in that window.
PolarisGame is @Serializable, so the game travels as JSON rather than through a
hand-written mirror of PolarisGameJsonAdapter that could drift from it. The
serialization runtime is already on the APK's runtime classpath via the shared model;
this only puts it on the app's compile classpath.
expandBottomSheet sized whatever the fragment's root view was, which was right for
the detail sheet and incidental for the desktop-Steam dialog that also called it.
That dialog is the only sheet the window still hosts, so the helper now sizes the
content view it is handed.
Behaviour is otherwise unchanged; the cinematic restructure is separate.
---
app/build.gradle | 1 +
app/lint-baseline.xml | 30 -
...seTest.kt => NovaGameDetailComposeTest.kt} | 5 +-
app/src/main/AndroidManifest.xml | 5 +
.../java/com/papi/nova/api/PolarisGameJson.kt | 28 +
.../papi/nova/ui/NovaGameDetailActivity.kt | 1386 +++++++++++++++++
...etailSheet.kt => NovaGameDetailContent.kt} | 1217 +--------------
.../com/papi/nova/ui/NovaLibraryActivity.kt | 81 +-
.../ui/NovaArtworkStudioSourceGuardTest.kt | 39 +-
.../nova/ui/NovaComposeSourceGuardTest.kt | 41 +-
.../papi/nova/ui/NovaLaunchSourceGuardTest.kt | 40 +-
.../papi/nova/ui/NovaThemeResourcesTest.kt | 15 +-
12 files changed, 1549 insertions(+), 1339 deletions(-)
rename app/src/androidTest/java/com/papi/nova/ui/{NovaGameDetailSheetComposeTest.kt => NovaGameDetailComposeTest.kt} (97%)
create mode 100644 app/src/main/java/com/papi/nova/api/PolarisGameJson.kt
create mode 100644 app/src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt
rename app/src/main/java/com/papi/nova/ui/{NovaGameDetailSheet.kt => NovaGameDetailContent.kt} (51%)
diff --git a/app/build.gradle b/app/build.gradle
index db66f2bb3..e6f220027 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -275,6 +275,7 @@ dependencies {
implementation 'com.squareup.okhttp3:okhttp:5.3.2'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2'
implementation project(':shared:polaris:model')
+ implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3'
implementation 'org.jmdns:jmdns:3.6.3'
implementation 'com.github.cgutman:ShieldControllerExtensions:1.0.1'
implementation 'com.google.code.gson:gson:2.13.2'
diff --git a/app/lint-baseline.xml b/app/lint-baseline.xml
index 5025c4a82..f4ea22680 100644
--- a/app/lint-baseline.xml
+++ b/app/lint-baseline.xml
@@ -6052,16 +6052,6 @@
column="25"/>
-
-
-
-
-
-
-
-
-
+
+
Unit)? =
+ { game, withVirtualDisplay, mirrorDesktop, forcePrivateAfterSteamClose, profilePreference, preflight ->
+ setResult(
+ RESULT_OK,
+ Intent().putExtra(
+ EXTRA_RESULT_LAUNCH,
+ JSONObject()
+ .put(RESULT_KEY_VIRTUAL_DISPLAY, withVirtualDisplay)
+ .put(RESULT_KEY_MIRROR_DESKTOP, mirrorDesktop)
+ .put(RESULT_KEY_FORCE_PRIVATE, forcePrivateAfterSteamClose)
+ .put(RESULT_KEY_PROFILE_PREFERENCE, profilePreference)
+ .put(RESULT_KEY_PREFLIGHT, preflight ?: JSONObject.NULL)
+ .toString(),
+ )
+ .putExtra(EXTRA_RESULT_LAUNCH_GAME, PolarisGameJson.encode(game))
+ .putExtra(EXTRA_RESULT_GAME, updatedGame?.let { PolarisGameJson.encode(it) }),
+ )
+ }
+
+ private val onGameUpdated: ((PolarisGame) -> Unit)? = { game -> updatedGame = game }
+
+ private val onRefreshArtwork: ((PolarisGame, (NovaArtworkMutationResult) -> Unit) -> Unit)? =
+ { game, onResult -> artworkViewModel.refreshArtwork(game = game, onResult = onResult) }
+
+ private val onApplyArtwork: ((
+ PolarisGame,
+ PolarisArtworkMatchCandidate,
+ Map,
+ (NovaArtworkMutationResult) -> Unit,
+ ) -> Unit)? = { game, candidate, selections, onResult ->
+ artworkViewModel.applyArtworkSelections(
+ game = game,
+ candidate = candidate,
+ selections = selections,
+ onResult = onResult,
+ )
+ }
+
+ private val onClearArtwork: ((PolarisGame, (NovaArtworkMutationResult) -> Unit) -> Unit)? =
+ { game, onResult -> artworkViewModel.clearArtworkOverride(game = game, onResult = onResult) }
+
+ /** Artwork and MangoHUD edits made here; handed back so the library can merge them. */
+ private var updatedGame: PolarisGame? = null
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ NovaThemeManager.applyTheme(this)
+ super.onCreate(savedInstanceState)
+
+ val host = intent.getStringExtra(EXTRA_HOST).orEmpty()
+ val httpsPort = intent.getIntExtra(EXTRA_HTTPS_PORT, DEFAULT_HTTPS_PORT)
+ val serverCert = intent.getByteArrayExtra(EXTRA_SERVER_CERT)
+ val game = intent.getStringExtra(EXTRA_GAME)?.let { PolarisGameJson.decode(it) }
+ if (host.isBlank() || game == null) {
+ LimeLog.warning("Nova: Game detail opened without a host or game; closing")
+ finish()
+ return
+ }
+ defaultToVirtualDisplay = intent.getBooleanExtra(EXTRA_DEFAULT_VIRTUAL_DISPLAY, false)
+
+ apiClient = PolarisApiClient(this, host, httpsPort, serverCert)
+ artworkViewModel = ViewModelProvider(
+ this,
+ NovaArtworkLibraryUpdateViewModel.Factory(
+ context = applicationContext,
+ serverAddress = host,
+ httpsPort = httpsPort,
+ serverCertDer = serverCert,
+ ),
+ )[NovaArtworkLibraryUpdateViewModel::class.java]
+
+ onBackPressedDispatcher.addCallback(
+ this,
+ object : OnBackPressedCallback(true) {
+ override fun handleOnBackPressed() {
+ publishGameUpdate()
+ finish()
+ }
+ },
+ )
+
+ setUpDetail(game, apiClient)
+ }
+
+ /** Carries artwork or MangoHUD edits back even when the window closes without launching. */
+ private fun publishGameUpdate() {
+ val game = updatedGame ?: return
+ setResult(RESULT_OK, Intent().putExtra(EXTRA_RESULT_GAME, PolarisGameJson.encode(game)))
+ }
+
+ override fun finish() {
+ super.finish()
+ NovaThemeManager.applyBackTransition(this)
+ }
+
+ private fun canPublishArtworkMutationUi(): Boolean =
+ canPublishArtworkMutationUiForState(lifecycle.currentState)
+
+ private fun setUpDetail(game: PolarisGame, apiClient: PolarisApiClient) {
+ val deviceName = DeviceUtils.getModel()
+
+ var currentGame by mutableStateOf(game)
+ var profilePreference by mutableStateOf(loadProfilePreference(currentGame))
+ var uiState by mutableStateOf(buildUiState(currentGame, profilePreference))
+ var mangoHudEnabled by mutableStateOf(game.mangohud)
+ var resetWorking by mutableStateOf(false)
+ var optimizationState by mutableStateOf(NovaGameDetailOptimizationState())
+ var launchOptionsState by mutableStateOf(null)
+ var profileOptionsState by mutableStateOf(null)
+ var steamLaunchOptionsState by mutableStateOf(null)
+ var artworkState by mutableStateOf(loadArtworkState(game))
+
+ fun refreshUiState(preference: String = profilePreference) {
+ uiState = buildUiState(currentGame, preference)
+ }
+
+ fun acceptArtwork(manifest: PolarisGame.ArtworkManifest) {
+ val nextChoiceGeneration = artworkState.choiceGeneration + 1
+ currentGame = currentGame.copy(artwork = manifest)
+ refreshUiState()
+ artworkState = loadArtworkState(currentGame).copy(choiceGeneration = nextChoiceGeneration)
+ onGameUpdated?.invoke(currentGame)
+ }
+
+ fun loadArtworkChoices(candidate: PolarisArtworkMatchCandidate, kind: String) {
+ val normalizedKind = kind.trim().lowercase().takeIf { it in NovaArtworkKinds.ALL } ?: return
+ if (normalizedKind in artworkState.loadedKinds || normalizedKind in artworkState.loadingKinds) return
+ val generation = artworkState.choiceGeneration
+ artworkState = artworkState.reduce(
+ NovaArtworkStudioAction.ChoicesLoading(candidate, normalizedKind, generation),
+ )
+ lifecycleScope.launch {
+ try {
+ val choices = withContext(Dispatchers.IO) {
+ apiClient.listArtworkChoices(currentGame.id, candidate, normalizedKind)
+ }
+ artworkState = artworkState.reduce(
+ NovaArtworkStudioAction.ChoicesLoaded(
+ candidate = candidate,
+ kind = normalizedKind,
+ choices = choices,
+ emptyMessage = if (choices.isEmpty()) getString(R.string.nova_artwork_no_choices) else "",
+ generation = generation,
+ ),
+ )
+ } catch (e: CancellationException) {
+ throw e
+ } catch (_: Exception) {
+ artworkState = artworkState.reduce(
+ NovaArtworkStudioAction.ChoicesFailed(
+ message = getString(R.string.nova_artwork_choices_failed),
+ candidate = candidate,
+ kind = normalizedKind,
+ generation = generation,
+ ),
+ )
+ }
+ }
+ }
+
+
+ fun loadOptimization(preference: String, usesVirtualDisplay: Boolean = uiState.playUsesVirtualDisplay) {
+ LimeLog.info(
+ "Nova: Preflight optimization requested game=${currentGame.name} " +
+ "preference=$preference virtualDisplay=$usesVirtualDisplay"
+ )
+ android.util.Log.i(
+ "NovaPreflight",
+ "requested game=${currentGame.name} preference=$preference virtualDisplay=$usesVirtualDisplay"
+ )
+ lifecycleScope.launch {
+ optimizationState = try {
+ val opt = withContext(Dispatchers.IO) {
+ syncLaunchPreflightSettings(this@NovaGameDetailActivity, apiClient, usesVirtualDisplay, clientSettings)?.let {
+ clientSettings = it
+ }
+ apiClient.getOptimization(deviceName, currentGame.name, preference)
+ }
+ logPreflightOptimization("Preflight optimization", opt, preference)
+ buildOptimizationState(opt, preference)
+ } catch (e: Exception) {
+ LimeLog.warning("Nova: Preflight optimization failed: ${e.message}")
+ NovaGameDetailOptimizationState()
+ }
+ }
+ }
+
+ fun retryHighFpsTrial() {
+ profilePreference = "high_fps"
+ saveProfilePreference(currentGame, profilePreference)
+ refreshUiState(profilePreference)
+ lifecycleScope.launch {
+ optimizationState = try {
+ val opt = withContext(Dispatchers.IO) {
+ syncLaunchPreflightSettings(this@NovaGameDetailActivity, apiClient, uiState.playUsesVirtualDisplay, clientSettings)?.let {
+ clientSettings = it
+ }
+ apiClient.getOptimization(deviceName, currentGame.name, profilePreference, "high_fps")
+ }
+ logPreflightOptimization("High FPS trial preflight", opt, profilePreference)
+ buildOptimizationState(opt, profilePreference)
+ } catch (e: Exception) {
+ LimeLog.warning("Nova: High FPS trial preflight failed: ${e.message}")
+ NovaGameDetailOptimizationState()
+ }
+ }
+ }
+
+ fun selectLaunchMode(mode: String) {
+ val allowed = when (mode) {
+ "virtual_display" -> uiState.virtualDisplayAllowed && !uiState.virtualDisplayUnavailable
+ else -> uiState.headlessAllowed
+ }
+ if (!allowed || mode == uiState.playMode) return
+
+ val previousLaunchMode = currentGame.launchMode
+ val allowedModes = previousLaunchMode?.allowedModes
+ ?.takeIf { it.isNotEmpty() }
+ ?: listOf("headless", "virtual_display")
+ val updatedLaunchMode = (previousLaunchMode ?: PolarisGame.LaunchModeContract()).copy(
+ preferredMode = mode,
+ allowedModes = allowedModes
+ )
+ currentGame = currentGame.copy(launchMode = updatedLaunchMode)
+ refreshUiState()
+ optimizationState = NovaGameDetailOptimizationState()
+ loadOptimization(profilePreference, usesVirtualDisplay = mode == "virtual_display")
+ }
+
+ setContentView(
+ ComposeView(this).apply {
+ setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnDetachedFromWindow)
+ setContent {
+ NovaComposeTheme {
+ NovaGameDetailContent(
+ uiState = uiState,
+ launchIntro = buildLaunchIntro(uiState),
+ recommendedBadge = getString(
+ R.string.nova_library_launch_recommended_mode_badge,
+ modeBadgeLabel(uiState.recommendedMode)
+ ),
+ lastPlayedText = lastPlayedText(currentGame),
+ profilePreferenceLabel = getString(AutoQualityProfilePreferences.labelRes(profilePreference)),
+ resetProfileLabel = getString(
+ if (resetWorking) {
+ R.string.nova_library_reset_game_profile_working
+ } else {
+ R.string.nova_library_reset_game_profile
+ }
+ ),
+ resetProfileWorking = resetWorking,
+ mangoHudEnabled = mangoHudEnabled,
+ mangoHudStatusLabel = getString(R.string.nova_mangohud_enabled_status),
+ mangoHudStatusCaption = getString(R.string.nova_mangohud_novahud_caption),
+ mangoHudWarning = uiState.mangoHudRisk != NovaGameDetailUiState.MangoHudRisk.NONE,
+ steamLaunchLabel = getString(R.string.nova_steam_launch_detail_label),
+ steamLaunchModeLabel = steamLaunchModeLabel(uiState.steamLaunchMode),
+ steamLaunchCaption = steamLaunchCaption(uiState),
+ optimizationState = optimizationState,
+ launchOptionsState = launchOptionsState,
+ profileOptionsState = profileOptionsState,
+ playLabel = if (optimizationState.reviewRequired) {
+ getString(R.string.nova_library_review_and_launch)
+ } else {
+ optimizationState.profileSummary
+ ?.primaryLaunchLabel
+ ?.takeIf { it.isNotBlank() }
+ ?: primaryPlayLabel(uiState)
+ },
+ launchOptionsLabel = getString(R.string.nova_library_launch_options_secondary),
+ launchModeTitle = getString(R.string.nova_library_launch_mode_title),
+ headlessModeLabel = modeBadgeLabel("headless"),
+ virtualDisplayModeLabel = modeBadgeLabel("virtual_display"),
+ 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(
+ 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)
+ }
+ },
+ onLaunchOptions = {
+ val nextState = showLaunchOptions(currentGame, uiState)
+ if (nextState == null) {
+ Toast.makeText(this@NovaGameDetailActivity, R.string.nova_library_no_launch_modes, Toast.LENGTH_SHORT).show()
+ } else {
+ launchOptionsState = nextState
+ profileOptionsState = null
+ }
+ },
+ onLaunchModeSelected = ::selectLaunchMode,
+ onLaunchOptionSelected = { option ->
+ fun launchSelected(mirrorDesktop: Boolean, forcePrivateAfterSteamClose: Boolean = false) {
+ val selectedLaunchOptimization = option.launchOptimization ?: optimizationState.rawOptimization
+ onLaunch?.invoke(
+ currentGame.copy(mangohud = mangoHudEnabled),
+ option.usesVirtualDisplay,
+ mirrorDesktop,
+ forcePrivateAfterSteamClose,
+ profilePreference,
+ selectedLaunchOptimization
+ )
+ launchOptionsState = null
+ finish()
+ }
+ val desktopSteamDecision = NovaDesktopSteamLaunchDecision.from(
+ uiState,
+ optimizationState.rawOptimization,
+ 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) }
+ )
+ } else {
+ launchSelected(mirrorDesktop = false)
+ }
+ },
+ onDismissLaunchOptions = {
+ launchOptionsState = null
+ },
+ onProfilePreference = {
+ profileOptionsState = showProfilePreferenceOptions(currentGame)
+ launchOptionsState = null
+ },
+ onProfilePreferenceSelected = { selected ->
+ saveProfilePreference(currentGame, selected.value)
+ profilePreference = selected.value
+ refreshUiState(selected.value)
+ optimizationState = NovaGameDetailOptimizationState()
+ profileOptionsState = null
+ loadOptimization(selected.value)
+ },
+ onDismissProfileOptions = {
+ profileOptionsState = null
+ },
+ onRetryHighFps = { retryHighFpsTrial() },
+ onResetProfile = {
+ resetWorking = true
+ lifecycleScope.launch {
+ val cleared = withContext(Dispatchers.IO) {
+ apiClient.clearOptimizerProfile(deviceName, currentGame.name)
+ }
+ val sheetContext = this@NovaGameDetailActivity
+ if (cleared == true) {
+ optimizationState = NovaGameDetailOptimizationState()
+ }
+ val message = when (cleared) {
+ true -> R.string.nova_library_reset_game_profile_cleared
+ false -> R.string.nova_library_reset_game_profile_empty
+ null -> R.string.nova_library_reset_game_profile_failed
+ }
+ Toast.makeText(sheetContext, message, Toast.LENGTH_SHORT).show()
+ resetWorking = false
+ }
+ },
+ steamLaunchOptionsState = steamLaunchOptionsState,
+ onSteamLaunchMode = {
+ steamLaunchOptionsState = steamLaunchModeOptionsState(currentGame)
+ },
+ onSteamLaunchModeSelected = { selected ->
+ val previousGame = currentGame
+ val requestedMode = PolarisGame.SteamLaunchContract.normalizeMode(selected.value)
+ if (requestedMode == previousGame.steamLaunchMode) {
+ steamLaunchOptionsState = null
+ return@NovaGameDetailContent
+ }
+
+ currentGame = previousGame.copy(
+ steamLaunch = previousGame.steamLaunch?.copy(mode = requestedMode)
+ )
+ refreshUiState()
+ lifecycleScope.launch {
+ val confirmedMode = withContext(Dispatchers.IO) {
+ apiClient.setSteamLaunchMode(previousGame.id, requestedMode)
+ }
+ val message = if (confirmedMode != null) {
+ currentGame = currentGame.copy(
+ steamLaunch = currentGame.steamLaunch?.copy(mode = confirmedMode)
+ )
+ refreshUiState()
+ steamLaunchOptionsState = null
+ R.string.nova_steam_launch_mode_updated
+ } else {
+ currentGame = previousGame
+ refreshUiState()
+ steamLaunchOptionsState = steamLaunchModeOptionsState(previousGame)
+ R.string.nova_steam_launch_mode_failed
+ }
+ Toast.makeText(this@NovaGameDetailActivity, message, Toast.LENGTH_SHORT).show()
+ }
+ },
+ onDismissSteamLaunchModeOptions = {
+ steamLaunchOptionsState = null
+ },
+ artworkState = artworkState,
+ onRefreshArtwork = {
+ artworkState = artworkState.reduce(NovaArtworkStudioAction.MutationLoading)
+ this@NovaGameDetailActivity.onRefreshArtwork?.invoke(currentGame) mutationResult@{ result ->
+ if (!canPublishArtworkMutationUi()) return@mutationResult
+ when (result) {
+ is NovaArtworkMutationResult.Committed -> {
+ val manifest = result.game.artwork ?: return@mutationResult
+ acceptArtwork(manifest)
+ }
+ NovaArtworkMutationResult.Rejected,
+ NovaArtworkMutationResult.Failed -> {
+ artworkState = artworkState.reduce(
+ NovaArtworkStudioAction.Failed(
+ getString(R.string.nova_artwork_refresh_failed),
+ ),
+ )
+ }
+ }
+ } ?: run {
+ artworkState = artworkState.reduce(
+ NovaArtworkStudioAction.Failed(
+ getString(R.string.nova_artwork_refresh_failed),
+ ),
+ )
+ }
+ },
+ onSearchArtwork = { query ->
+ artworkState = artworkState.reduce(NovaArtworkStudioAction.SearchLoading)
+ lifecycleScope.launch {
+ try {
+ val candidates = withContext(Dispatchers.IO) {
+ apiClient.searchArtworkCandidates(currentGame.id, query)
+ }
+ artworkState = artworkState.reduce(
+ NovaArtworkStudioAction.SearchLoaded(
+ candidates,
+ if (candidates.isEmpty()) getString(R.string.nova_artwork_no_matches) else "",
+ ),
+ )
+ } catch (e: CancellationException) {
+ throw e
+ } catch (_: Exception) {
+ artworkState = artworkState.reduce(
+ NovaArtworkStudioAction.Failed(getString(R.string.nova_artwork_search_failed)),
+ )
+ }
+ }
+ },
+ onIdentitySelected = { candidate ->
+ artworkState = artworkState.reduce(NovaArtworkStudioAction.IdentitySelected(candidate))
+ loadArtworkChoices(candidate, NovaArtworkKinds.POSTER)
+ },
+ onIdentityChange = {
+ artworkState = artworkState.reduce(NovaArtworkStudioAction.IdentityChangeRequested)
+ },
+ onKindSelected = { kind ->
+ artworkState = artworkState.reduce(NovaArtworkStudioAction.KindSelected(kind))
+ artworkState.selectedCandidate?.let { loadArtworkChoices(it, kind) }
+ },
+ onChoiceSelected = { choice ->
+ artworkState = artworkState.reduce(NovaArtworkStudioAction.ChoiceSelected(choice))
+ },
+ onStudioAction = { action ->
+ artworkState = artworkState.reduce(action)
+ },
+ onApplyArtwork = { candidate, selections ->
+ artworkState = artworkState.reduce(NovaArtworkStudioAction.MutationLoading)
+ this@NovaGameDetailActivity.onApplyArtwork?.invoke(
+ currentGame,
+ candidate,
+ selections,
+ ) mutationResult@{ result ->
+ if (!canPublishArtworkMutationUi()) return@mutationResult
+ when (result) {
+ is NovaArtworkMutationResult.Committed -> {
+ val manifest = result.game.artwork ?: return@mutationResult
+ acceptArtwork(manifest)
+ }
+ NovaArtworkMutationResult.Rejected,
+ NovaArtworkMutationResult.Failed -> {
+ artworkState = artworkState.reduce(
+ NovaArtworkStudioAction.ApplyFailed(
+ getString(R.string.nova_artwork_apply_failed),
+ ),
+ )
+ }
+ }
+ } ?: run {
+ artworkState = artworkState.reduce(
+ NovaArtworkStudioAction.ApplyFailed(
+ getString(R.string.nova_artwork_apply_failed),
+ ),
+ )
+ }
+ },
+ onClearArtwork = {
+ artworkState = artworkState.reduce(NovaArtworkStudioAction.MutationLoading)
+ this@NovaGameDetailActivity.onClearArtwork?.invoke(currentGame) mutationResult@{ result ->
+ if (!canPublishArtworkMutationUi()) return@mutationResult
+ when (result) {
+ is NovaArtworkMutationResult.Committed -> {
+ val manifest = result.game.artwork ?: return@mutationResult
+ acceptArtwork(manifest)
+ }
+ NovaArtworkMutationResult.Rejected,
+ NovaArtworkMutationResult.Failed -> {
+ artworkState = artworkState.reduce(
+ NovaArtworkStudioAction.Failed(
+ getString(R.string.nova_artwork_clear_failed),
+ ),
+ )
+ }
+ }
+ } ?: run {
+ artworkState = artworkState.reduce(
+ NovaArtworkStudioAction.Failed(
+ getString(R.string.nova_artwork_clear_failed),
+ ),
+ )
+ }
+ },
+ onLogoTransform = { scale, x, y ->
+ artworkState = artworkState.copy(logoScale = scale, logoX = x, logoY = y)
+ saveArtworkTransform(currentGame.id, scale, x, y)
+ },
+ candidatePreviewLoader = apiClient::loadArtworkCandidatePreviewInto,
+ choicePreviewLoader = apiClient::loadArtworkChoicePreviewInto,
+ currentArtworkPresentationKey = { kind ->
+ PolarisApiClient.artworkPresentationKey(currentGame, kind)
+ },
+ currentArtworkLoader = { imageView, kind ->
+ apiClient.loadArtworkInto(imageView, currentGame, kind)
+ },
+
+
+ heroAvailable = currentGame.heroArtwork?.cached == true,
+ heroPresentationKey = PolarisApiClient.artworkPresentationKey(currentGame, PolarisGame.ARTWORK_KIND_HERO),
+ heroLoader = { imageView -> apiClient.loadArtworkInto(imageView, currentGame, PolarisGame.ARTWORK_KIND_HERO) },
+ heroContentDescription = getString(R.string.nova_artwork_hero_content_description, currentGame.name),
+ logoAvailable = currentGame.logoArtwork?.cached == true,
+ logoPresentationKey = PolarisApiClient.artworkPresentationKey(currentGame, PolarisGame.ARTWORK_KIND_LOGO),
+ logoLoader = { imageView -> apiClient.loadArtworkInto(imageView, currentGame, PolarisGame.ARTWORK_KIND_LOGO) },
+ logoContentDescription = getString(R.string.nova_artwork_logo_content_description, currentGame.name),
+ iconAvailable = currentGame.iconArtwork?.cached == true,
+ iconPresentationKey = PolarisApiClient.artworkPresentationKey(currentGame, PolarisGame.ARTWORK_KIND_ICON),
+ iconLoader = { imageView -> apiClient.loadArtworkInto(imageView, currentGame, PolarisGame.ARTWORK_KIND_ICON) },
+ iconContentDescription = getString(R.string.nova_artwork_icon_content_description, currentGame.name),
+ coverLoader = { imageView ->
+ apiClient.loadCoverInto(imageView, currentGame)
+ }
+ )
+ }
+ }
+ }
+ )
+
+ loadOptimization(profilePreference)
+
+ }
+
+ private fun buildUiState(game: PolarisGame, profilePreference: String): NovaGameDetailUiState {
+ return NovaGameDetailUiState.from(
+ game = game,
+ defaultToVirtualDisplay = defaultToVirtualDisplay,
+ clientSettings = clientSettings,
+ profilePreference = profilePreference
+ )
+ }
+
+ private fun loadProfilePreference(game: PolarisGame): String {
+ return AutoQualityProfilePreferences.load(this@NovaGameDetailActivity, game.name)
+ }
+
+ private fun saveProfilePreference(game: PolarisGame, preference: String) {
+ AutoQualityProfilePreferences.save(this@NovaGameDetailActivity, game.name, preference)
+ }
+
+ private fun loadArtworkState(game: PolarisGame): NovaArtworkStudioState {
+ val defaults = NovaArtworkStudioState.from(game)
+ val prefs = this@NovaGameDetailActivity.getSharedPreferences("nova_artwork", 0)
+ val key = "logo_${game.id}_"
+ return defaults.copy(
+ logoScale = prefs.getFloat("${key}scale", defaults.logoScale).coerceIn(0.25f, 4f),
+ logoX = prefs.getFloat("${key}x", defaults.logoX).coerceIn(0f, 1f),
+ logoY = prefs.getFloat("${key}y", defaults.logoY).coerceIn(0f, 1f),
+ )
+ }
+
+ private fun saveArtworkTransform(gameId: String, scale: Float, x: Float, y: Float) {
+ getSharedPreferences("nova_artwork", 0).edit {
+ putFloat("logo_${gameId}_scale", scale.coerceIn(0.25f, 4f))
+ putFloat("logo_${gameId}_x", x.coerceIn(0f, 1f))
+ putFloat("logo_${gameId}_y", y.coerceIn(0f, 1f))
+ }
+ }
+
+ private fun logPreflightOptimization(
+ label: String,
+ opt: JSONObject?,
+ preference: String
+ ) {
+ if (opt == null) {
+ LimeLog.warning("Nova: $label returned no profile for preference=$preference")
+ return
+ }
+
+ val profileState = opt.optJSONObject("profile_state")
+ val effective = opt.optJSONObject("effective_profile")
+ val selectedFps = opt.optDouble(
+ "effective_target_fps",
+ profileState
+ ?.optJSONObject("current_profile")
+ ?.optDouble("target_fps", 0.0)
+ ?: 0.0
+ )
+ LimeLog.info(
+ "Nova: $label loaded source=${opt.optString("source", "unknown")} " +
+ "cache=${opt.optString("cache_status", "unknown")} " +
+ "state=${profileState?.optString("state", "none") ?: "none"} " +
+ "effective=${effective?.optString("display_mode", "") ?: ""} " +
+ "fps=$selectedFps preference=$preference " +
+ "applied=${opt.optBoolean("preference_applied", false)} " +
+ "trial=${opt.optBoolean("trial_profile", false)}"
+ )
+ }
+
+ private fun showProfilePreferenceOptions(
+ game: PolarisGame
+ ): NovaProfilePreferenceOptionsState {
+ val values = AutoQualityProfilePreferences.values()
+ val current = loadProfilePreference(game)
+ val labels = values.map {
+ when (it) {
+ "quality" -> "Prefer Quality"
+ "high_fps" -> "Prefer High FPS"
+ "stability" -> "Prefer Stability"
+ else -> "Auto"
+ }
+ }
+ return NovaProfilePreferenceOptionsState(
+ title = getString(R.string.nova_library_profile_preference_title),
+ closeLabel = getString(R.string.nova_controller_hint_close),
+ options = values.mapIndexed { index, value ->
+ NovaProfilePreferenceItem(
+ label = labels[index],
+ value = value,
+ selected = value == current
+ )
+ }
+ )
+ }
+
+ private fun steamLaunchModeOptionsState(game: PolarisGame): NovaSteamLaunchModeOptionsState {
+ val modes = listOf("direct", "big-picture")
+ return NovaSteamLaunchModeOptionsState(
+ title = getString(R.string.nova_steam_launch_options_title),
+ subtitle = getString(R.string.nova_steam_launch_detail_label),
+ closeLabel = getString(R.string.nova_controller_hint_close),
+ options = modes.map { mode ->
+ val normalizedMode = PolarisGame.SteamLaunchContract.normalizeMode(mode)
+ NovaSteamLaunchModeItem(
+ label = steamLaunchModeLabel(normalizedMode),
+ value = normalizedMode,
+ selected = normalizedMode == game.steamLaunchMode
+ )
+ }
+ )
+ }
+
+ private fun showLaunchOptions(
+ game: PolarisGame,
+ uiState: NovaGameDetailUiState
+ ): NovaLaunchOptionsState? {
+ val options = mutableListOf()
+ val fallbackMode = clientSettings?.desired?.displayMode
+ ?.takeIf { it.isNotBlank() }
+ ?: clientSettings?.effective?.displayMode
+ ?: ""
+ val planner = NovaDisplayResolutionPlanner.from(
+ contract = game.displayPlanner,
+ fallbackMode = fallbackMode,
+ includeAdvanced = true
+ )
+ if (planner.available) {
+ planner.visibleChoices.forEach { choice ->
+ options += NovaLaunchOptionItem(
+ label = choice.title,
+ usesVirtualDisplay = uiState.playUsesVirtualDisplay,
+ recommended = choice.recommended,
+ caption = listOf(choice.targetMode, choice.reason).filter { it.isNotBlank() }.joinToString(" · "),
+ badge = choice.badge,
+ launchOptimization = NovaDisplayResolutionPlanner.buildLaunchOptimizationOverride(
+ choice,
+ source = "nova_display_planner"
+ )
+ )
+ }
+ } else {
+ if (uiState.headlessAllowed) {
+ options += NovaLaunchOptionItem(
+ label = optionLabel("headless", uiState.recommendedMode),
+ usesVirtualDisplay = false,
+ recommended = uiState.recommendedMode == "headless"
+ )
+ }
+ if (uiState.virtualDisplayAllowed) {
+ options += NovaLaunchOptionItem(
+ label = optionLabel("virtual_display", uiState.recommendedMode),
+ usesVirtualDisplay = true,
+ recommended = uiState.recommendedMode == "virtual_display"
+ )
+ }
+ }
+
+ if (options.isEmpty()) return null
+
+ return NovaLaunchOptionsState(
+ title = getString(R.string.nova_library_launch_options_title),
+ closeLabel = getString(R.string.nova_controller_hint_close),
+ gameName = game.name,
+ options = options
+ )
+ }
+
+ private fun optionLabel(mode: String, recommendedMode: String): String {
+ val label = modeLabel(mode)
+ return if (mode == recommendedMode) {
+ getString(R.string.nova_library_launch_recommended_format, label)
+ } else {
+ label
+ }
+ }
+
+ private fun syncLaunchPreflightSettings(
+ context: Context,
+ apiClient: PolarisApiClient,
+ usesVirtualDisplay: Boolean,
+ clientSettings: PolarisClientSettings?
+ ): PolarisClientSettings? {
+ val preferences = PreferenceConfiguration.readPreferences(context)
+ return apiClient.updateClientSettings(
+ streamDisplayMode = PolarisStreamDisplayMode.preflightModeForLaunch(usesVirtualDisplay, clientSettings),
+ displayMode = PreferenceConfiguration.formatStreamingDisplayMode(
+ preferences.width,
+ preferences.height,
+ preferences.fps
+ ),
+ targetBitrateKbps = preferences.bitrate.takeIf { it > 0 }
+ )
+ }
+
+ 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
+ NovaSheetChrome.applyBottomSheetChrome(bottomSheetDialog, contentView)
+ contentView.post {
+ val isLandscape = resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
+ val maxHeightRatio = if (isLandscape) 0.96f else 0.90f
+ val maxHeight = (resources.displayMetrics.heightPixels * maxHeightRatio).toInt()
+ val contentHeight = contentView.measuredHeight.takeIf { it > 0 } ?: return@post
+ val desiredHeight = contentHeight.coerceAtMost(maxHeight)
+ val displayWidth = resources.displayMetrics.widthPixels
+ val density = resources.displayMetrics.density
+ val desiredWidth = if (isLandscape) {
+ val minWidth = (720 * density).toInt()
+ val maxWidth = (1260 * density).toInt()
+ (displayWidth * 0.7f).toInt().coerceIn(minWidth, maxWidth)
+ } else {
+ displayWidth
+ }
+ val horizontalMargin = if (isLandscape) {
+ ((displayWidth - desiredWidth) / 2).coerceAtLeast((18 * density).toInt())
+ } else {
+ 0
+ }
+
+ contentView.layoutParams = contentView.layoutParams.apply {
+ height = if (contentHeight > maxHeight) desiredHeight else ViewGroup.LayoutParams.WRAP_CONTENT
+ }
+ sheet.layoutParams = sheet.layoutParams.apply {
+ width = if (isLandscape) displayWidth - (horizontalMargin * 2) else ViewGroup.LayoutParams.MATCH_PARENT
+ height = desiredHeight
+ }
+ (sheet.layoutParams as? ViewGroup.MarginLayoutParams)?.let { lp ->
+ lp.marginStart = horizontalMargin
+ lp.marginEnd = horizontalMargin
+ sheet.layoutParams = lp
+ }
+ sheet.minimumHeight = 0
+ sheet.requestLayout()
+
+ val behavior = BottomSheetBehavior.from(sheet)
+ behavior.isFitToContents = true
+ behavior.isDraggable = false
+ behavior.skipCollapsed = true
+ behavior.peekHeight = desiredHeight
+ behavior.state = BottomSheetBehavior.STATE_EXPANDED
+
+ when (contentView) {
+ is NestedScrollView -> contentView.post { contentView.scrollTo(0, 0) }
+ is ScrollView -> contentView.post { contentView.scrollTo(0, 0) }
+ }
+ }
+ }
+
+ private fun modeLabel(mode: String): String {
+ return when (mode) {
+ "virtual_display" -> getString(R.string.nova_library_launch_virtual_display)
+ else -> getString(R.string.nova_library_launch_headless)
+ }
+ }
+
+ private fun modeBadgeLabel(mode: String): String {
+ return when (mode) {
+ "virtual_display" -> getString(R.string.nova_library_launch_virtual_short)
+ else -> getString(R.string.nova_library_launch_headless)
+ }
+ }
+
+ private fun primaryPlayLabel(uiState: NovaGameDetailUiState): String {
+ return if (uiState.playEnabled) {
+ getString(R.string.nova_library_play_mode, modeBadgeLabel(uiState.playMode))
+ } else {
+ getString(R.string.nova_library_play_unavailable)
+ }
+ }
+
+ private fun steamLaunchModeLabel(mode: String): String {
+ return when (PolarisGame.SteamLaunchContract.normalizeMode(mode)) {
+ "big-picture" -> getString(R.string.nova_steam_launch_big_picture)
+ else -> getString(R.string.nova_steam_launch_direct)
+ }
+ }
+
+ private fun steamLaunchCaption(uiState: NovaGameDetailUiState): String {
+ return if (uiState.steamLaunchWarning) {
+ getString(R.string.nova_steam_launch_caption_big_picture)
+ } else {
+ getString(R.string.nova_steam_launch_caption_direct)
+ }
+ }
+
+ private fun buildLaunchIntro(uiState: NovaGameDetailUiState): String {
+ val parts = mutableListOf()
+ if (uiState.preferredMode != uiState.recommendedMode) {
+ parts += getString(R.string.nova_library_launch_preferred_mode_format, modeLabel(uiState.preferredMode))
+ }
+ if (uiState.hostStreamDisplayMode in setOf(
+ PolarisClientSettings.MODE_DESKTOP_DISPLAY,
+ PolarisClientSettings.MODE_GPU_NATIVE_TEST
+ ) && uiState.hostStreamDisplayModeLabel.isNotBlank()
+ ) {
+ parts += getString(R.string.nova_polaris_sync_host_mode_detail, uiState.hostStreamDisplayModeLabel)
+ }
+ parts += when {
+ uiState.virtualDisplayUnavailable -> {
+ val unavailableParts = mutableListOf(
+ getString(R.string.nova_library_virtual_display_unavailable_body)
+ )
+ uiState.virtualDisplayUnavailableReason
+ .takeIf { it.isNotBlank() }
+ ?.let {
+ unavailableParts += getString(
+ R.string.nova_library_virtual_display_unavailable_reason_format,
+ it
+ )
+ }
+ unavailableParts.joinToString(" ")
+ }
+ uiState.launchChoice.hostModeReason.isNotBlank() -> uiState.launchChoice.hostModeReason
+ uiState.game.launchMode?.modeReason?.isNotBlank() == true -> uiState.game.launchMode?.modeReason.orEmpty()
+ uiState.recommendedMode == "virtual_display" -> getString(R.string.nova_library_launch_intro_virtual_default)
+ else -> getString(R.string.nova_library_launch_intro_headless_default)
+ }
+ return parts.joinToString(" ")
+ }
+
+ private fun lastPlayedText(game: PolarisGame): String? {
+ if (game.lastLaunched <= 0) return null
+ val relative = DateUtils.getRelativeTimeSpanString(
+ game.lastLaunched * 1000,
+ System.currentTimeMillis(),
+ DateUtils.MINUTE_IN_MILLIS,
+ DateUtils.FORMAT_ABBREV_RELATIVE
+ )
+ return getString(R.string.nova_library_meta_last_played, relative)
+ }
+
+ private fun buildOptimizationState(
+ opt: JSONObject?,
+ profilePreference: String
+ ): NovaGameDetailOptimizationState {
+ if (opt == null) return NovaGameDetailOptimizationState()
+
+ val profileState = opt.optJSONObject("profile_state")
+ val currentProfile = profileState?.optJSONObject("current_profile") ?: opt.optJSONObject("effective_profile")
+ val lastResult = profileState?.optJSONObject("last_result")
+ val source = opt.optString("source", "")
+ val confidence = opt.optString("confidence", "")
+ val cacheStatus = opt.optString("cache_status", "")
+ val displayMode = currentProfile
+ ?.optString("display_mode", "")
+ ?.takeIf { it.isNotBlank() }
+ ?: opt.optString("display_mode", "")
+ val bitrate = currentProfile
+ ?.optInt("target_bitrate_kbps", 0)
+ ?.takeIf { it > 0 }
+ ?: opt.optInt("target_bitrate_kbps", 0)
+ val targetFps = currentProfile?.optDouble("target_fps", 0.0) ?: 0.0
+ val codec = currentProfile
+ ?.optString("preferred_codec", "")
+ ?.takeIf { it.isNotBlank() }
+ ?: opt.optString("preferred_codec", "")
+ val reasoning = opt.optString("reasoning", "")
+ val normalizationReason = opt.optString("normalization_reason", "")
+ val generatedAt = opt.optLong("generated_at", 0L)
+
+ val aiCard = if (displayMode.isNotEmpty() || codec.isNotEmpty() || profileState != null) {
+ val parts = mutableListOf()
+ if (displayMode.isNotEmpty()) parts.add(displayMode)
+ if (displayMode.isEmpty() && targetFps > 0.0) parts.add("${formatFps(targetFps)} FPS")
+ if (codec.isNotEmpty()) parts.add(codec.uppercase())
+ if (bitrate > 0) parts.add("up to ${bitrate / 1000} Mbps")
+ val settingsText = parts.joinToString(" · ").ifBlank { "Profile is being learned" }
+
+ val titleLabel = profileState
+ ?.optString("label", "")
+ ?.takeIf { it.isNotBlank() }
+ ?: when {
+ source.contains("ai_live") && cacheStatus.equals("invalidated", ignoreCase = true) ->
+ "Auto Quality Recovery"
+ source.contains("ai_cached") -> "Auto Quality Ready"
+ source.contains("ai_live") -> "Auto Quality Optimized"
+ source.contains("device_db") -> "Auto Quality Baseline"
+ else -> "Auto Quality"
+ }
+ val sourceLabel = when {
+ source.contains("ai_live") && cacheStatus.equals("invalidated", ignoreCase = true) ->
+ "Recovery"
+ source.contains("ai_cached") -> "Cached profile"
+ source.contains("ai_live") -> "Fresh profile"
+ source.contains("device_db") -> getString(R.string.nova_library_ai_baseline_source_label)
+ else -> source
+ }
+ val profileStateLabel = profileState
+ ?.optString("state", "")
+ ?.takeIf { it.isNotBlank() }
+ ?.let { profileStateLabel(it) }
+ .orEmpty()
+ val stateLabel = when {
+ profileStateLabel.isNotBlank() -> profileStateLabel
+ normalizationReason.isNotBlank() -> getString(R.string.nova_optimization_host_adjusted)
+ cacheStatus.equals("hit", ignoreCase = true) -> getString(R.string.nova_optimization_cached)
+ cacheStatus.equals("invalidated", ignoreCase = true) -> getString(R.string.nova_optimization_recovery)
+ cacheStatus.equals("miss", ignoreCase = true) -> getString(R.string.nova_optimization_fresh)
+ source.contains("device_db") -> getString(R.string.nova_optimization_device_tune)
+ else -> ""
+ }
+ val lastResultText = buildLastResultText(lastResult)
+ val generatedLabel = if (generatedAt > 0) {
+ DateUtils.getRelativeTimeSpanString(
+ generatedAt * 1000,
+ System.currentTimeMillis(),
+ DateUtils.MINUTE_IN_MILLIS,
+ DateUtils.FORMAT_ABBREV_RELATIVE
+ ).toString()
+ } else {
+ ""
+ }
+ val sourceText = listOf(
+ stateLabel.takeIf { it.isNotBlank() },
+ profileState?.optString("preference_label", "")?.takeIf { it.isNotBlank() },
+ lastResultText.takeIf { it.isNotBlank() },
+ sourceLabel.takeIf { it.isNotBlank() && sourceLabel != titleLabel },
+ confidence.takeIf { it.isNotBlank() }?.lowercase()?.plus(" confidence"),
+ generatedLabel.takeIf { it.isNotBlank() }
+ ).filter { !it.isNullOrBlank() }.joinToString(" · ")
+ val profileReason = profileState?.optString("reason", "").orEmpty()
+ val preferenceNote = profileState
+ ?.optString("preference_note", "")
+ ?.takeIf { profilePreference != "auto" }
+ .orEmpty()
+ val requestedFps = opt.optDouble("requested_target_fps", 0.0)
+ val effectiveFps = opt.optDouble("effective_target_fps", 0.0)
+ val requestedReason = if (requestedFps > 0.0 && effectiveFps > 0.0 && abs(requestedFps - effectiveFps) > 0.5) {
+ "Requested ${formatFps(requestedFps)} FPS, selected ${formatFps(effectiveFps)} FPS."
+ } else {
+ ""
+ }
+ val fullReasoning = listOf(profileReason, preferenceNote, requestedReason, reasoning, normalizationReason)
+ .filter { it.isNotBlank() }
+ .joinToString(" ")
+
+ NovaGameDetailInsightCard(
+ label = titleLabel,
+ source = sourceText,
+ settings = settingsText,
+ reasoning = fullReasoning,
+ isWarning = cacheStatus.equals("invalidated", ignoreCase = true)
+ )
+ } else {
+ null
+ }
+
+ val stabilityCard = opt.optJSONObject("stability")?.let { stability ->
+ val safeProfile = stability.optJSONObject("safe_profile")
+ val safeProfileParts = mutableListOf()
+ val safeCodec = safeProfile?.optString("preferred_codec", "").orEmpty()
+ if (safeCodec.isNotBlank()) {
+ safeProfileParts += safeCodec.uppercase()
+ }
+ val safeBitrate = safeProfile?.optInt("target_bitrate_kbps", 0) ?: 0
+ if (safeBitrate > 0) {
+ safeProfileParts += "${safeBitrate / 1000} Mbps"
+ }
+ val safeDisplayMode = safeProfile?.optString("display_mode", "").orEmpty()
+ if (safeDisplayMode.isNotBlank()) {
+ safeProfileParts += modeBadgeLabel(safeDisplayMode)
+ }
+ if (safeProfile?.has("hdr") == true && !safeProfile.optBoolean("hdr", false)) {
+ safeProfileParts += "HDR off"
+ }
+
+ val discouragedFeatures = stability.optJSONArray("discouraged_features")
+ val firstDiscouragedReason = if (discouragedFeatures != null && discouragedFeatures.length() > 0) {
+ discouragedFeatures.optJSONObject(0)?.optString("reason", "").orEmpty()
+ } else {
+ ""
+ }
+ val relaunchNotes = stability.optJSONArray("relaunch_notes")
+ val relaunchNote = if (relaunchNotes != null && relaunchNotes.length() > 0) {
+ relaunchNotes.optString(0)
+ } else {
+ ""
+ }
+ val stabilitySummary = stability.optString("summary", "")
+ val stabilityMode = stability.optString("mode", "")
+ val stabilityDetails = listOfNotNull(
+ stabilitySummary.takeIf { it.isNotBlank() },
+ firstDiscouragedReason.takeIf { it.isNotBlank() },
+ relaunchNote.takeIf { it.isNotBlank() }
+ ).joinToString(" ")
+
+ if (safeProfileParts.isNotEmpty() || stabilityDetails.isNotBlank()) {
+ val isStabilityFirst = stabilityMode.equals("stability_first", ignoreCase = true) ||
+ opt.optInt("consecutive_poor_outcomes", 0) > 0
+ val relaunchRequired = stability.optBoolean("relaunch_required", false)
+ NovaGameDetailInsightCard(
+ label = when {
+ isStabilityFirst -> "Recovery Profile"
+ relaunchRequired -> "Recovery Queued"
+ else -> "Safer Fallback"
+ },
+ source = "",
+ settings = if (safeProfileParts.isNotEmpty()) {
+ safeProfileParts.joinToString(" · ")
+ } else {
+ "Safer next launch"
+ },
+ reasoning = stabilityDetails,
+ isWarning = isStabilityFirst
+ )
+ } else {
+ null
+ }
+ }
+
+ return NovaGameDetailOptimizationState(
+ ai = aiCard,
+ stability = stabilityCard,
+ profileSummary = buildNovaLaunchProfileSummary(opt),
+ rawOptimization = opt,
+ reviewRequired = StreamSyncManager.requiresLaunchPreflightReview(opt),
+ reviewReason = StreamSyncManager.launchPreflightReviewReason(opt)
+ )
+ }
+
+ private fun profileStateLabel(state: String): String {
+ return when (state.lowercase()) {
+ "manual_override" -> "Manual"
+ "upgrade_available" -> "Ready"
+ "recovering" -> "Recovery"
+ "blocked" -> "Holding"
+ "learning" -> "Learning"
+ "stable" -> "Stable"
+ else -> state.replace('_', ' ').replaceFirstChar { it.uppercase() }
+ }
+ }
+
+ private fun formatFps(fps: Double): String {
+ val rounded = round(fps)
+ return if (abs(fps - rounded) < 0.01) {
+ rounded.toInt().toString()
+ } else {
+ String.format(Locale.US, "%.1f", fps)
+ }
+ }
+
+ private fun buildLastResultText(lastResult: JSONObject?): String {
+ if (lastResult == null) return ""
+ val grade = lastResult.optString("grade", "")
+ val delivered = lastResult.optDouble("delivered_fps", 0.0)
+ val target = lastResult.optDouble("target_fps", 0.0)
+ val fpsText = if (delivered > 0.0 && target > 0.0) {
+ "${formatFps(delivered)}/${formatFps(target)} FPS"
+ } else {
+ ""
+ }
+ return listOf(
+ grade.takeIf { it.isNotBlank() }?.let { "Last $it" },
+ fpsText.takeIf { it.isNotBlank() }
+ ).filterNotNull().joinToString(" · ")
+ }
+
+
+ companion object {
+ const val EXTRA_HOST = "nova.detail.host"
+ const val EXTRA_HTTPS_PORT = "nova.detail.httpsPort"
+ const val EXTRA_SERVER_CERT = "nova.detail.serverCert"
+ const val EXTRA_GAME = "nova.detail.game"
+ 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_GAME = "nova.detail.result.game"
+
+ const val RESULT_KEY_VIRTUAL_DISPLAY = "virtualDisplay"
+ const val RESULT_KEY_MIRROR_DESKTOP = "mirrorDesktop"
+ const val RESULT_KEY_FORCE_PRIVATE = "forcePrivateAfterSteamClose"
+ const val RESULT_KEY_PROFILE_PREFERENCE = "profilePreference"
+ const val RESULT_KEY_PREFLIGHT = "preflightOptimization"
+
+ private const val DEFAULT_HTTPS_PORT = 47984
+
+ fun newIntent(
+ context: Context,
+ game: PolarisGame,
+ host: String,
+ httpsPort: Int,
+ serverCert: ByteArray?,
+ defaultToVirtualDisplay: Boolean,
+ ): Intent = Intent(context, NovaGameDetailActivity::class.java)
+ .putExtra(EXTRA_HOST, host)
+ .putExtra(EXTRA_HTTPS_PORT, httpsPort)
+ .putExtra(EXTRA_SERVER_CERT, serverCert)
+ .putExtra(EXTRA_GAME, PolarisGameJson.encode(game))
+ .putExtra(EXTRA_DEFAULT_VIRTUAL_DISPLAY, defaultToVirtualDisplay)
+ }
+}
diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailSheet.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt
similarity index 51%
rename from app/src/main/java/com/papi/nova/ui/NovaGameDetailSheet.kt
rename to app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt
index 7c65f9ca9..434f76ccf 100644
--- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailSheet.kt
+++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt
@@ -110,1216 +110,6 @@ import kotlin.math.round
internal fun canPublishArtworkMutationUiForState(state: Lifecycle.State?): Boolean =
state?.isAtLeast(Lifecycle.State.CREATED) == true
-
-/**
- * Bottom sheet showing game details, tuning, and explicit launch modes.
- * Triggered when opening a game from the Polaris library.
- */
-class NovaGameDetailSheet : BottomSheetDialogFragment() {
-
- private var game: PolarisGame? = null
- private var apiClient: PolarisApiClient? = null
- private var defaultToVirtualDisplay: Boolean = false
- private var clientSettings: PolarisClientSettings? = null
- private var onLaunch: ((PolarisGame, Boolean, Boolean, Boolean, String, JSONObject?) -> Unit)? = null
- private var onGameUpdated: ((PolarisGame) -> Unit)? = null
- private var onRefreshArtwork: ((PolarisGame, (NovaArtworkMutationResult) -> Unit) -> Unit)? = null
- private var onApplyArtwork: ((
- PolarisGame,
- PolarisArtworkMatchCandidate,
- Map,
- (NovaArtworkMutationResult) -> Unit,
- ) -> Unit)? = null
- private var onClearArtwork: ((PolarisGame, (NovaArtworkMutationResult) -> Unit) -> Unit)? = null
-
- companion object {
- fun newInstance(
- game: PolarisGame,
- apiClient: PolarisApiClient,
- defaultToVirtualDisplay: Boolean,
- clientSettings: PolarisClientSettings?,
- onGameUpdated: (PolarisGame) -> Unit,
- onRefreshArtwork: (PolarisGame, (NovaArtworkMutationResult) -> Unit) -> Unit,
- onApplyArtwork: (
- PolarisGame,
- PolarisArtworkMatchCandidate,
- Map,
- (NovaArtworkMutationResult) -> Unit,
- ) -> Unit,
- onClearArtwork: (PolarisGame, (NovaArtworkMutationResult) -> Unit) -> Unit,
- onLaunch: (PolarisGame, Boolean, Boolean, Boolean, String, JSONObject?) -> Unit
- ): NovaGameDetailSheet {
- return NovaGameDetailSheet().apply {
- this.game = game
- this.apiClient = apiClient
- this.defaultToVirtualDisplay = defaultToVirtualDisplay
- this.clientSettings = clientSettings
- this.onGameUpdated = onGameUpdated
- this.onRefreshArtwork = onRefreshArtwork
- this.onApplyArtwork = onApplyArtwork
- this.onClearArtwork = onClearArtwork
- this.onLaunch = onLaunch
- }
- }
- }
-
- override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
- return ComposeView(requireContext()).apply {
- setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
- background = NovaSheetChrome.createSheetBackground(requireContext())
- }
- }
-
- private fun canPublishArtworkMutationUi(): Boolean =
- canPublishArtworkMutationUiForState(
- viewLifecycleOwnerLiveData.value?.lifecycle?.currentState,
- )
-
- override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
- return BottomSheetDialog(requireContext(), theme).apply {
- setOnShowListener {
- expandBottomSheet(this)
- }
- }
- }
-
- override fun onStart() {
- super.onStart()
- view?.post {
- expandBottomSheet(dialog as? BottomSheetDialog)
- }
- }
-
- override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
- super.onViewCreated(view, savedInstanceState)
- val game = this.game ?: return
- val apiClient = this.apiClient ?: return
- val composeView = view as? ComposeView ?: return
- val deviceName = DeviceUtils.getModel()
-
- var currentGame by mutableStateOf(game)
- var profilePreference by mutableStateOf(loadProfilePreference(currentGame))
- var uiState by mutableStateOf(buildUiState(currentGame, profilePreference))
- var mangoHudEnabled by mutableStateOf(game.mangohud)
- var resetWorking by mutableStateOf(false)
- var optimizationState by mutableStateOf(NovaGameDetailOptimizationState())
- var launchOptionsState by mutableStateOf(null)
- var profileOptionsState by mutableStateOf(null)
- var steamLaunchOptionsState by mutableStateOf(null)
- var artworkState by mutableStateOf(loadArtworkState(game))
-
- fun refreshUiState(preference: String = profilePreference) {
- uiState = buildUiState(currentGame, preference)
- }
-
- fun acceptArtwork(manifest: PolarisGame.ArtworkManifest) {
- val nextChoiceGeneration = artworkState.choiceGeneration + 1
- currentGame = currentGame.copy(artwork = manifest)
- refreshUiState()
- artworkState = loadArtworkState(currentGame).copy(choiceGeneration = nextChoiceGeneration)
- onGameUpdated?.invoke(currentGame)
- }
-
- fun loadArtworkChoices(candidate: PolarisArtworkMatchCandidate, kind: String) {
- val normalizedKind = kind.trim().lowercase().takeIf { it in NovaArtworkKinds.ALL } ?: return
- if (normalizedKind in artworkState.loadedKinds || normalizedKind in artworkState.loadingKinds) return
- val generation = artworkState.choiceGeneration
- artworkState = artworkState.reduce(
- NovaArtworkStudioAction.ChoicesLoading(candidate, normalizedKind, generation),
- )
- viewLifecycleOwner.lifecycleScope.launch {
- try {
- val choices = withContext(Dispatchers.IO) {
- apiClient.listArtworkChoices(currentGame.id, candidate, normalizedKind)
- }
- artworkState = artworkState.reduce(
- NovaArtworkStudioAction.ChoicesLoaded(
- candidate = candidate,
- kind = normalizedKind,
- choices = choices,
- emptyMessage = if (choices.isEmpty()) getString(R.string.nova_artwork_no_choices) else "",
- generation = generation,
- ),
- )
- } catch (e: CancellationException) {
- throw e
- } catch (_: Exception) {
- artworkState = artworkState.reduce(
- NovaArtworkStudioAction.ChoicesFailed(
- message = getString(R.string.nova_artwork_choices_failed),
- candidate = candidate,
- kind = normalizedKind,
- generation = generation,
- ),
- )
- }
- }
- }
-
-
- fun loadOptimization(preference: String, usesVirtualDisplay: Boolean = uiState.playUsesVirtualDisplay) {
- LimeLog.info(
- "Nova: Preflight optimization requested game=${currentGame.name} " +
- "preference=$preference virtualDisplay=$usesVirtualDisplay"
- )
- android.util.Log.i(
- "NovaPreflight",
- "requested game=${currentGame.name} preference=$preference virtualDisplay=$usesVirtualDisplay"
- )
- viewLifecycleOwner.lifecycleScope.launch {
- optimizationState = try {
- val opt = withContext(Dispatchers.IO) {
- syncLaunchPreflightSettings(requireContext(), apiClient, usesVirtualDisplay, clientSettings)?.let {
- clientSettings = it
- }
- apiClient.getOptimization(deviceName, currentGame.name, preference)
- }
- logPreflightOptimization("Preflight optimization", opt, preference)
- buildOptimizationState(opt, preference)
- } catch (e: Exception) {
- LimeLog.warning("Nova: Preflight optimization failed: ${e.message}")
- NovaGameDetailOptimizationState()
- }
- }
- }
-
- fun retryHighFpsTrial() {
- profilePreference = "high_fps"
- saveProfilePreference(currentGame, profilePreference)
- refreshUiState(profilePreference)
- viewLifecycleOwner.lifecycleScope.launch {
- optimizationState = try {
- val opt = withContext(Dispatchers.IO) {
- syncLaunchPreflightSettings(requireContext(), apiClient, uiState.playUsesVirtualDisplay, clientSettings)?.let {
- clientSettings = it
- }
- apiClient.getOptimization(deviceName, currentGame.name, profilePreference, "high_fps")
- }
- logPreflightOptimization("High FPS trial preflight", opt, profilePreference)
- buildOptimizationState(opt, profilePreference)
- } catch (e: Exception) {
- LimeLog.warning("Nova: High FPS trial preflight failed: ${e.message}")
- NovaGameDetailOptimizationState()
- }
- }
- }
-
- fun selectLaunchMode(mode: String) {
- val allowed = when (mode) {
- "virtual_display" -> uiState.virtualDisplayAllowed && !uiState.virtualDisplayUnavailable
- else -> uiState.headlessAllowed
- }
- if (!allowed || mode == uiState.playMode) return
-
- val previousLaunchMode = currentGame.launchMode
- val allowedModes = previousLaunchMode?.allowedModes
- ?.takeIf { it.isNotEmpty() }
- ?: listOf("headless", "virtual_display")
- val updatedLaunchMode = (previousLaunchMode ?: PolarisGame.LaunchModeContract()).copy(
- preferredMode = mode,
- allowedModes = allowedModes
- )
- currentGame = currentGame.copy(launchMode = updatedLaunchMode)
- refreshUiState()
- optimizationState = NovaGameDetailOptimizationState()
- loadOptimization(profilePreference, usesVirtualDisplay = mode == "virtual_display")
- }
-
- composeView.setContent {
- NovaComposeTheme {
- NovaGameDetailSheetContent(
- uiState = uiState,
- launchIntro = buildLaunchIntro(uiState),
- recommendedBadge = getString(
- R.string.nova_library_launch_recommended_mode_badge,
- modeBadgeLabel(uiState.recommendedMode)
- ),
- lastPlayedText = lastPlayedText(currentGame),
- profilePreferenceLabel = getString(AutoQualityProfilePreferences.labelRes(profilePreference)),
- resetProfileLabel = getString(
- if (resetWorking) {
- R.string.nova_library_reset_game_profile_working
- } else {
- R.string.nova_library_reset_game_profile
- }
- ),
- resetProfileWorking = resetWorking,
- mangoHudEnabled = mangoHudEnabled,
- mangoHudStatusLabel = getString(R.string.nova_mangohud_enabled_status),
- mangoHudStatusCaption = getString(R.string.nova_mangohud_novahud_caption),
- mangoHudWarning = uiState.mangoHudRisk != NovaGameDetailUiState.MangoHudRisk.NONE,
- steamLaunchLabel = getString(R.string.nova_steam_launch_detail_label),
- steamLaunchModeLabel = steamLaunchModeLabel(uiState.steamLaunchMode),
- steamLaunchCaption = steamLaunchCaption(uiState),
- optimizationState = optimizationState,
- launchOptionsState = launchOptionsState,
- profileOptionsState = profileOptionsState,
- playLabel = if (optimizationState.reviewRequired) {
- getString(R.string.nova_library_review_and_launch)
- } else {
- optimizationState.profileSummary
- ?.primaryLaunchLabel
- ?.takeIf { it.isNotBlank() }
- ?: primaryPlayLabel(uiState)
- },
- launchOptionsLabel = getString(R.string.nova_library_launch_options_secondary),
- launchModeTitle = getString(R.string.nova_library_launch_mode_title),
- headlessModeLabel = modeBadgeLabel("headless"),
- virtualDisplayModeLabel = modeBadgeLabel("virtual_display"),
- coverContentDescription = getString(R.string.nova_a11y_game_cover),
- onSheetHandleDismiss = { dismiss() },
- onPrimaryLaunch = {
- if (!uiState.playEnabled) return@NovaGameDetailSheetContent
- fun launchConfirmed(mirrorDesktop: Boolean, forcePrivateAfterSteamClose: Boolean = false) {
- onLaunch?.invoke(
- currentGame.copy(mangohud = mangoHudEnabled),
- uiState.playUsesVirtualDisplay,
- mirrorDesktop,
- forcePrivateAfterSteamClose,
- profilePreference,
- optimizationState.rawOptimization
- )
- dismiss()
- }
- val desktopSteamDecision = 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
- viewLifecycleOwner.lifecycleScope.launch {
- withContext(Dispatchers.IO) {
- apiClient.clearOptimizerProfile(deviceName, currentGame.name)
- }
- optimizationState = NovaGameDetailOptimizationState()
- loadOptimization(profilePreference)
- resetWorking = false
- }
- }
- )
- } else {
- launchConfirmed(false)
- }
- },
- onLaunchOptions = {
- val nextState = showLaunchOptions(currentGame, uiState)
- if (nextState == null) {
- Toast.makeText(requireContext(), R.string.nova_library_no_launch_modes, Toast.LENGTH_SHORT).show()
- } else {
- launchOptionsState = nextState
- profileOptionsState = null
- }
- },
- onLaunchModeSelected = ::selectLaunchMode,
- onLaunchOptionSelected = { option ->
- fun launchSelected(mirrorDesktop: Boolean, forcePrivateAfterSteamClose: Boolean = false) {
- val selectedLaunchOptimization = option.launchOptimization ?: optimizationState.rawOptimization
- onLaunch?.invoke(
- currentGame.copy(mangohud = mangoHudEnabled),
- option.usesVirtualDisplay,
- mirrorDesktop,
- forcePrivateAfterSteamClose,
- profilePreference,
- selectedLaunchOptimization
- )
- launchOptionsState = null
- dismiss()
- }
- val desktopSteamDecision = NovaDesktopSteamLaunchDecision.from(
- uiState,
- optimizationState.rawOptimization,
- 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) }
- )
- } else {
- launchSelected(mirrorDesktop = false)
- }
- },
- onDismissLaunchOptions = {
- launchOptionsState = null
- },
- onProfilePreference = {
- profileOptionsState = showProfilePreferenceOptions(currentGame)
- launchOptionsState = null
- },
- onProfilePreferenceSelected = { selected ->
- saveProfilePreference(currentGame, selected.value)
- profilePreference = selected.value
- refreshUiState(selected.value)
- optimizationState = NovaGameDetailOptimizationState()
- profileOptionsState = null
- loadOptimization(selected.value)
- },
- onDismissProfileOptions = {
- profileOptionsState = null
- },
- onRetryHighFps = { retryHighFpsTrial() },
- onResetProfile = {
- resetWorking = true
- viewLifecycleOwner.lifecycleScope.launch {
- val cleared = withContext(Dispatchers.IO) {
- apiClient.clearOptimizerProfile(deviceName, currentGame.name)
- }
- val sheetContext = context ?: return@launch
- if (cleared == true) {
- optimizationState = NovaGameDetailOptimizationState()
- }
- val message = when (cleared) {
- true -> R.string.nova_library_reset_game_profile_cleared
- false -> R.string.nova_library_reset_game_profile_empty
- null -> R.string.nova_library_reset_game_profile_failed
- }
- Toast.makeText(sheetContext, message, Toast.LENGTH_SHORT).show()
- resetWorking = false
- }
- },
- steamLaunchOptionsState = steamLaunchOptionsState,
- onSteamLaunchMode = {
- steamLaunchOptionsState = steamLaunchModeOptionsState(currentGame)
- },
- onSteamLaunchModeSelected = { selected ->
- val previousGame = currentGame
- val requestedMode = PolarisGame.SteamLaunchContract.normalizeMode(selected.value)
- if (requestedMode == previousGame.steamLaunchMode) {
- steamLaunchOptionsState = null
- return@NovaGameDetailSheetContent
- }
-
- currentGame = previousGame.copy(
- steamLaunch = previousGame.steamLaunch?.copy(mode = requestedMode)
- )
- refreshUiState()
- viewLifecycleOwner.lifecycleScope.launch {
- val confirmedMode = withContext(Dispatchers.IO) {
- apiClient.setSteamLaunchMode(previousGame.id, requestedMode)
- }
- val message = if (confirmedMode != null) {
- currentGame = currentGame.copy(
- steamLaunch = currentGame.steamLaunch?.copy(mode = confirmedMode)
- )
- refreshUiState()
- steamLaunchOptionsState = null
- R.string.nova_steam_launch_mode_updated
- } else {
- currentGame = previousGame
- refreshUiState()
- steamLaunchOptionsState = steamLaunchModeOptionsState(previousGame)
- R.string.nova_steam_launch_mode_failed
- }
- Toast.makeText(requireContext(), message, Toast.LENGTH_SHORT).show()
- }
- },
- onDismissSteamLaunchModeOptions = {
- steamLaunchOptionsState = null
- },
- artworkState = artworkState,
- onRefreshArtwork = {
- artworkState = artworkState.reduce(NovaArtworkStudioAction.MutationLoading)
- this@NovaGameDetailSheet.onRefreshArtwork?.invoke(currentGame) mutationResult@{ result ->
- if (!canPublishArtworkMutationUi()) return@mutationResult
- when (result) {
- is NovaArtworkMutationResult.Committed -> {
- val manifest = result.game.artwork ?: return@mutationResult
- acceptArtwork(manifest)
- }
- NovaArtworkMutationResult.Rejected,
- NovaArtworkMutationResult.Failed -> {
- artworkState = artworkState.reduce(
- NovaArtworkStudioAction.Failed(
- getString(R.string.nova_artwork_refresh_failed),
- ),
- )
- }
- }
- } ?: run {
- artworkState = artworkState.reduce(
- NovaArtworkStudioAction.Failed(
- getString(R.string.nova_artwork_refresh_failed),
- ),
- )
- }
- },
- onSearchArtwork = { query ->
- artworkState = artworkState.reduce(NovaArtworkStudioAction.SearchLoading)
- viewLifecycleOwner.lifecycleScope.launch {
- try {
- val candidates = withContext(Dispatchers.IO) {
- apiClient.searchArtworkCandidates(currentGame.id, query)
- }
- artworkState = artworkState.reduce(
- NovaArtworkStudioAction.SearchLoaded(
- candidates,
- if (candidates.isEmpty()) getString(R.string.nova_artwork_no_matches) else "",
- ),
- )
- } catch (e: CancellationException) {
- throw e
- } catch (_: Exception) {
- artworkState = artworkState.reduce(
- NovaArtworkStudioAction.Failed(getString(R.string.nova_artwork_search_failed)),
- )
- }
- }
- },
- onIdentitySelected = { candidate ->
- artworkState = artworkState.reduce(NovaArtworkStudioAction.IdentitySelected(candidate))
- loadArtworkChoices(candidate, NovaArtworkKinds.POSTER)
- },
- onIdentityChange = {
- artworkState = artworkState.reduce(NovaArtworkStudioAction.IdentityChangeRequested)
- },
- onKindSelected = { kind ->
- artworkState = artworkState.reduce(NovaArtworkStudioAction.KindSelected(kind))
- artworkState.selectedCandidate?.let { loadArtworkChoices(it, kind) }
- },
- onChoiceSelected = { choice ->
- artworkState = artworkState.reduce(NovaArtworkStudioAction.ChoiceSelected(choice))
- },
- onStudioAction = { action ->
- artworkState = artworkState.reduce(action)
- },
- onApplyArtwork = { candidate, selections ->
- artworkState = artworkState.reduce(NovaArtworkStudioAction.MutationLoading)
- this@NovaGameDetailSheet.onApplyArtwork?.invoke(
- currentGame,
- candidate,
- selections,
- ) mutationResult@{ result ->
- if (!canPublishArtworkMutationUi()) return@mutationResult
- when (result) {
- is NovaArtworkMutationResult.Committed -> {
- val manifest = result.game.artwork ?: return@mutationResult
- acceptArtwork(manifest)
- }
- NovaArtworkMutationResult.Rejected,
- NovaArtworkMutationResult.Failed -> {
- artworkState = artworkState.reduce(
- NovaArtworkStudioAction.ApplyFailed(
- getString(R.string.nova_artwork_apply_failed),
- ),
- )
- }
- }
- } ?: run {
- artworkState = artworkState.reduce(
- NovaArtworkStudioAction.ApplyFailed(
- getString(R.string.nova_artwork_apply_failed),
- ),
- )
- }
- },
- onClearArtwork = {
- artworkState = artworkState.reduce(NovaArtworkStudioAction.MutationLoading)
- this@NovaGameDetailSheet.onClearArtwork?.invoke(currentGame) mutationResult@{ result ->
- if (!canPublishArtworkMutationUi()) return@mutationResult
- when (result) {
- is NovaArtworkMutationResult.Committed -> {
- val manifest = result.game.artwork ?: return@mutationResult
- acceptArtwork(manifest)
- }
- NovaArtworkMutationResult.Rejected,
- NovaArtworkMutationResult.Failed -> {
- artworkState = artworkState.reduce(
- NovaArtworkStudioAction.Failed(
- getString(R.string.nova_artwork_clear_failed),
- ),
- )
- }
- }
- } ?: run {
- artworkState = artworkState.reduce(
- NovaArtworkStudioAction.Failed(
- getString(R.string.nova_artwork_clear_failed),
- ),
- )
- }
- },
- onLogoTransform = { scale, x, y ->
- artworkState = artworkState.copy(logoScale = scale, logoX = x, logoY = y)
- saveArtworkTransform(currentGame.id, scale, x, y)
- },
- candidatePreviewLoader = apiClient::loadArtworkCandidatePreviewInto,
- choicePreviewLoader = apiClient::loadArtworkChoicePreviewInto,
- currentArtworkPresentationKey = { kind ->
- PolarisApiClient.artworkPresentationKey(currentGame, kind)
- },
- currentArtworkLoader = { imageView, kind ->
- apiClient.loadArtworkInto(imageView, currentGame, kind)
- },
-
-
- heroAvailable = currentGame.heroArtwork?.cached == true,
- heroPresentationKey = PolarisApiClient.artworkPresentationKey(currentGame, PolarisGame.ARTWORK_KIND_HERO),
- heroLoader = { imageView -> apiClient.loadArtworkInto(imageView, currentGame, PolarisGame.ARTWORK_KIND_HERO) },
- heroContentDescription = getString(R.string.nova_artwork_hero_content_description, currentGame.name),
- logoAvailable = currentGame.logoArtwork?.cached == true,
- logoPresentationKey = PolarisApiClient.artworkPresentationKey(currentGame, PolarisGame.ARTWORK_KIND_LOGO),
- logoLoader = { imageView -> apiClient.loadArtworkInto(imageView, currentGame, PolarisGame.ARTWORK_KIND_LOGO) },
- logoContentDescription = getString(R.string.nova_artwork_logo_content_description, currentGame.name),
- iconAvailable = currentGame.iconArtwork?.cached == true,
- iconPresentationKey = PolarisApiClient.artworkPresentationKey(currentGame, PolarisGame.ARTWORK_KIND_ICON),
- iconLoader = { imageView -> apiClient.loadArtworkInto(imageView, currentGame, PolarisGame.ARTWORK_KIND_ICON) },
- iconContentDescription = getString(R.string.nova_artwork_icon_content_description, currentGame.name),
- coverLoader = { imageView ->
- apiClient.loadCoverInto(imageView, currentGame)
- }
- )
- }
- }
-
- loadOptimization(profilePreference)
- }
-
- private fun buildUiState(game: PolarisGame, profilePreference: String): NovaGameDetailUiState {
- return NovaGameDetailUiState.from(
- game = game,
- defaultToVirtualDisplay = defaultToVirtualDisplay,
- clientSettings = clientSettings,
- profilePreference = profilePreference
- )
- }
-
- private fun loadProfilePreference(game: PolarisGame): String {
- return AutoQualityProfilePreferences.load(requireContext(), game.name)
- }
-
- private fun saveProfilePreference(game: PolarisGame, preference: String) {
- AutoQualityProfilePreferences.save(requireContext(), game.name, preference)
- }
-
- private fun loadArtworkState(game: PolarisGame): NovaArtworkStudioState {
- val defaults = NovaArtworkStudioState.from(game)
- val prefs = requireContext().getSharedPreferences("nova_artwork", 0)
- val key = "logo_${game.id}_"
- return defaults.copy(
- logoScale = prefs.getFloat("${key}scale", defaults.logoScale).coerceIn(0.25f, 4f),
- logoX = prefs.getFloat("${key}x", defaults.logoX).coerceIn(0f, 1f),
- logoY = prefs.getFloat("${key}y", defaults.logoY).coerceIn(0f, 1f),
- )
- }
-
- private fun saveArtworkTransform(gameId: String, scale: Float, x: Float, y: Float) {
- requireContext().getSharedPreferences("nova_artwork", 0).edit()
- .putFloat("logo_${gameId}_scale", scale.coerceIn(0.25f, 4f))
- .putFloat("logo_${gameId}_x", x.coerceIn(0f, 1f))
- .putFloat("logo_${gameId}_y", y.coerceIn(0f, 1f))
- .apply()
- }
-
- private fun logPreflightOptimization(
- label: String,
- opt: JSONObject?,
- preference: String
- ) {
- if (opt == null) {
- LimeLog.warning("Nova: $label returned no profile for preference=$preference")
- return
- }
-
- val profileState = opt.optJSONObject("profile_state")
- val effective = opt.optJSONObject("effective_profile")
- val selectedFps = opt.optDouble(
- "effective_target_fps",
- profileState
- ?.optJSONObject("current_profile")
- ?.optDouble("target_fps", 0.0)
- ?: 0.0
- )
- LimeLog.info(
- "Nova: $label loaded source=${opt.optString("source", "unknown")} " +
- "cache=${opt.optString("cache_status", "unknown")} " +
- "state=${profileState?.optString("state", "none") ?: "none"} " +
- "effective=${effective?.optString("display_mode", "") ?: ""} " +
- "fps=$selectedFps preference=$preference " +
- "applied=${opt.optBoolean("preference_applied", false)} " +
- "trial=${opt.optBoolean("trial_profile", false)}"
- )
- }
-
- private fun showProfilePreferenceOptions(
- game: PolarisGame
- ): NovaProfilePreferenceOptionsState {
- val values = AutoQualityProfilePreferences.values()
- val current = loadProfilePreference(game)
- val labels = values.map {
- when (it) {
- "quality" -> "Prefer Quality"
- "high_fps" -> "Prefer High FPS"
- "stability" -> "Prefer Stability"
- else -> "Auto"
- }
- }
- return NovaProfilePreferenceOptionsState(
- title = getString(R.string.nova_library_profile_preference_title),
- closeLabel = getString(R.string.nova_controller_hint_close),
- options = values.mapIndexed { index, value ->
- NovaProfilePreferenceItem(
- label = labels[index],
- value = value,
- selected = value == current
- )
- }
- )
- }
-
- private fun steamLaunchModeOptionsState(game: PolarisGame): NovaSteamLaunchModeOptionsState {
- val modes = listOf("direct", "big-picture")
- return NovaSteamLaunchModeOptionsState(
- title = getString(R.string.nova_steam_launch_options_title),
- subtitle = getString(R.string.nova_steam_launch_detail_label),
- closeLabel = getString(R.string.nova_controller_hint_close),
- options = modes.map { mode ->
- val normalizedMode = PolarisGame.SteamLaunchContract.normalizeMode(mode)
- NovaSteamLaunchModeItem(
- label = steamLaunchModeLabel(normalizedMode),
- value = normalizedMode,
- selected = normalizedMode == game.steamLaunchMode
- )
- }
- )
- }
-
- private fun showLaunchOptions(
- game: PolarisGame,
- uiState: NovaGameDetailUiState
- ): NovaLaunchOptionsState? {
- val options = mutableListOf()
- val fallbackMode = clientSettings?.desired?.displayMode
- ?.takeIf { it.isNotBlank() }
- ?: clientSettings?.effective?.displayMode
- ?: ""
- val planner = NovaDisplayResolutionPlanner.from(
- contract = game.displayPlanner,
- fallbackMode = fallbackMode,
- includeAdvanced = true
- )
- if (planner.available) {
- planner.visibleChoices.forEach { choice ->
- options += NovaLaunchOptionItem(
- label = choice.title,
- usesVirtualDisplay = uiState.playUsesVirtualDisplay,
- recommended = choice.recommended,
- caption = listOf(choice.targetMode, choice.reason).filter { it.isNotBlank() }.joinToString(" · "),
- badge = choice.badge,
- launchOptimization = NovaDisplayResolutionPlanner.buildLaunchOptimizationOverride(
- choice,
- source = "nova_display_planner"
- )
- )
- }
- } else {
- if (uiState.headlessAllowed) {
- options += NovaLaunchOptionItem(
- label = optionLabel("headless", uiState.recommendedMode),
- usesVirtualDisplay = false,
- recommended = uiState.recommendedMode == "headless"
- )
- }
- if (uiState.virtualDisplayAllowed) {
- options += NovaLaunchOptionItem(
- label = optionLabel("virtual_display", uiState.recommendedMode),
- usesVirtualDisplay = true,
- recommended = uiState.recommendedMode == "virtual_display"
- )
- }
- }
-
- if (options.isEmpty()) return null
-
- return NovaLaunchOptionsState(
- title = getString(R.string.nova_library_launch_options_title),
- closeLabel = getString(R.string.nova_controller_hint_close),
- gameName = game.name,
- options = options
- )
- }
-
- private fun optionLabel(mode: String, recommendedMode: String): String {
- val label = modeLabel(mode)
- return if (mode == recommendedMode) {
- getString(R.string.nova_library_launch_recommended_format, label)
- } else {
- label
- }
- }
-
- private fun syncLaunchPreflightSettings(
- context: Context,
- apiClient: PolarisApiClient,
- usesVirtualDisplay: Boolean,
- clientSettings: PolarisClientSettings?
- ): PolarisClientSettings? {
- val preferences = PreferenceConfiguration.readPreferences(context)
- return apiClient.updateClientSettings(
- streamDisplayMode = PolarisStreamDisplayMode.preflightModeForLaunch(usesVirtualDisplay, clientSettings),
- displayMode = PreferenceConfiguration.formatStreamingDisplayMode(
- preferences.width,
- preferences.height,
- preferences.fps
- ),
- targetBitrateKbps = preferences.bitrate.takeIf { it > 0 }
- )
- }
-
- private fun showPreflightReview(
- optimizationState: NovaGameDetailOptimizationState,
- onLaunchConfirmed: () -> Unit,
- onRetryHighFps: () -> Unit,
- onResetProfile: () -> Unit
- ) {
- val reason = optimizationState.reviewReason.ifBlank { "fps_override" }
- val dialog = AlertDialog.Builder(requireContext())
- .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(requireContext(), theme)
- val composeView = ComposeView(requireContext()).apply {
- setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnDetachedFromWindow)
- 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) }
- sheet.show()
- }
-
- private fun modeLabel(mode: String): String {
- return when (mode) {
- "virtual_display" -> getString(R.string.nova_library_launch_virtual_display)
- else -> getString(R.string.nova_library_launch_headless)
- }
- }
-
- private fun modeBadgeLabel(mode: String): String {
- return when (mode) {
- "virtual_display" -> getString(R.string.nova_library_launch_virtual_short)
- else -> getString(R.string.nova_library_launch_headless)
- }
- }
-
- private fun primaryPlayLabel(uiState: NovaGameDetailUiState): String {
- return if (uiState.playEnabled) {
- getString(R.string.nova_library_play_mode, modeBadgeLabel(uiState.playMode))
- } else {
- getString(R.string.nova_library_play_unavailable)
- }
- }
-
- private fun steamLaunchModeLabel(mode: String): String {
- return when (PolarisGame.SteamLaunchContract.normalizeMode(mode)) {
- "big-picture" -> getString(R.string.nova_steam_launch_big_picture)
- else -> getString(R.string.nova_steam_launch_direct)
- }
- }
-
- private fun steamLaunchCaption(uiState: NovaGameDetailUiState): String {
- return if (uiState.steamLaunchWarning) {
- getString(R.string.nova_steam_launch_caption_big_picture)
- } else {
- getString(R.string.nova_steam_launch_caption_direct)
- }
- }
-
- private fun buildLaunchIntro(uiState: NovaGameDetailUiState): String {
- val parts = mutableListOf()
- if (uiState.preferredMode != uiState.recommendedMode) {
- parts += getString(R.string.nova_library_launch_preferred_mode_format, modeLabel(uiState.preferredMode))
- }
- if (uiState.hostStreamDisplayMode in setOf(
- PolarisClientSettings.MODE_DESKTOP_DISPLAY,
- PolarisClientSettings.MODE_GPU_NATIVE_TEST
- ) && uiState.hostStreamDisplayModeLabel.isNotBlank()
- ) {
- parts += getString(R.string.nova_polaris_sync_host_mode_detail, uiState.hostStreamDisplayModeLabel)
- }
- parts += when {
- uiState.virtualDisplayUnavailable -> {
- val unavailableParts = mutableListOf(
- getString(R.string.nova_library_virtual_display_unavailable_body)
- )
- uiState.virtualDisplayUnavailableReason
- .takeIf { it.isNotBlank() }
- ?.let {
- unavailableParts += getString(
- R.string.nova_library_virtual_display_unavailable_reason_format,
- it
- )
- }
- unavailableParts.joinToString(" ")
- }
- uiState.launchChoice.hostModeReason.isNotBlank() -> uiState.launchChoice.hostModeReason
- uiState.game.launchMode?.modeReason?.isNotBlank() == true -> uiState.game.launchMode?.modeReason.orEmpty()
- uiState.recommendedMode == "virtual_display" -> getString(R.string.nova_library_launch_intro_virtual_default)
- else -> getString(R.string.nova_library_launch_intro_headless_default)
- }
- return parts.joinToString(" ")
- }
-
- private fun lastPlayedText(game: PolarisGame): String? {
- if (game.lastLaunched <= 0) return null
- val relative = DateUtils.getRelativeTimeSpanString(
- game.lastLaunched * 1000,
- System.currentTimeMillis(),
- DateUtils.MINUTE_IN_MILLIS,
- DateUtils.FORMAT_ABBREV_RELATIVE
- )
- return getString(R.string.nova_library_meta_last_played, relative)
- }
-
- private fun buildOptimizationState(
- opt: JSONObject?,
- profilePreference: String
- ): NovaGameDetailOptimizationState {
- if (opt == null) return NovaGameDetailOptimizationState()
-
- val profileState = opt.optJSONObject("profile_state")
- val currentProfile = profileState?.optJSONObject("current_profile") ?: opt.optJSONObject("effective_profile")
- val lastResult = profileState?.optJSONObject("last_result")
- val source = opt.optString("source", "")
- val confidence = opt.optString("confidence", "")
- val cacheStatus = opt.optString("cache_status", "")
- val displayMode = currentProfile
- ?.optString("display_mode", "")
- ?.takeIf { it.isNotBlank() }
- ?: opt.optString("display_mode", "")
- val bitrate = currentProfile
- ?.optInt("target_bitrate_kbps", 0)
- ?.takeIf { it > 0 }
- ?: opt.optInt("target_bitrate_kbps", 0)
- val targetFps = currentProfile?.optDouble("target_fps", 0.0) ?: 0.0
- val codec = currentProfile
- ?.optString("preferred_codec", "")
- ?.takeIf { it.isNotBlank() }
- ?: opt.optString("preferred_codec", "")
- val reasoning = opt.optString("reasoning", "")
- val normalizationReason = opt.optString("normalization_reason", "")
- val generatedAt = opt.optLong("generated_at", 0L)
-
- val aiCard = if (displayMode.isNotEmpty() || codec.isNotEmpty() || profileState != null) {
- val parts = mutableListOf()
- if (displayMode.isNotEmpty()) parts.add(displayMode)
- if (displayMode.isEmpty() && targetFps > 0.0) parts.add("${formatFps(targetFps)} FPS")
- if (codec.isNotEmpty()) parts.add(codec.uppercase())
- if (bitrate > 0) parts.add("up to ${bitrate / 1000} Mbps")
- val settingsText = parts.joinToString(" · ").ifBlank { "Profile is being learned" }
-
- val titleLabel = profileState
- ?.optString("label", "")
- ?.takeIf { it.isNotBlank() }
- ?: when {
- source.contains("ai_live") && cacheStatus.equals("invalidated", ignoreCase = true) ->
- "Auto Quality Recovery"
- source.contains("ai_cached") -> "Auto Quality Ready"
- source.contains("ai_live") -> "Auto Quality Optimized"
- source.contains("device_db") -> "Auto Quality Baseline"
- else -> "Auto Quality"
- }
- val sourceLabel = when {
- source.contains("ai_live") && cacheStatus.equals("invalidated", ignoreCase = true) ->
- "Recovery"
- source.contains("ai_cached") -> "Cached profile"
- source.contains("ai_live") -> "Fresh profile"
- source.contains("device_db") -> getString(R.string.nova_library_ai_baseline_source_label)
- else -> source
- }
- val profileStateLabel = profileState
- ?.optString("state", "")
- ?.takeIf { it.isNotBlank() }
- ?.let { profileStateLabel(it) }
- .orEmpty()
- val stateLabel = when {
- profileStateLabel.isNotBlank() -> profileStateLabel
- normalizationReason.isNotBlank() -> getString(R.string.nova_optimization_host_adjusted)
- cacheStatus.equals("hit", ignoreCase = true) -> getString(R.string.nova_optimization_cached)
- cacheStatus.equals("invalidated", ignoreCase = true) -> getString(R.string.nova_optimization_recovery)
- cacheStatus.equals("miss", ignoreCase = true) -> getString(R.string.nova_optimization_fresh)
- source.contains("device_db") -> getString(R.string.nova_optimization_device_tune)
- else -> ""
- }
- val lastResultText = buildLastResultText(lastResult)
- val generatedLabel = if (generatedAt > 0) {
- DateUtils.getRelativeTimeSpanString(
- generatedAt * 1000,
- System.currentTimeMillis(),
- DateUtils.MINUTE_IN_MILLIS,
- DateUtils.FORMAT_ABBREV_RELATIVE
- ).toString()
- } else {
- ""
- }
- val sourceText = listOf(
- stateLabel.takeIf { it.isNotBlank() },
- profileState?.optString("preference_label", "")?.takeIf { it.isNotBlank() },
- lastResultText.takeIf { it.isNotBlank() },
- sourceLabel.takeIf { it.isNotBlank() && sourceLabel != titleLabel },
- confidence.takeIf { it.isNotBlank() }?.lowercase()?.plus(" confidence"),
- generatedLabel.takeIf { it.isNotBlank() }
- ).filter { !it.isNullOrBlank() }.joinToString(" · ")
- val profileReason = profileState?.optString("reason", "").orEmpty()
- val preferenceNote = profileState
- ?.optString("preference_note", "")
- ?.takeIf { profilePreference != "auto" }
- .orEmpty()
- val requestedFps = opt.optDouble("requested_target_fps", 0.0)
- val effectiveFps = opt.optDouble("effective_target_fps", 0.0)
- val requestedReason = if (requestedFps > 0.0 && effectiveFps > 0.0 && abs(requestedFps - effectiveFps) > 0.5) {
- "Requested ${formatFps(requestedFps)} FPS, selected ${formatFps(effectiveFps)} FPS."
- } else {
- ""
- }
- val fullReasoning = listOf(profileReason, preferenceNote, requestedReason, reasoning, normalizationReason)
- .filter { it.isNotBlank() }
- .joinToString(" ")
-
- NovaGameDetailInsightCard(
- label = titleLabel,
- source = sourceText,
- settings = settingsText,
- reasoning = fullReasoning,
- isWarning = cacheStatus.equals("invalidated", ignoreCase = true)
- )
- } else {
- null
- }
-
- val stabilityCard = opt.optJSONObject("stability")?.let { stability ->
- val safeProfile = stability.optJSONObject("safe_profile")
- val safeProfileParts = mutableListOf()
- val safeCodec = safeProfile?.optString("preferred_codec", "").orEmpty()
- if (safeCodec.isNotBlank()) {
- safeProfileParts += safeCodec.uppercase()
- }
- val safeBitrate = safeProfile?.optInt("target_bitrate_kbps", 0) ?: 0
- if (safeBitrate > 0) {
- safeProfileParts += "${safeBitrate / 1000} Mbps"
- }
- val safeDisplayMode = safeProfile?.optString("display_mode", "").orEmpty()
- if (safeDisplayMode.isNotBlank()) {
- safeProfileParts += modeBadgeLabel(safeDisplayMode)
- }
- if (safeProfile?.has("hdr") == true && !safeProfile.optBoolean("hdr", false)) {
- safeProfileParts += "HDR off"
- }
-
- val discouragedFeatures = stability.optJSONArray("discouraged_features")
- val firstDiscouragedReason = if (discouragedFeatures != null && discouragedFeatures.length() > 0) {
- discouragedFeatures.optJSONObject(0)?.optString("reason", "").orEmpty()
- } else {
- ""
- }
- val relaunchNotes = stability.optJSONArray("relaunch_notes")
- val relaunchNote = if (relaunchNotes != null && relaunchNotes.length() > 0) {
- relaunchNotes.optString(0)
- } else {
- ""
- }
- val stabilitySummary = stability.optString("summary", "")
- val stabilityMode = stability.optString("mode", "")
- val stabilityDetails = listOfNotNull(
- stabilitySummary.takeIf { it.isNotBlank() },
- firstDiscouragedReason.takeIf { it.isNotBlank() },
- relaunchNote.takeIf { it.isNotBlank() }
- ).joinToString(" ")
-
- if (safeProfileParts.isNotEmpty() || stabilityDetails.isNotBlank()) {
- val isStabilityFirst = stabilityMode.equals("stability_first", ignoreCase = true) ||
- opt.optInt("consecutive_poor_outcomes", 0) > 0
- val relaunchRequired = stability.optBoolean("relaunch_required", false)
- NovaGameDetailInsightCard(
- label = when {
- isStabilityFirst -> "Recovery Profile"
- relaunchRequired -> "Recovery Queued"
- else -> "Safer Fallback"
- },
- source = "",
- settings = if (safeProfileParts.isNotEmpty()) {
- safeProfileParts.joinToString(" · ")
- } else {
- "Safer next launch"
- },
- reasoning = stabilityDetails,
- isWarning = isStabilityFirst
- )
- } else {
- null
- }
- }
-
- return NovaGameDetailOptimizationState(
- ai = aiCard,
- stability = stabilityCard,
- profileSummary = buildNovaLaunchProfileSummary(opt),
- rawOptimization = opt,
- reviewRequired = StreamSyncManager.requiresLaunchPreflightReview(opt),
- reviewReason = StreamSyncManager.launchPreflightReviewReason(opt)
- )
- }
-
- private fun profileStateLabel(state: String): String {
- return when (state.lowercase()) {
- "manual_override" -> "Manual"
- "upgrade_available" -> "Ready"
- "recovering" -> "Recovery"
- "blocked" -> "Holding"
- "learning" -> "Learning"
- "stable" -> "Stable"
- else -> state.replace('_', ' ').replaceFirstChar { it.uppercase() }
- }
- }
-
- private fun formatFps(fps: Double): String {
- val rounded = round(fps)
- return if (abs(fps - rounded) < 0.01) {
- rounded.toInt().toString()
- } else {
- String.format(Locale.US, "%.1f", fps)
- }
- }
-
- private fun buildLastResultText(lastResult: JSONObject?): String {
- if (lastResult == null) return ""
- val grade = lastResult.optString("grade", "")
- val delivered = lastResult.optDouble("delivered_fps", 0.0)
- val target = lastResult.optDouble("target_fps", 0.0)
- val fpsText = if (delivered > 0.0 && target > 0.0) {
- "${formatFps(delivered)}/${formatFps(target)} FPS"
- } else {
- ""
- }
- return listOf(
- grade.takeIf { it.isNotBlank() }?.let { "Last $it" },
- fpsText.takeIf { it.isNotBlank() }
- ).filterNotNull().joinToString(" · ")
- }
-
- private fun sheetBackgroundRes(): Int {
- return if (NovaThemeManager.isOled(requireContext())) {
- R.drawable.nova_sheet_bg_oled
- } else {
- R.drawable.nova_sheet_bg
- }
- }
-
- private fun expandBottomSheet(bottomSheetDialog: BottomSheetDialog?) {
- val sheet = bottomSheetDialog?.findViewById(com.google.android.material.R.id.design_bottom_sheet) ?: return
- val contentView = view ?: return
- NovaSheetChrome.applyBottomSheetChrome(bottomSheetDialog, contentView)
- contentView.post {
- val isLandscape = resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
- val maxHeightRatio = if (isLandscape) 0.96f else 0.90f
- val maxHeight = (resources.displayMetrics.heightPixels * maxHeightRatio).toInt()
- val contentHeight = contentView.measuredHeight.takeIf { it > 0 } ?: return@post
- val desiredHeight = contentHeight.coerceAtMost(maxHeight)
- val displayWidth = resources.displayMetrics.widthPixels
- val density = resources.displayMetrics.density
- val desiredWidth = if (isLandscape) {
- val minWidth = (720 * density).toInt()
- val maxWidth = (1260 * density).toInt()
- (displayWidth * 0.7f).toInt().coerceIn(minWidth, maxWidth)
- } else {
- displayWidth
- }
- val horizontalMargin = if (isLandscape) {
- ((displayWidth - desiredWidth) / 2).coerceAtLeast((18 * density).toInt())
- } else {
- 0
- }
-
- contentView.layoutParams = contentView.layoutParams.apply {
- height = if (contentHeight > maxHeight) desiredHeight else ViewGroup.LayoutParams.WRAP_CONTENT
- }
- sheet.layoutParams = sheet.layoutParams.apply {
- width = if (isLandscape) displayWidth - (horizontalMargin * 2) else ViewGroup.LayoutParams.MATCH_PARENT
- height = desiredHeight
- }
- (sheet.layoutParams as? ViewGroup.MarginLayoutParams)?.let { lp ->
- lp.marginStart = horizontalMargin
- lp.marginEnd = horizontalMargin
- sheet.layoutParams = lp
- }
- sheet.minimumHeight = 0
- sheet.requestLayout()
-
- val behavior = BottomSheetBehavior.from(sheet)
- behavior.isFitToContents = true
- behavior.isDraggable = false
- behavior.skipCollapsed = true
- behavior.peekHeight = desiredHeight
- behavior.state = BottomSheetBehavior.STATE_EXPANDED
-
- when (contentView) {
- is NestedScrollView -> contentView.post { contentView.scrollTo(0, 0) }
- is ScrollView -> contentView.post { contentView.scrollTo(0, 0) }
- }
- }
- }
-}
-
-
@Composable
private fun NovaSheetDragHandle(
onDismiss: () -> Unit,
@@ -1423,7 +213,7 @@ data class NovaSteamLaunchModeOptionsState(
@Composable
-fun NovaGameDetailSheetContent(
+fun NovaGameDetailContent(
uiState: NovaGameDetailUiState,
launchIntro: String,
recommendedBadge: String,
@@ -1447,7 +237,6 @@ fun NovaGameDetailSheetContent(
headlessModeLabel: String,
virtualDisplayModeLabel: String,
coverContentDescription: String,
- onSheetHandleDismiss: () -> Unit,
modifier: Modifier = Modifier,
onPrimaryLaunch: () -> Unit,
onLaunchOptions: () -> Unit,
@@ -1510,8 +299,6 @@ fun NovaGameDetailSheetContent(
scrollState = verticalScroll,
modifier = Modifier.weight(1f)
) {
- NovaSheetDragHandle(onDismiss = onSheetHandleDismiss)
-
GameDetailsPanel(
uiState = uiState,
lastPlayedText = lastPlayedText,
@@ -1770,7 +557,7 @@ private fun NovaDetailPanel(
}
@Composable
-private fun NovaDesktopSteamLaunchDecisionContent(
+internal fun NovaDesktopSteamLaunchDecisionContent(
title: String,
message: String,
privateStreamLabel: String,
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 7314ce391..ddcd916e2 100644
--- a/app/src/main/java/com/papi/nova/ui/NovaLibraryActivity.kt
+++ b/app/src/main/java/com/papi/nova/ui/NovaLibraryActivity.kt
@@ -124,6 +124,8 @@ import com.papi.nova.LimeLog
import com.papi.nova.NovaSessionEndSignal
import com.papi.nova.R
import com.papi.nova.api.PolarisApiClient
+import com.papi.nova.api.PolarisGameJson
+import org.json.JSONObject
import com.papi.nova.api.PolarisClientSettings
import com.papi.nova.api.PolarisStreamDisplayMode
import com.papi.nova.manager.StreamSyncManager
@@ -174,7 +176,9 @@ class NovaLibraryActivity : NovaActivity() {
private var streamPcName: String = ""
private var streamServerCommands: ArrayList? = null
private var streamServerCert: ByteArray? = null
- private var detailSheet: NovaGameDetailSheet? = null
+ private val gameDetailLauncher = registerForActivityResult(
+ androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult(),
+ ) { result -> onGameDetailResult(result) }
private var allGames by mutableStateOf>(emptyList())
private var filterState by mutableStateOf(NovaLibraryFilterState())
@@ -533,8 +537,6 @@ class NovaLibraryActivity : NovaActivity() {
activeSessionRefreshJob = null
controllerHintIdleJob?.cancel()
controllerHintIdleJob = null
- detailSheet?.dismissAllowingStateLoss()
- detailSheet = null
super.onStop()
}
@@ -772,41 +774,48 @@ class NovaLibraryActivity : NovaActivity() {
private fun showGameDetail(game: PolarisGame) {
launchErrorMessage = null
- detailSheet?.dismissAllowingStateLoss()
val preferences = PreferenceConfiguration.readPreferences(this)
- val defaultToVirtualDisplay = preferences.useVirtualDisplay
- detailSheet = NovaGameDetailSheet.newInstance(
- game = game,
- apiClient = apiClient,
- defaultToVirtualDisplay = defaultToVirtualDisplay,
- clientSettings = clientSettings,
- onGameUpdated = { updated ->
- allGames = allGames.map { if (it.id == updated.id) updated else it }
- },
- onRefreshArtwork = { gameToUpdate, onResult ->
- artworkLibraryUpdateViewModel.refreshArtwork(
- game = gameToUpdate,
- onResult = onResult,
- )
- },
- onApplyArtwork = { gameToUpdate, candidate, selections, onResult ->
- artworkLibraryUpdateViewModel.applyArtworkSelections(
- game = gameToUpdate,
- candidate = candidate,
- selections = selections,
- onResult = onResult,
- )
- },
- onClearArtwork = { gameToUpdate, onResult ->
- artworkLibraryUpdateViewModel.clearArtworkOverride(
- game = gameToUpdate,
- onResult = onResult,
- )
- },
- ) { selectedGame, withVirtualDisplay, mirrorDesktop, forcePrivateAfterSteamClose, profilePreference, preflightOptimization ->
- launchGame(selectedGame, withVirtualDisplay, mirrorDesktop, forcePrivateAfterSteamClose, profilePreference, preflightOptimization)
+ gameDetailLauncher.launch(
+ NovaGameDetailActivity.newIntent(
+ context = this,
+ game = game,
+ host = streamHost,
+ httpsPort = streamHttpsPort,
+ serverCert = streamServerCert,
+ defaultToVirtualDisplay = preferences.useVirtualDisplay,
+ ),
+ )
+ NovaThemeManager.applyForwardTransition(this)
+ }
+
+ /**
+ * The detail window returns the launch it chose rather than performing it, so the
+ * stream starts from the library after that window is gone.
+ */
+ private fun onGameDetailResult(result: androidx.activity.result.ActivityResult) {
+ val data = result.data ?: return
+ data.getStringExtra(NovaGameDetailActivity.EXTRA_RESULT_GAME)
+ ?.let { PolarisGameJson.decode(it) }
+ ?.let { updated -> allGames = allGames.map { if (it.id == updated.id) updated else it } }
+
+ val launch = data.getStringExtra(NovaGameDetailActivity.EXTRA_RESULT_LAUNCH) ?: return
+ val request = try {
+ JSONObject(launch)
+ } catch (e: Exception) {
+ LimeLog.warning("Nova: Unreadable launch result from the game detail window: ${e.message}")
+ return
}
- detailSheet?.show(supportFragmentManager, "game_detail")
+ val selected = data.getStringExtra(NovaGameDetailActivity.EXTRA_RESULT_LAUNCH_GAME)
+ ?.let { PolarisGameJson.decode(it) }
+ ?: return
+ launchGame(
+ game = selected,
+ withVirtualDisplay = request.optBoolean(NovaGameDetailActivity.RESULT_KEY_VIRTUAL_DISPLAY),
+ mirrorDesktop = request.optBoolean(NovaGameDetailActivity.RESULT_KEY_MIRROR_DESKTOP),
+ forcePrivateAfterSteamClose = request.optBoolean(NovaGameDetailActivity.RESULT_KEY_FORCE_PRIVATE),
+ profilePreference = request.optString(NovaGameDetailActivity.RESULT_KEY_PROFILE_PREFERENCE, "auto"),
+ preflightOptimization = request.optJSONObject(NovaGameDetailActivity.RESULT_KEY_PREFLIGHT),
+ )
}
private fun launchGame(
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 9051dcdae..af2e2a7d8 100644
--- a/app/src/test/java/com/papi/nova/ui/NovaArtworkStudioSourceGuardTest.kt
+++ b/app/src/test/java/com/papi/nova/ui/NovaArtworkStudioSourceGuardTest.kt
@@ -80,11 +80,12 @@ class NovaArtworkStudioSourceGuardTest {
@Test
fun detailSheetUsesArtworkStudioInsteadOfThePosterCentricFixMatchFlow() {
- val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailSheet.kt")
+ val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.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")
- val gameUpdated = library.section("onGameUpdated = { updated ->", ") { selectedGame")
+ val gameUpdated = library.section("private fun onGameDetailResult(", "private fun launchGame(")
assertTrue(detail.contains("NovaArtworkStudioState.from(game)"))
assertTrue(detail.contains("NovaArtworkStudio("))
@@ -118,7 +119,8 @@ class NovaArtworkStudioSourceGuardTest {
@Test
fun studioMutationsAreOwnedByRetainedViewModelAndUiCallbacksAreLifecycleGated() {
- val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailSheet.kt")
+ val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.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")
@@ -126,7 +128,7 @@ class NovaArtworkStudioSourceGuardTest {
assertFalse(detail.contains("apiClient.clearArtworkOverride("))
assertTrue(detail.contains("onApplyArtwork?.invoke("))
assertTrue(detail.contains("onClearArtwork?.invoke("))
- assertTrue(detail.contains("viewLifecycleOwnerLiveData.value?.lifecycle?.currentState"))
+ assertTrue(detail.contains("canPublishArtworkMutationUiForState(lifecycle.currentState)"))
assertTrue(detail.contains("canPublishArtworkMutationUiForState("))
assertTrue(detail.contains("Lifecycle.State.CREATED"))
assertFalse(detail.contains("isAtLeast(Lifecycle.State.STARTED)"))
@@ -141,10 +143,10 @@ class NovaArtworkStudioSourceGuardTest {
),
)
assertTrue(updater.contains("coordinator.publishCommittedArtwork(mutation, manifest)"))
- assertTrue(library.contains("onApplyArtwork = { gameToUpdate, candidate, selections, onResult ->"))
- assertTrue(library.contains("artworkLibraryUpdateViewModel.applyArtworkSelections("))
- assertTrue(library.contains("onClearArtwork = { gameToUpdate, onResult ->"))
- assertTrue(library.contains("artworkLibraryUpdateViewModel.clearArtworkOverride("))
+ assertTrue(detail.contains("artworkViewModel.applyArtworkSelections("))
+ assertTrue(detail.contains("artworkViewModel.clearArtworkOverride("))
+ assertTrue(detail.contains("NovaArtworkLibraryUpdateViewModel.Factory("))
+ assertTrue(library.contains("NovaArtworkLibraryUpdateViewModel.Factory("))
}
@Test
@@ -186,9 +188,10 @@ class NovaArtworkStudioSourceGuardTest {
@Test
fun gameDetailsPreservesCachedHeroAvailabilityAndStablePresentationIdentity() {
- val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailSheet.kt")
+ val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt")
val contentCall = detail.section(
- "NovaGameDetailSheetContent(",
+ "NovaGameDetailContent(",
"\n loadOptimization(profilePreference)",
)
@@ -240,7 +243,8 @@ class NovaArtworkStudioSourceGuardTest {
@Test
fun applyIsOneExplicitAtomicMutationAndFailureRequiresRefetch() {
val studio = readSource("src/main/java/com/papi/nova/ui/NovaArtworkStudio.kt")
- val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailSheet.kt")
+ val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt")
val updater = readSource("src/main/java/com/papi/nova/ui/NovaArtworkLibraryUpdater.kt")
val stateReducer = studio.section(
"fun reduce(action: NovaArtworkStudioAction)",
@@ -304,7 +308,8 @@ class NovaArtworkStudioSourceGuardTest {
@Test
fun resetAndCancelDiscardDraftLocallyWithoutServerMutation() {
val studio = readSource("src/main/java/com/papi/nova/ui/NovaArtworkStudio.kt")
- val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailSheet.kt")
+ val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt")
val resetButton = studio.section(
"text = stringResource(R.string.nova_artwork_studio_reset)",
"text = stringResource(R.string.nova_artwork_studio_apply)",
@@ -328,7 +333,8 @@ class NovaArtworkStudioSourceGuardTest {
@Test
fun ordinaryDetailAndLibraryLoadingCannotStartChoiceOrProviderMutationTraffic() {
- val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailSheet.kt")
+ val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt")
val library = readSource("src/main/java/com/papi/nova/ui/NovaLibraryActivity.kt")
val detailLoad = detail.section(
"private fun loadArtworkState(",
@@ -344,7 +350,8 @@ class NovaArtworkStudioSourceGuardTest {
@Test
fun studioRefreshUsesTheRetainedPerGameMutationCoordinator() {
- val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailSheet.kt")
+ val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.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")
@@ -352,8 +359,8 @@ class NovaArtworkStudioSourceGuardTest {
assertEquals(1, updater.occurrences("apiClient.resolveArtwork("))
assertTrue(updater.contains("fun refreshArtwork("))
assertTrue(updater.contains("apiClient.resolveArtwork(game.id, force = true)"))
- assertTrue(detail.contains("this@NovaGameDetailSheet.onRefreshArtwork?.invoke(currentGame)"))
- assertTrue(library.contains("artworkLibraryUpdateViewModel.refreshArtwork("))
+ assertTrue(detail.contains("this@NovaGameDetailActivity.onRefreshArtwork?.invoke(currentGame)"))
+ assertTrue(detail.contains("artworkViewModel.refreshArtwork("))
}
@Test
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 70b143c6a..5bfae7110 100644
--- a/app/src/test/java/com/papi/nova/ui/NovaComposeSourceGuardTest.kt
+++ b/app/src/test/java/com/papi/nova/ui/NovaComposeSourceGuardTest.kt
@@ -1033,7 +1033,7 @@ class NovaComposeSourceGuardTest {
@Test
fun gameDetailRetroidFirstPaintUsesCompactGameIdentityHeader() {
- val detail = readNovaGameDetailSheet()
+ val detail = readNovaGameDetail()
val detailsPanel = detail.section(
"private fun GameDetailsPanel(",
"@Composable\nprivate fun LaunchControlsPanel("
@@ -1072,7 +1072,7 @@ class NovaComposeSourceGuardTest {
@Test
fun gameDetailLaunchControlsPrioritizePrimaryPlayFocus() {
- val detail = readNovaGameDetailSheet()
+ val detail = readNovaGameDetail()
val launchControls = detail.section(
"private fun LaunchControls(",
"@Composable\nprivate fun LaunchProfileSummaryInline("
@@ -1082,7 +1082,7 @@ class NovaComposeSourceGuardTest {
"@Composable\nprivate fun NovaDetailPanel("
)
val sheetContent = detail.section(
- "fun NovaGameDetailSheetContent(",
+ "fun NovaGameDetailContent(",
"@Composable\nprivate fun NovaGameDetailScrollableContent("
)
val scrollableContent = detail.section(
@@ -1169,7 +1169,7 @@ class NovaComposeSourceGuardTest {
@Test
fun gameDetailLaunchModeUsesSingleInlineSelectorInsteadOfDuplicateOptionsDrawer() {
- val detail = readNovaGameDetailSheet()
+ val detail = readNovaGameDetail()
val launchControls = detail.section(
"private fun LaunchControls(",
"@Composable\nprivate fun LaunchProfileSummaryInline("
@@ -1198,9 +1198,9 @@ class NovaComposeSourceGuardTest {
@Test
fun gameDetailKeepsMangoHudOutOfPrimaryLaunchDrawer() {
- val detail = readNovaGameDetailSheet()
+ val detail = readNovaGameDetail()
val sheetContent = detail.section(
- "fun NovaGameDetailSheetContent(",
+ "fun NovaGameDetailContent(",
"@Composable\nprivate fun NovaDetailPanel("
)
@@ -1218,7 +1218,7 @@ class NovaComposeSourceGuardTest {
@Test
fun gameDetailCoverLoadingIsKeyedByGameIdentity() {
- val source = readNovaGameDetailSheet()
+ val source = readNovaGameDetail()
val detailsPanel = source.section(
"private fun GameDetailsPanel(",
"@Composable\nprivate fun LaunchControlsPanel("
@@ -1238,9 +1238,9 @@ class NovaComposeSourceGuardTest {
@Test
fun gameDetailUsesHeroBackdropLogoTransformIconIdentityAndPosterFallback() {
- val source = readNovaGameDetailSheet()
+ val source = readNovaGameDetail()
val sheetContent = source.section(
- "fun NovaGameDetailSheetContent(",
+ "fun NovaGameDetailContent(",
"@Composable\nprivate fun NovaGameDetailScrollableContent("
)
val detailsPanel = source.section(
@@ -1290,9 +1290,9 @@ class NovaComposeSourceGuardTest {
@Test
fun artworkPreferencesAreCollapsedAtBottomAndUseManifestArtwork() {
- val source = readNovaGameDetailSheet()
+ val source = readNovaGameDetail()
val content = source.section(
- "fun NovaGameDetailSheetContent(",
+ "fun NovaGameDetailContent(",
"@Composable\nprivate fun NovaGameDetailScrollableContent("
)
val artworkIndex = content.indexOf("NovaArtworkStudio(")
@@ -1310,7 +1310,7 @@ class NovaComposeSourceGuardTest {
@Test
fun artworkProviderFailuresAreNotReportedAsNoMatches() {
- val sheet = readNovaGameDetailSheet()
+ val sheet = readNovaGameDetail()
val api = readSource("src/main/java/com/papi/nova/api/PolarisApiClient.kt")
val strings = readSource("src/main/res/values/strings.xml")
val searchHandler = sheet.section(
@@ -1711,9 +1711,9 @@ class NovaComposeSourceGuardTest {
"private fun NovaLibraryScreen(",
"@Composable\n private fun NovaLibraryHomeHero("
)
- val detail = readNovaGameDetailSheet()
+ val detail = readNovaGameDetail()
val detailContent = detail.section(
- "fun NovaGameDetailSheetContent(",
+ "fun NovaGameDetailContent(",
"@Composable\nprivate fun NovaDetailPanel("
)
val settings = readNovaSettingsScreen()
@@ -2747,8 +2747,9 @@ class NovaComposeSourceGuardTest {
private fun readNovaQuickMenuContent(): String =
readSource("src/main/java/com/papi/nova/ui/NovaQuickMenuContent.kt")
- private fun readNovaGameDetailSheet(): String =
- readSource("src/main/java/com/papi/nova/ui/NovaGameDetailSheet.kt")
+ private fun readNovaGameDetail(): String =
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt")
private fun readNovaSettingsScreen(): String =
readSource("src/main/java/com/papi/nova/preferences/NovaSettingsScreen.kt")
@@ -2758,7 +2759,7 @@ class NovaComposeSourceGuardTest {
@Test
fun gameDetailLaunchOptionsUseActionableModeState() {
- val launchControls = readNovaGameDetailSheet().section(
+ val launchControls = readNovaGameDetail().section(
"private fun LaunchControls(",
"@Composable\nprivate fun LaunchModeChoicePill("
)
@@ -2770,7 +2771,8 @@ class NovaComposeSourceGuardTest {
@Test
fun gameDetailLaunchOptionsAvoidRawAppCompatAlertDialogButtons() {
- val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailSheet.kt")
+ val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt")
val launchOptions = detail.section(
"private fun showLaunchOptions(",
"private fun optionLabel("
@@ -2786,7 +2788,8 @@ class NovaComposeSourceGuardTest {
@Test
fun gameDetailProfilePreferenceAvoidsRawAppCompatAlertDialogButtons() {
- val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailSheet.kt")
+ val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.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 d934a2d14..3188ef345 100644
--- a/app/src/test/java/com/papi/nova/ui/NovaLaunchSourceGuardTest.kt
+++ b/app/src/test/java/com/papi/nova/ui/NovaLaunchSourceGuardTest.kt
@@ -10,9 +10,10 @@ class NovaLaunchSourceGuardTest {
@Test
fun gameDetailLaunchUsesSelectedMangoHudState() {
- val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailSheet.kt")
+ 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(", "composeView.setContent {")
+ val launchModeSelection = detail.section("fun selectLaunchMode(", "setContentView(")
assertTrue(
"primary Play should pass the selected MangoHUD state into the launch request",
@@ -43,7 +44,8 @@ class NovaLaunchSourceGuardTest {
@Test
fun desktopSteamDecisionSheetUsesNovaGlassAndExplicitMirrorDesktopPlumbing() {
- val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailSheet.kt")
+ val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.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")
@@ -60,7 +62,7 @@ class NovaLaunchSourceGuardTest {
assertTrue(
"desktop Steam active policy should open a Nova-themed Compose bottom sheet, not a legacy square AlertDialog",
- decisionSheet.contains("BottomSheetDialog(requireContext(), theme)") &&
+ decisionSheet.contains("BottomSheetDialog(this@NovaGameDetailActivity)") &&
decisionSheet.contains("NovaDesktopSteamLaunchDecisionContent(") &&
!decisionSheet.contains("AlertDialog.Builder")
)
@@ -93,7 +95,8 @@ class NovaLaunchSourceGuardTest {
@Test
fun launchFailureAndDesktopSteamActionsUseNovaThemedFlow() {
- val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailSheet.kt")
+ val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.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")
@@ -127,7 +130,7 @@ class NovaLaunchSourceGuardTest {
"Nova drawers should let content scroll down without minimizing the whole sheet; only the top handle strip may drag-dismiss",
detail.contains("behavior.isDraggable = false") &&
detail.contains("novaSheetHandleDrag") &&
- detail.contains("onSheetHandleDismiss") &&
+ detail.contains("NovaSheetDragHandle(") &&
syncSheetGestureIsLocked() &&
chrome.contains("isDraggable = false") &&
chrome.contains("attachHandleDragToDismiss") &&
@@ -158,13 +161,14 @@ class NovaLaunchSourceGuardTest {
@Test
fun composeBottomSheetsUseThemeAwareGlassHostInsteadOfStaticOldThemeInset() {
- val gameDetailSheet = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailSheet.kt")
+ val gameDetailSheet = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt")
val syncSheet = readSource("src/main/java/com/papi/nova/ui/NovaPolarisSyncSheet.kt")
assertTrue(
- "Game detail sheet must clear/style the Material host with shared Nova glass chrome so bottom/nav inset gaps do not show old static blue chrome",
+ "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(requireContext())") &&
+ gameDetailSheet.contains("NovaSheetChrome.createSheetBackground(this@NovaGameDetailActivity)") &&
!gameDetailSheet.contains("sheet.setBackgroundResource(sheetBackgroundRes())")
)
assertTrue(
@@ -176,7 +180,8 @@ class NovaLaunchSourceGuardTest {
@Test
fun virtualLaunchPreflightUsesHostVirtualDisplayContractConstants() {
- val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailSheet.kt")
+ val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt")
val trampoline = readSource("src/main/java/com/papi/nova/ShortcutTrampoline.kt")
val displayMode = readSource("src/main/java/com/papi/nova/api/PolarisStreamDisplayMode.kt")
@@ -337,7 +342,8 @@ class NovaLaunchSourceGuardTest {
@Test
fun displayPlannerAndPostSessionReportStayControllerFirstAndLowNoise() {
- val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailSheet.kt")
+ val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.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(
@@ -482,7 +488,8 @@ class NovaLaunchSourceGuardTest {
@Test
fun virtualDisplayUnavailableCopyUsesHostVirtualDisplayLanguageAndReason() {
- val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailSheet.kt")
+ val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.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"))
@@ -618,7 +625,8 @@ class NovaLaunchSourceGuardTest {
@Test
fun gameDetailPreflightPreservesExplicitPolarisNonVirtualMode() {
- val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailSheet.kt")
+ val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt")
val preflight = detail.section(
"private fun syncLaunchPreflightSettings(",
"private fun showPreflightReview("
@@ -650,7 +658,8 @@ class NovaLaunchSourceGuardTest {
@Test
fun steamLaunchSelectionDoesNotDismissGameDetailOrStartStream() {
- val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailSheet.kt")
+ val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt")
val selection = detail.section(
"onSteamLaunchModeSelected = { selected ->",
"},\n onDismissSteamLaunchModeOptions"
@@ -664,7 +673,8 @@ class NovaLaunchSourceGuardTest {
@Test
fun steamLaunchModeUpdateConfirmsHostModeAndStaysInline() {
val api = readSource("src/main/java/com/papi/nova/api/PolarisApiClient.kt")
- val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailSheet.kt")
+ val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.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 8612c32b4..78c822739 100644
--- a/app/src/test/java/com/papi/nova/ui/NovaThemeResourcesTest.kt
+++ b/app/src/test/java/com/papi/nova/ui/NovaThemeResourcesTest.kt
@@ -183,7 +183,8 @@ class NovaThemeResourcesTest {
val sheetChrome = File("src/main/java/com/papi/nova/ui/NovaSheetChrome.kt").readText()
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/NovaGameDetailSheet.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()
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()
@@ -255,7 +256,8 @@ class NovaThemeResourcesTest {
fun sheetChromeUsesSharedTranslucentGlassForNovaHudFriendlyDrawers() {
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/NovaGameDetailSheet.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()
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"))
@@ -387,7 +389,8 @@ class NovaThemeResourcesTest {
fun menuOpacityCoversLifecycleLibraryOptionsAndResetPaths() {
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/NovaGameDetailSheet.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()
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()
@@ -449,7 +452,8 @@ class NovaThemeResourcesTest {
@Test
fun requiredNativeAlertsUseSharedOpacityAndBlurChrome() {
- val gameDetail = File("src/main/java/com/papi/nova/ui/NovaGameDetailSheet.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()
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()
@@ -542,7 +546,8 @@ class NovaThemeResourcesTest {
fun materialYouAppSurfacesDoNotUseStaticLegacyBackgrounds() {
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/NovaGameDetailSheet.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()
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()