diff --git a/app/build.gradle b/app/build.gradle
index db66f2bb..e6f22002 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 5025c4a8..f4ea2268 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 7c65f9ca..434f76cc 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 7314ce39..ddcd916e 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 9051dcda..af2e2a7d 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 70b143c6..5bfae711 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 d934a2d1..3188ef34 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 8612c32b..78c82273 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()