From d9cb1c14622239d0aec3a8cc68d205c4ba942d4f Mon Sep 17 00:00:00 2001 From: papi Date: Wed, 20 May 2026 14:42:13 -0400 Subject: [PATCH 1/5] fix: preserve high fps trial launch target --- .../com/papi/nova/manager/StreamSyncManager.kt | 15 ++++++++++++++- .../papi/nova/manager/StreamSyncManagerTest.kt | 16 ++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/papi/nova/manager/StreamSyncManager.kt b/app/src/main/java/com/papi/nova/manager/StreamSyncManager.kt index fc314e05..53426292 100644 --- a/app/src/main/java/com/papi/nova/manager/StreamSyncManager.kt +++ b/app/src/main/java/com/papi/nova/manager/StreamSyncManager.kt @@ -285,7 +285,20 @@ class StreamSyncManager private constructor() { private fun isSafeTargetFpsRelaxed(optimization: JSONObject, stability: JSONObject?): Boolean = optimization.optBoolean("safe_target_fps_relaxed", false) || - (stability != null && stability.optBoolean("safe_target_fps_relaxed", false)) + (stability != null && stability.optBoolean("safe_target_fps_relaxed", false)) || + isHighFpsTrial(optimization) + + private fun isHighFpsTrial(optimization: JSONObject): Boolean { + if (optimization.optBoolean("trial_profile", false) && + normalized(optimization.optString("trial_kind", "")) == "high_fps" + ) { + return true + } + + val profileState = optimization.optJSONObject("profile_state") ?: return false + return profileState.optBoolean("trial_profile", false) && + normalized(profileState.optString("trial_kind", "")) == "high_fps" + } private fun shouldHonorOptimizerTarget(optimization: JSONObject, stability: JSONObject?): Boolean { if (isConfirmedRecoveryPolicy(optimization, stability)) { diff --git a/app/src/test/java/com/papi/nova/manager/StreamSyncManagerTest.kt b/app/src/test/java/com/papi/nova/manager/StreamSyncManagerTest.kt index e37b2c2c..6b94a19f 100644 --- a/app/src/test/java/com/papi/nova/manager/StreamSyncManagerTest.kt +++ b/app/src/test/java/com/papi/nova/manager/StreamSyncManagerTest.kt @@ -199,6 +199,22 @@ class StreamSyncManagerTest { assertEquals(30f, targetFps, 0.01f) } + @Test + fun resolveAutoSafeTargetFps_highFpsTrialBypassesConfirmedRecoveryCapOnce() { + val optimization = JSONObject( + "{\"display_mode\":\"1920x1080x120\",\"safe_target_fps\":60," + + "\"source\":\"history_safe\",\"trial_profile\":true,\"trial_kind\":\"high_fps\"," + + "\"profile_state\":{\"preference\":\"high_fps\",\"trial_profile\":true," + + "\"trial_kind\":\"high_fps\"}," + + "\"stability\":{\"mode\":\"stability_first\",\"auto_action\":\"apply_recovery\"," + + "\"safe_profile\":{\"target_fps\":60}}}" + ) + + val targetFps = StreamSyncManager.resolveAutoSafeTargetFps(120f, optimization) + + assertEquals(120f, targetFps, 0.01f) + } + @Test fun resolveDisplayCompatibleAutoSafeTargetFps_keepsFortyWhenOneTwentyAllowed() { val selected = StreamSyncManager.resolveDisplayCompatibleAutoSafeTargetFps( From 7d35d13802a86f28e2625c9688db22188146b0dd Mon Sep 17 00:00:00 2001 From: papi Date: Wed, 20 May 2026 15:01:08 -0400 Subject: [PATCH 2/5] feat: add launch profile preflight review --- .../nova/ui/NovaGameDetailSheetComposeTest.kt | 1 + app/src/main/java/com/papi/nova/Game.kt | 31 +- .../com/papi/nova/api/PolarisApiClient.kt | 47 ++- .../papi/nova/manager/StreamSyncManager.kt | 77 +++++ .../papi/nova/nvstream/StreamConfiguration.kt | 8 + .../com/papi/nova/nvstream/http/NvHTTP.kt | 7 + .../com/papi/nova/ui/NovaGameDetailSheet.kt | 305 +++++++++++++++++- .../java/com/papi/nova/ui/NovaHudUiState.kt | 2 + .../papi/nova/ui/NovaLaunchProfileSummary.kt | 257 +++++++++++++++ .../com/papi/nova/ui/NovaLibraryActivity.kt | 30 +- .../java/com/papi/nova/utils/ServerHelper.kt | 14 + app/src/main/res/values/strings.xml | 5 + .../nova/KotlinGameRuntimeMigrationTest.kt | 2 + .../nova/api/PolarisApiClientParsingTest.kt | 15 + .../nova/manager/StreamSyncManagerTest.kt | 74 +++++ .../com/papi/nova/ui/NovaHudUiStateTest.kt | 51 ++- .../nova/ui/NovaLaunchProfileSummaryTest.kt | 160 +++++++++ .../papi/nova/ui/NovaLaunchSourceGuardTest.kt | 2 +- 18 files changed, 1046 insertions(+), 42 deletions(-) create mode 100644 app/src/main/java/com/papi/nova/ui/NovaLaunchProfileSummary.kt create mode 100644 app/src/test/java/com/papi/nova/ui/NovaLaunchProfileSummaryTest.kt diff --git a/app/src/androidTest/java/com/papi/nova/ui/NovaGameDetailSheetComposeTest.kt b/app/src/androidTest/java/com/papi/nova/ui/NovaGameDetailSheetComposeTest.kt index b4ff6ca3..e1536ea8 100644 --- a/app/src/androidTest/java/com/papi/nova/ui/NovaGameDetailSheetComposeTest.kt +++ b/app/src/androidTest/java/com/papi/nova/ui/NovaGameDetailSheetComposeTest.kt @@ -80,6 +80,7 @@ class NovaGameDetailSheetComposeTest { onPrimaryLaunch = {}, onLaunchOptions = {}, onProfilePreference = {}, + onRetryHighFps = {}, onResetProfile = {}, onMangoHudChanged = {}, onSteamLaunchMode = {}, diff --git a/app/src/main/java/com/papi/nova/Game.kt b/app/src/main/java/com/papi/nova/Game.kt index 977da0b4..fc97e1e7 100644 --- a/app/src/main/java/com/papi/nova/Game.kt +++ b/app/src/main/java/com/papi/nova/Game.kt @@ -211,6 +211,8 @@ private var streamingDisplayId:Int = Display.DEFAULT_DISPLAY @Volatile private var lastReportedClientPresentationKey:String = "" @Volatile private var lastPolarisDeviceCapabilities:JSONObject? = null @Volatile private var lastPolarisAppliedStreamSettings:JSONObject? = null +private var launchProfilePreference:String = "auto" +private var launchOptimizationJson:String? = null private var clientPresentationReportInFlight:AtomicBoolean = AtomicBoolean(false) private var cursorVisibilitySyncLock:Any = Any() private var pendingHostCursorVisible:Boolean = false @@ -690,6 +692,8 @@ watchOnlyRequested = this@Game.getIntent().getBooleanExtra(EXTRA_WATCH_ONLY, fal watchStreamWidth = this@Game.getIntent().getIntExtra(EXTRA_STREAM_WIDTH, 0) watchStreamHeight = this@Game.getIntent().getIntExtra(EXTRA_STREAM_HEIGHT, 0) watchStreamFps = this@Game.getIntent().getFloatExtra(EXTRA_STREAM_FPS, 0f) +launchProfilePreference = this@Game.getIntent().getStringExtra(EXTRA_AI_PROFILE_PREFERENCE) ?: "" +launchOptimizationJson = this@Game.getIntent().getStringExtra(EXTRA_LAUNCH_OPTIMIZATION) serverCmds = this@Game.getIntent().getStringArrayListExtra(EXTRA_SERVER_COMMANDS) ?: ArrayList() var appSupportsHdr:Boolean = this@Game.getIntent().getBooleanExtra(EXTRA_APP_HDR, false) var derCertData:ByteArray? = this@Game.getIntent().getByteArrayExtra(EXTRA_SERVER_CERT) @@ -1055,6 +1059,11 @@ supportedVideoFormats, prefConfig!!.videoFormat, displayModeExplicit ) +try +{ +lastPolarisAppliedStreamSettings?.put("profile_preference", launchProfilePreference) +} +catch (ignored:Exception) {} var config:StreamConfiguration = StreamConfiguration.Builder() .setResolution( @@ -1071,6 +1080,7 @@ displayHeight .setForceFreshLaunch(forceFreshLaunch) .setBitrate(configuredStreamBitrateKbps) .setEnableSops(prefConfig!!.enableSops) +.setProfilePreference(launchProfilePreference) .enableLocalAudioPlayback(prefConfig!!.playHostAudio) .setMaxPacketSize(1392) .setRemoteConfiguration(StreamConfiguration.STREAM_CFG_AUTO) // NvConnection will perform LAN and VPN detection @@ -1875,6 +1885,16 @@ getConfiguredStreamFrameRateFps() <= 60f && } private fun loadLaunchOptimization(appName:String?):JSONObject? { +if (!launchOptimizationJson.isNullOrBlank()) +{ +try +{ +return JSONObject(launchOptimizationJson!!) +} +catch (e:Exception) { +LimeLog.warning("Nova: Ignoring invalid preflight optimization payload") +} +} if (novaApiClient == null) { return null @@ -1884,10 +1904,11 @@ var result:Array = arrayOfNulls(1) var failure:Array = arrayOfNulls(1) var thread:Thread = Thread({ try { -var safeAppName:String? = if (appName != null) appName else "" -var preference:String? = getSharedPreferences("nova_prefs", MODE_PRIVATE) -.getString("ai_profile_preference_name_" + safeAppName!!, "auto") -result[0] = novaApiClient!!.getOptimization(DeviceUtils.getModel(), safeAppName, if (preference != null) preference else "auto") +var safeAppName:String = appName ?: "" +var preference:String = launchProfilePreference.takeIf { it.isNotBlank() } ?: getSharedPreferences("nova_prefs", MODE_PRIVATE) +.getString("ai_profile_preference_name_" + safeAppName, "auto") ?: "auto" +launchProfilePreference = preference +result[0] = novaApiClient!!.getOptimization(DeviceUtils.getModel(), safeAppName, preference) } catch (e:Exception) { failure[0] = e @@ -5884,6 +5905,8 @@ companion object { const val EXTRA_STREAM_WIDTH:String = "StreamWidth" const val EXTRA_STREAM_HEIGHT:String = "StreamHeight" const val EXTRA_STREAM_FPS:String = "StreamFps" + const val EXTRA_AI_PROFILE_PREFERENCE:String = "AiProfilePreference" + const val EXTRA_LAUNCH_OPTIMIZATION:String = "LaunchOptimization" const val EXTRA_SERVER_COMMANDS:String = "ServerCommands" const val EXTRA_DISPLAY_ID:String = "DisplayID" diff --git a/app/src/main/java/com/papi/nova/api/PolarisApiClient.kt b/app/src/main/java/com/papi/nova/api/PolarisApiClient.kt index ea83c450..2eed5ee0 100644 --- a/app/src/main/java/com/papi/nova/api/PolarisApiClient.kt +++ b/app/src/main/java/com/papi/nova/api/PolarisApiClient.kt @@ -77,6 +77,27 @@ class PolarisApiClient @JvmOverloads constructor( .generateCertificate(ByteArrayInputStream(serverCertDer)) as X509Certificate } + @JvmStatic + fun buildOptimizationPath( + device: String, + game: String, + preference: String = "", + trial: String = "" + ): String { + val preferenceParam = preference + .takeIf { it.isNotBlank() } + ?.let { "&preference=${java.net.URLEncoder.encode(it, "UTF-8")}" } + ?: "" + val trialParam = trial + .takeIf { it.isNotBlank() } + ?.let { "&trial=${java.net.URLEncoder.encode(it, "UTF-8")}" } + ?: "" + return "/optimize?device=${java.net.URLEncoder.encode(device, "UTF-8")}" + + "&game=${java.net.URLEncoder.encode(game, "UTF-8")}" + + preferenceParam + + trialParam + } + private fun parseStringArray(array: org.json.JSONArray?): List { if (array == null) return emptyList() return (0 until array.length()).mapNotNull { index -> @@ -1194,20 +1215,26 @@ class PolarisApiClient @JvmOverloads constructor( * Get AI-recommended streaming settings for a device+game combo. */ @JvmOverloads - fun getOptimization(device: String, game: String, preference: String = ""): org.json.JSONObject? { + fun getOptimization( + device: String, + game: String, + preference: String = "", + trial: String = "" + ): org.json.JSONObject? { return try { - val preferenceParam = preference - .takeIf { it.isNotBlank() } - ?.let { "&preference=${java.net.URLEncoder.encode(it, "UTF-8")}" } - ?: "" - val url = "$baseUrl/optimize?device=${java.net.URLEncoder.encode(device, "UTF-8")}" + - "&game=${java.net.URLEncoder.encode(game, "UTF-8")}" + - preferenceParam + val url = "$baseUrl${buildOptimizationPath(device, game, preference, trial)}" val request = Request.Builder().url(url).get().build() + LimeLog.info("Nova: Optimization query start for $url") executeGetWithRetry(request).use { response -> if (response.code == 200) { - org.json.JSONObject(response.body?.string() ?: "{}") - } else null + LimeLog.info("Nova: Optimization query HTTP 200 for $url") + val body = response.body?.string() ?: "{}" + LimeLog.info("Nova: Optimization query body received (${body.length} bytes)") + org.json.JSONObject(body) + } else { + LimeLog.warning("Nova: Optimization query returned HTTP ${response.code} for $url") + null + } } } catch (e: Exception) { LimeLog.warning("Nova: Optimization query failed: ${errorMessage(e)}") diff --git a/app/src/main/java/com/papi/nova/manager/StreamSyncManager.kt b/app/src/main/java/com/papi/nova/manager/StreamSyncManager.kt index 53426292..cf255d03 100644 --- a/app/src/main/java/com/papi/nova/manager/StreamSyncManager.kt +++ b/app/src/main/java/com/papi/nova/manager/StreamSyncManager.kt @@ -251,6 +251,83 @@ class StreamSyncManager private constructor() { return stability != null && stability.optBoolean("relaunch_required", false) } + @JvmStatic + fun requiresLaunchPreflightReview(optimization: JSONObject?): Boolean { + if (optimization == null) { + return false + } + + if (hasMaterialFpsOverride(optimization)) { + return true + } + + val profileState = optimization.optJSONObject("profile_state") + val preference = normalized( + profileState?.optString("preference", "")?.takeIf { it.isNotBlank() } + ?: optimization.optString("preference", "auto") + ) + val preferenceApplied = + if (profileState != null && profileState.has("preference_applied")) { + profileState.optBoolean("preference_applied", preference == "auto") + } else { + optimization.optBoolean("preference_applied", preference == "auto") + } + if (preference == "high_fps" && !hasMaterialFpsOverride(optimization)) { + return false + } + + return preference != "auto" && !preferenceApplied && + explicitPreferenceBlockReason(optimization).isNotEmpty() + } + + @JvmStatic + fun launchPreflightReviewReason(optimization: JSONObject?): String { + if (optimization == null) { + return "" + } + + val profileState = optimization.optJSONObject("profile_state") + val preference = normalized( + profileState?.optString("preference", "")?.takeIf { it.isNotBlank() } + ?: optimization.optString("preference", "auto") + ) + if (preference == "high_fps" && !hasMaterialFpsOverride(optimization)) { + return "" + } + + explicitPreferenceBlockReason(optimization).takeIf { it.isNotEmpty() }?.let { + return it + } + + if (hasMaterialFpsOverride(optimization)) { + return "fps_override" + } + + return "" + } + + private fun hasMaterialFpsOverride(optimization: JSONObject): Boolean { + val requestedFps = optimization.optDouble("requested_target_fps", 0.0) + val effectiveFps = optimization.optDouble("effective_target_fps", 0.0) + return requestedFps > 0.0 && effectiveFps > 0.0 && + kotlin.math.abs(requestedFps - effectiveFps) > 0.5 + } + + private fun explicitPreferenceBlockReason(optimization: JSONObject): String { + val profileState = optimization.optJSONObject("profile_state") + val stateReason = normalized(profileState?.optString("preference_blocked_reason", "none")) + if (stateReason.isNotEmpty() && stateReason != "none") { + return stateReason + } + + val topLevelReason = normalized(optimization.optString("preference_blocked_reason", "none")) + if (topLevelReason.isNotEmpty() && topLevelReason != "none") { + return topLevelReason + } + + return "" + } + @JvmStatic fun shouldPreferStableRefreshMultiple(optimization: JSONObject?, targetFps: Float): Boolean { if (optimization == null || targetFps <= 0f || targetFps > 45f) { diff --git a/app/src/main/java/com/papi/nova/nvstream/StreamConfiguration.kt b/app/src/main/java/com/papi/nova/nvstream/StreamConfiguration.kt index e537c4bd..447a41c3 100644 --- a/app/src/main/java/com/papi/nova/nvstream/StreamConfiguration.kt +++ b/app/src/main/java/com/papi/nova/nvstream/StreamConfiguration.kt @@ -27,6 +27,7 @@ class StreamConfiguration private constructor() { private var persistGamepadsAfterDisconnect = false private var enableUltraLowLatency = false private var forceFreshLaunch = false + private var profilePreference = "auto" class Builder { private val config = StreamConfiguration() @@ -152,6 +153,11 @@ class StreamConfiguration private constructor() { return this } + fun setProfilePreference(profilePreference: String?): Builder { + config.profilePreference = if (profilePreference.isNullOrBlank()) "auto" else profilePreference + return this + } + fun build(): StreamConfiguration = config } @@ -213,6 +219,8 @@ class StreamConfiguration private constructor() { fun getForceFreshLaunch(): Boolean = forceFreshLaunch + fun getProfilePreference(): String = profilePreference + companion object { const val INVALID_APP_ID = 0 diff --git a/app/src/main/java/com/papi/nova/nvstream/http/NvHTTP.kt b/app/src/main/java/com/papi/nova/nvstream/http/NvHTTP.kt index 0a948ff2..4c7e2583 100644 --- a/app/src/main/java/com/papi/nova/nvstream/http/NvHTTP.kt +++ b/app/src/main/java/com/papi/nova/nvstream/http/NvHTTP.kt @@ -26,6 +26,7 @@ import java.net.Inet4Address import java.net.InetAddress import java.net.Proxy import java.net.Socket +import java.net.URLEncoder import java.security.KeyManagementException import java.security.KeyStore import java.security.KeyStoreException @@ -689,6 +690,11 @@ class NvHTTP @Throws(IOException::class) constructor( } } + val profilePreference = streamConfig.getProfilePreference() + .takeIf { it.isNotBlank() } + ?.let { "&profilePreference=" + URLEncoder.encode(it, "UTF-8") } + ?: "" + val xmlStr = openHttpConnectionToString( httpClientLongConnectNoReadTimeout, getHttpsUrl(true), @@ -705,6 +711,7 @@ class NvHTTP @Throws(IOException::class) constructor( (if (watchOnly) "&watch=1" else "") + "&virtualDisplay=" + (if (streamConfig.getVirtualDisplay()) 1 else 0) + "&displayModeExplicit=" + (if (streamConfig.getDisplayModeExplicit()) 1 else 0) + + profilePreference + "&localAudioPlayMode=" + (if (streamConfig.getPlayLocalAudio()) 1 else 0) + "&surroundAudioInfo=" + streamConfig.getAudioConfiguration()!!.getSurroundAudioInfo() + "&remoteControllersBitmap=" + streamConfig.getAttachedGamepadMask() + diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailSheet.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailSheet.kt index 3c43bb36..d666ebd7 100644 --- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailSheet.kt +++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailSheet.kt @@ -59,10 +59,13 @@ import androidx.lifecycle.lifecycleScope import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.papi.nova.LimeLog import com.papi.nova.R import com.papi.nova.api.PolarisApiClient import com.papi.nova.api.PolarisClientSettings import com.papi.nova.api.PolarisGame +import com.papi.nova.manager.StreamSyncManager +import com.papi.nova.preferences.PreferenceConfiguration import com.papi.nova.ui.compose.LocalNovaComposeColors import com.papi.nova.ui.compose.LocalNovaLibrarySurfaces import com.papi.nova.ui.compose.NovaActionButton @@ -88,7 +91,7 @@ class NovaGameDetailSheet : BottomSheetDialogFragment() { private var apiClient: PolarisApiClient? = null private var defaultToVirtualDisplay: Boolean = false private var clientSettings: PolarisClientSettings? = null - private var onLaunch: ((PolarisGame, Boolean) -> Unit)? = null + private var onLaunch: ((PolarisGame, Boolean, String, JSONObject?) -> Unit)? = null companion object { fun newInstance( @@ -96,7 +99,7 @@ class NovaGameDetailSheet : BottomSheetDialogFragment() { apiClient: PolarisApiClient, defaultToVirtualDisplay: Boolean, clientSettings: PolarisClientSettings?, - onLaunch: (PolarisGame, Boolean) -> Unit + onLaunch: (PolarisGame, Boolean, String, JSONObject?) -> Unit ): NovaGameDetailSheet { return NovaGameDetailSheet().apply { this.game = game @@ -148,14 +151,48 @@ class NovaGameDetailSheet : BottomSheetDialogFragment() { uiState = buildUiState(currentGame, preference) } - fun loadOptimization(preference: String) { + 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)?.let { + clientSettings = it + } apiClient.getOptimization(deviceName, currentGame.name, preference) } + logPreflightOptimization("Preflight optimization", opt, preference) buildOptimizationState(opt, preference) - } catch (_: Exception) { + } 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)?.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() } } @@ -188,20 +225,65 @@ class NovaGameDetailSheet : BottomSheetDialogFragment() { steamLaunchModeLabel = steamLaunchModeLabel(uiState.steamLaunchMode), steamLaunchCaption = steamLaunchCaption(uiState), optimizationState = optimizationState, - playLabel = getString(R.string.nova_library_play), + playLabel = getString( + if (optimizationState.reviewRequired) { + R.string.nova_library_review_and_launch + } else { + R.string.nova_library_play + } + ).let { label -> + if (optimizationState.reviewRequired) { + label + } else { + optimizationState.profileSummary + ?.primaryLaunchLabel + ?.takeIf { it.isNotBlank() } + ?: label + } + }, launchOptionsLabel = getString(R.string.nova_library_launch_options), launchModeTitle = getString(R.string.nova_library_launch_mode_title), coverContentDescription = getString(R.string.nova_a11y_game_cover), onPrimaryLaunch = { if (!uiState.playEnabled) return@NovaGameDetailSheetContent - onLaunch?.invoke( - currentGame.copy(mangohud = mangoHudEnabled), - uiState.playUsesVirtualDisplay - ) - dismiss() + val launchConfirmed = { + onLaunch?.invoke( + currentGame.copy(mangohud = mangoHudEnabled), + uiState.playUsesVirtualDisplay, + profilePreference, + optimizationState.rawOptimization + ) + dismiss() + } + if (optimizationState.reviewRequired) { + showPreflightReview( + optimizationState = optimizationState, + onLaunchConfirmed = launchConfirmed, + onRetryHighFps = { retryHighFpsTrial() }, + onResetProfile = { + resetWorking = true + viewLifecycleOwner.lifecycleScope.launch { + withContext(Dispatchers.IO) { + apiClient.clearOptimizerProfile(deviceName, currentGame.name) + } + optimizationState = NovaGameDetailOptimizationState() + loadOptimization(profilePreference) + resetWorking = false + } + } + ) + } else { + launchConfirmed() + } }, onLaunchOptions = { - showLaunchOptions(currentGame, uiState, mangoHudEnabled) + showLaunchOptions( + currentGame, + uiState, + mangoHudEnabled, + profilePreference, + optimizationState.rawOptimization + ) }, onProfilePreference = { showProfilePreferenceOptions(currentGame) { selected -> @@ -211,6 +293,7 @@ class NovaGameDetailSheet : BottomSheetDialogFragment() { loadOptimization(selected) } }, + onRetryHighFps = { retryHighFpsTrial() }, onResetProfile = { resetWorking = true viewLifecycleOwner.lifecycleScope.launch { @@ -291,6 +374,36 @@ class NovaGameDetailSheet : BottomSheetDialogFragment() { AutoQualityProfilePreferences.save(requireContext(), game.name, preference) } + 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, onChanged: (String) -> Unit @@ -335,7 +448,9 @@ class NovaGameDetailSheet : BottomSheetDialogFragment() { private fun showLaunchOptions( game: PolarisGame, uiState: NovaGameDetailUiState, - mangoHudEnabled: Boolean + mangoHudEnabled: Boolean, + profilePreference: String, + rawOptimization: JSONObject? ) { val options = mutableListOf>() if (uiState.headlessAllowed) { @@ -353,12 +468,50 @@ class NovaGameDetailSheet : BottomSheetDialogFragment() { AlertDialog.Builder(requireContext()) .setTitle(R.string.nova_library_launch_options_title) .setItems(options.map { it.first }.toTypedArray()) { _, which -> - onLaunch?.invoke(game.copy(mangohud = mangoHudEnabled), options[which].second) + onLaunch?.invoke( + game.copy(mangohud = mangoHudEnabled), + options[which].second, + profilePreference, + rawOptimization + ) dismiss() } .show() } + private fun syncLaunchPreflightSettings( + context: Context, + apiClient: PolarisApiClient, + usesVirtualDisplay: Boolean + ): PolarisClientSettings? { + val preferences = PreferenceConfiguration.readPreferences(context) + return apiClient.updateClientSettings( + streamDisplayMode = if (usesVirtualDisplay) "host_virtual_display" else "headless_stream", + 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" } + 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() } + .show() + } + private fun optionLabel(mode: String, recommendedMode: String): String { val label = modeLabel(mode) return if (mode == recommendedMode) { @@ -448,7 +601,7 @@ class NovaGameDetailSheet : BottomSheetDialogFragment() { if (opt == null) return NovaGameDetailOptimizationState() val profileState = opt.optJSONObject("profile_state") - val currentProfile = profileState?.optJSONObject("current_profile") + 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", "") @@ -535,7 +688,14 @@ class NovaGameDetailSheet : BottomSheetDialogFragment() { ?.optString("preference_note", "") ?.takeIf { profilePreference != "auto" } .orEmpty() - val fullReasoning = listOf(profileReason, preferenceNote, reasoning, normalizationReason) + 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(" ") @@ -613,7 +773,14 @@ class NovaGameDetailSheet : BottomSheetDialogFragment() { } } - return NovaGameDetailOptimizationState(ai = aiCard, stability = stabilityCard) + 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 { @@ -717,7 +884,11 @@ class NovaGameDetailSheet : BottomSheetDialogFragment() { data class NovaGameDetailOptimizationState( val ai: NovaGameDetailInsightCard? = null, - val stability: NovaGameDetailInsightCard? = null + val stability: NovaGameDetailInsightCard? = null, + val profileSummary: NovaLaunchProfileSummary? = null, + val rawOptimization: JSONObject? = null, + val reviewRequired: Boolean = false, + val reviewReason: String = "" ) data class NovaGameDetailInsightCard( @@ -752,6 +923,7 @@ fun NovaGameDetailSheetContent( onPrimaryLaunch: () -> Unit, onLaunchOptions: () -> Unit, onProfilePreference: () -> Unit, + onRetryHighFps: () -> Unit, onResetProfile: () -> Unit, onMangoHudChanged: (Boolean) -> Unit, onSteamLaunchMode: () -> Unit, @@ -794,11 +966,13 @@ fun NovaGameDetailSheetContent( playLabel = playLabel, launchOptionsLabel = launchOptionsLabel, profilePreferenceLabel = profilePreferenceLabel, + profileSummary = optimizationState.profileSummary, resetProfileLabel = resetProfileLabel, resetProfileWorking = resetProfileWorking, onPrimaryLaunch = onPrimaryLaunch, onLaunchOptions = onLaunchOptions, onProfilePreference = onProfilePreference, + onRetryHighFps = onRetryHighFps, onResetProfile = onResetProfile ) @@ -951,11 +1125,13 @@ private fun LaunchControlsPanel( playLabel: String, launchOptionsLabel: String, profilePreferenceLabel: String, + profileSummary: NovaLaunchProfileSummary?, resetProfileLabel: String, resetProfileWorking: Boolean, onPrimaryLaunch: () -> Unit, onLaunchOptions: () -> Unit, onProfilePreference: () -> Unit, + onRetryHighFps: () -> Unit, onResetProfile: () -> Unit ) { NovaDetailPanel( @@ -974,11 +1150,13 @@ private fun LaunchControlsPanel( playLabel = playLabel, launchOptionsLabel = launchOptionsLabel, profilePreferenceLabel = profilePreferenceLabel, + profileSummary = profileSummary, resetProfileLabel = resetProfileLabel, resetProfileWorking = resetProfileWorking, onPrimaryLaunch = onPrimaryLaunch, onLaunchOptions = onLaunchOptions, onProfilePreference = onProfilePreference, + onRetryHighFps = onRetryHighFps, onResetProfile = onResetProfile ) } @@ -1030,11 +1208,13 @@ private fun LaunchControls( playLabel: String, launchOptionsLabel: String, profilePreferenceLabel: String, + profileSummary: NovaLaunchProfileSummary?, resetProfileLabel: String, resetProfileWorking: Boolean, onPrimaryLaunch: () -> Unit, onLaunchOptions: () -> Unit, onProfilePreference: () -> Unit, + onRetryHighFps: () -> Unit, onResetProfile: () -> Unit ) { val colors = LocalNovaComposeColors.current @@ -1076,6 +1256,13 @@ private fun LaunchControls( overflow = TextOverflow.Ellipsis ) + profileSummary?.let { + LaunchProfileSummaryInline( + summary = it, + onRetryHighFps = onRetryHighFps + ) + } + NovaActionButton( text = playLabel, onClick = onPrimaryLaunch, @@ -1137,6 +1324,90 @@ private fun LaunchControls( } } +@Composable +private fun LaunchProfileSummaryInline( + summary: NovaLaunchProfileSummary, + onRetryHighFps: () -> Unit +) { + val colors = LocalNovaComposeColors.current + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 10.dp) + .semantics { contentDescription = "Launch profile summary" } + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp) + .heightIn(min = 1.dp, max = 1.dp) + .background(colors.divider.copy(alpha = 0.55f)) + ) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Launch Profile", + color = colors.accent, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + ProfileSummaryText(summary.selectedLine, topPadding = 4) + ProfileSummaryText(summary.requestedLine) + ProfileSummaryText(summary.limitingLine) + ProfileSummaryText(summary.reasonLine) + } + } + + if (summary.historyLines.isNotEmpty()) { + Text( + text = summary.historyLines.first(), + modifier = Modifier.padding(top = 4.dp), + color = colors.textMuted, + fontSize = 10.sp, + lineHeight = 13.sp, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } else { + ProfileSummaryText(summary.freshnessLine) + } + + if (summary.showRetryHighFps) { + NovaActionButton( + text = summary.retryHighFpsLabel, + onClick = onRetryHighFps, + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + contentDescription = summary.retryHighFpsLabel, + minHeight = 36.dp, + cornerRadius = 8.dp, + fontSize = 11.sp, + contentPadding = PaddingValues(horizontal = 10.dp, vertical = 7.dp) + ) + } + } +} + +@Composable +private fun ProfileSummaryText(text: String, topPadding: Int = 3) { + if (text.isBlank()) return + Text( + text = text, + modifier = Modifier.padding(top = topPadding.dp), + color = LocalNovaComposeColors.current.textMuted, + fontSize = 10.sp, + lineHeight = 13.sp, + maxLines = 3, + overflow = TextOverflow.Ellipsis + ) +} + @Composable private fun SteamLaunchModeCard( visible: Boolean, diff --git a/app/src/main/java/com/papi/nova/ui/NovaHudUiState.kt b/app/src/main/java/com/papi/nova/ui/NovaHudUiState.kt index 9cc962b8..cb3fd0fc 100644 --- a/app/src/main/java/com/papi/nova/ui/NovaHudUiState.kt +++ b/app/src/main/java/com/papi/nova/ui/NovaHudUiState.kt @@ -289,6 +289,8 @@ data class NovaHudUiState( } AutoQualityUiState.State.RECOVERING -> when { compactLabel == "HOST" -> "AI Recovery" + label.contains("safe", ignoreCase = true) -> "Auto Safe" + label.contains("cap", ignoreCase = true) -> "Auto Safe" label.contains("bitrate", ignoreCase = true) -> "Bitrate Recovery" label.contains("FPS", ignoreCase = true) -> "FPS Recovery" else -> "Recovering" diff --git a/app/src/main/java/com/papi/nova/ui/NovaLaunchProfileSummary.kt b/app/src/main/java/com/papi/nova/ui/NovaLaunchProfileSummary.kt new file mode 100644 index 00000000..ce2c14b0 --- /dev/null +++ b/app/src/main/java/com/papi/nova/ui/NovaLaunchProfileSummary.kt @@ -0,0 +1,257 @@ +package com.papi.nova.ui + +import org.json.JSONObject +import java.util.Locale +import kotlin.math.abs +import kotlin.math.round + +data class NovaLaunchProfileSummary( + val primaryLaunchLabel: String, + val requestedLine: String, + val selectedLine: String, + val reasonLine: String, + val limitingLine: String, + val freshnessLine: String, + val historyLines: List, + val showRetryHighFps: Boolean, + val retryHighFpsLabel: String +) + +internal fun buildNovaLaunchProfileSummary( + optimization: JSONObject?, + nowSeconds: Long = System.currentTimeMillis() / 1000L +): NovaLaunchProfileSummary? { + if (optimization == null) return null + + val profileState = optimization.optJSONObject("profile_state") + val currentProfile = profileState?.optJSONObject("current_profile") + ?: optimization.optJSONObject("effective_profile") + val requestedProfile = optimization.optJSONObject("preference_requested_profile") + ?: optimization.optJSONObject("requested_profile") + val lastResult = profileState?.optJSONObject("last_result") + val actions = profileState?.optJSONObject("actions") + + val preference = normalized(optimization.optString("preference", profileState?.optString("preference", "auto") ?: "auto")) + val preferenceLabel = profileState + ?.optString("preference_label", "") + ?.takeIf { it.isNotBlank() } + ?: preferenceLabel(preference) + val state = normalized(profileState?.optString("state", "") ?: "") + val rawSelectedLabel = profileState + ?.optString("label", "") + ?.takeIf { it.isNotBlank() } + ?: selectedLabelFromState(state) + val trialProfile = optimization.optBoolean("trial_profile", false) || + profileState?.optBoolean("trial_profile", false) == true + + val requestedFps = firstPositive( + requestedProfile?.optDouble("target_fps", 0.0) ?: 0.0, + optimization.optDouble("preference_requested_target_fps", 0.0), + optimization.optDouble("requested_target_fps", 0.0), + parseDisplayModeFps(requestedProfile?.optString("display_mode", "")) + ) + val effectiveFps = firstPositive( + currentProfile?.optDouble("target_fps", 0.0) ?: 0.0, + optimization.optDouble("effective_target_fps", 0.0), + parseDisplayModeFps(currentProfile?.optString("display_mode", "")), + parseDisplayModeFps(optimization.optString("display_mode", "")) + ) + val highFpsRequestSatisfied = preference == "high_fps" && + requestedFps > 0.0 && + effectiveFps > 0.0 && + effectiveFps + 0.5 >= requestedFps + val selectedLabel = when { + trialProfile -> "High FPS Trial" + highFpsRequestSatisfied -> "High FPS" + else -> rawSelectedLabel + } + + val primaryLabel = when { + trialProfile && effectiveFps > 0.0 -> "Launch High FPS Trial ${formatFps(effectiveFps)} FPS" + selectedLabel.equals("High FPS", ignoreCase = true) && effectiveFps > 0.0 -> + "Launch High FPS ${formatFps(effectiveFps)} FPS" + selectedLabel.equals("Recovery", ignoreCase = true) && effectiveFps > 0.0 -> + "Launch Recovery ${formatFps(effectiveFps)} FPS" + effectiveFps > 0.0 -> "Launch ${formatFps(effectiveFps)} FPS" + selectedLabel.isNotBlank() -> "Launch $selectedLabel" + else -> "" + } + + val requestedLine = if (requestedFps > 0.0) { + "Requested: $preferenceLabel / ${formatFps(requestedFps)} FPS" + } else { + "Requested: $preferenceLabel" + } + val selectedLine = if (effectiveFps > 0.0) { + "Selected: $selectedLabel / ${formatFps(effectiveFps)} FPS" + } else { + "Selected: $selectedLabel" + } + + val reasonText = profileState + ?.optString("reason", "") + ?.takeIf { it.isNotBlank() } + ?: optimization.optString("reasoning", "").takeIf { it.isNotBlank() }.orEmpty() + val reasonLine = reasonText.takeIf { it.isNotBlank() }?.let { "Reason: $it" }.orEmpty() + + val issue = limitingIssue(optimization, lastResult) + val limitingLine = issue.takeIf { it.isNotBlank() }?.let { "Limited by: ${issueLabel(it)}" }.orEmpty() + + val updatedAt = lastResult?.optLong("updated_at", 0L) ?: 0L + val freshnessLine = when { + trialProfile -> "One-launch trial; learned recovery remains active unless this launch grades cleanly." + selectedLabel.equals("Recovery", ignoreCase = true) && updatedAt > 0L -> + "Recovery active from last session · ${relativeAge(updatedAt, nowSeconds)}" + selectedLabel.equals("Recovery", ignoreCase = true) -> + "Recovery active from last session" + else -> "" + } + + val historyLines = buildHistoryLines(lastResult, issue, selectedLabel) + val highFpsHeldBelowRequest = preference == "high_fps" && + requestedFps > 0.0 && + effectiveFps > 0.0 && + requestedFps > effectiveFps + 0.5 + val preferenceApplied = optimization.optBoolean( + "preference_applied", + profileState?.optBoolean("preference_applied", false) ?: false + ) + val showRetryHighFps = !trialProfile && + highFpsHeldBelowRequest && + ( + actions?.optBoolean("can_retry_high_fps", false) == true || + (preference == "high_fps" && !preferenceApplied) + ) + val retryLabel = if (requestedFps > effectiveFps + 0.5) { + "Try ${formatFps(requestedFps)} FPS once" + } else { + "Try High FPS once" + } + + return NovaLaunchProfileSummary( + primaryLaunchLabel = primaryLabel, + requestedLine = requestedLine, + selectedLine = selectedLine, + reasonLine = reasonLine, + limitingLine = limitingLine, + freshnessLine = freshnessLine, + historyLines = historyLines, + showRetryHighFps = showRetryHighFps, + retryHighFpsLabel = retryLabel + ) +} + +private fun buildHistoryLines( + lastResult: JSONObject?, + issue: String, + selectedLabel: String +): List { + if (lastResult == null) return emptyList() + + val lines = mutableListOf() + val grade = lastResult.optString("grade", "").takeIf { it.isNotBlank() } + val deliveredFps = lastResult.optDouble("delivered_fps", 0.0) + val targetFps = lastResult.optDouble("target_fps", 0.0) + if (grade != null && deliveredFps > 0.0 && targetFps > 0.0) { + lines += "Last: grade $grade at ${formatFps(deliveredFps)}/${formatFps(targetFps)} FPS" + } else if (grade != null) { + lines += "Last: grade $grade" + } + if (issue.isNotBlank()) { + lines += "Issue: ${issueLabel(issue)}" + } + if (selectedLabel.equals("Recovery", ignoreCase = true)) { + lines += "Next: one clean launch can release recovery, or reset this game profile." + } + return lines +} + +private fun limitingIssue(optimization: JSONObject, lastResult: JSONObject?): String { + val limitingFactor = meaningfulIssue(optimization.optString("limiting_factor", "")) + if (limitingFactor.isNotBlank()) { + return limitingFactor + } + return meaningfulIssue(lastResult?.optString("primary_issue", "") ?: "") +} + +private fun meaningfulIssue(value: String): String { + val issue = normalized(value) + return when (issue) { + "", "none", "steady", "stable", "good", "ok", "healthy" -> "" + else -> issue + } +} + +private fun preferenceLabel(preference: String): String { + return when (preference) { + "quality" -> "Prefer Quality" + "high_fps" -> "Prefer High FPS" + "stability" -> "Prefer Stability" + else -> "Auto" + } +} + +private fun selectedLabelFromState(state: String): String { + return when (state) { + "recovering" -> "Recovery" + "trial" -> "High FPS Trial" + "blocked" -> "Holding" + "learning" -> "Learning" + "stable" -> "Quality" + else -> "Profile" + } +} + +private fun issueLabel(issue: String): String { + return when (normalized(issue)) { + "host_render", "host_render_limited" -> "Host render" + "decoder", "decoder_path" -> "Decoder path" + "network" -> "Network" + "encoder" -> "Encoder" + "pacing", "frame_pacing" -> "Frame pacing" + else -> issue.replace('_', ' ').replaceFirstChar { it.uppercase() } + } +} + +private fun relativeAge(updatedAtSeconds: Long, nowSeconds: Long): String { + val deltaSeconds = (nowSeconds - updatedAtSeconds).coerceAtLeast(0L) + return when { + deltaSeconds < 60L -> "just now" + deltaSeconds < 3600L -> { + val minutes = deltaSeconds / 60L + "$minutes min ago" + } + deltaSeconds < 86_400L -> { + val hours = deltaSeconds / 3600L + "$hours hr ago" + } + else -> { + val days = deltaSeconds / 86_400L + "$days d ago" + } + } +} + +private fun parseDisplayModeFps(displayMode: String?): Double { + if (displayMode.isNullOrBlank()) return 0.0 + val parts = displayMode.split("x") + if (parts.size < 3) return 0.0 + return parts[2].toDoubleOrNull() ?: 0.0 +} + +private fun firstPositive(vararg values: Double): Double { + return values.firstOrNull { it > 0.0 } ?: 0.0 +} + +private fun normalized(value: String): String { + return value.trim().lowercase(Locale.US) +} + +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) + } +} 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 c7e47016..bad303fd 100644 --- a/app/src/main/java/com/papi/nova/ui/NovaLibraryActivity.kt +++ b/app/src/main/java/com/papi/nova/ui/NovaLibraryActivity.kt @@ -424,13 +424,18 @@ class NovaLibraryActivity : AppCompatActivity() { apiClient = apiClient, defaultToVirtualDisplay = defaultToVirtualDisplay, clientSettings = clientSettings - ) { selectedGame, withVirtualDisplay -> - launchGame(selectedGame, withVirtualDisplay) + ) { selectedGame, withVirtualDisplay, profilePreference, preflightOptimization -> + launchGame(selectedGame, withVirtualDisplay, profilePreference, preflightOptimization) } detailSheet?.show(supportFragmentManager, "game_detail") } - private fun launchGame(game: PolarisGame, withVirtualDisplay: Boolean) { + private fun launchGame( + game: PolarisGame, + withVirtualDisplay: Boolean, + profilePreference: String = "auto", + preflightOptimization: org.json.JSONObject? = null + ) { if (game.appId <= 0) { Toast.makeText(this, "This game entry is missing a launch ID", Toast.LENGTH_SHORT).show() return @@ -462,6 +467,21 @@ class NovaLibraryActivity : AppCompatActivity() { if (!mangoHudSynced) { LimeLog.warning("Nova: MangoHUD launch state sync failed; continuing launch") } + val preferences = com.papi.nova.preferences.PreferenceConfiguration.readPreferences(this@NovaLibraryActivity) + val syncedSettings = withContext(Dispatchers.IO) { + apiClient.updateClientSettings( + streamDisplayMode = if (withVirtualDisplay) "host_virtual_display" else "headless_stream", + displayMode = com.papi.nova.preferences.PreferenceConfiguration.formatStreamingDisplayMode( + preferences.width, + preferences.height, + preferences.fps + ), + targetBitrateKbps = preferences.bitrate.takeIf { it > 0 } + ) + } + if (syncedSettings == null) { + LimeLog.warning("Nova: Preflight client settings sync failed; continuing launch") + } val app = NvApp(game.name, game.id, game.appId, game.hdrSupported) ServerHelper.doStart( @@ -477,7 +497,9 @@ class NovaLibraryActivity : AppCompatActivity() { withVirtualDisplay, true, false, - serverCert + serverCert, + aiProfilePreference = profilePreference, + launchOptimizationJson = preflightOptimization?.toString() ) } } diff --git a/app/src/main/java/com/papi/nova/utils/ServerHelper.kt b/app/src/main/java/com/papi/nova/utils/ServerHelper.kt index 322a8945..662898de 100644 --- a/app/src/main/java/com/papi/nova/utils/ServerHelper.kt +++ b/app/src/main/java/com/papi/nova/utils/ServerHelper.kt @@ -112,6 +112,8 @@ object ServerHelper { streamWidth: Int = 0, streamHeight: Int = 0, streamFps: Float = 0f, + aiProfilePreference: String = "auto", + launchOptimizationJson: String? = null, ): Intent { val prefConfig = PreferenceConfiguration.readPreferences(parent) val gameIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && prefConfig.enableFullExDisplay) { @@ -147,6 +149,10 @@ object ServerHelper { if (streamFps > 0f) { gameIntent.putExtra(Game.EXTRA_STREAM_FPS, streamFps) } + gameIntent.putExtra(Game.EXTRA_AI_PROFILE_PREFERENCE, aiProfilePreference) + if (!launchOptimizationJson.isNullOrBlank()) { + gameIntent.putExtra(Game.EXTRA_LAUNCH_OPTIMIZATION, launchOptimizationJson) + } if (serverCommands != null) { gameIntent.putStringArrayListExtra(Game.EXTRA_SERVER_COMMANDS, serverCommands) @@ -280,6 +286,8 @@ object ServerHelper { streamWidth: Int, streamHeight: Int, streamFps: Float, + aiProfilePreference: String = "auto", + launchOptimizationJson: String? = null, ) { parent.getSharedPreferences("nova_prefs", Context.MODE_PRIVATE).edit() .putInt("last_played_$pcUuid", app.appId) @@ -302,6 +310,8 @@ object ServerHelper { streamWidth, streamHeight, streamFps, + aiProfilePreference, + launchOptimizationJson, ) parent.startActivity(intent) NovaThemeManager.applyFadeTransition(parent) @@ -363,6 +373,8 @@ object ServerHelper { displayModeExplicit: Boolean, watchOnly: Boolean, serverCert: ByteArray?, + aiProfilePreference: String = "auto", + launchOptimizationJson: String? = null, ) { parent.getSharedPreferences("nova_prefs", Context.MODE_PRIVATE).edit() .putInt("last_played_$pcUuid", app.appId) @@ -382,6 +394,8 @@ object ServerHelper { watchOnly, serverCommands, serverCert, + aiProfilePreference = aiProfilePreference, + launchOptimizationJson = launchOptimizationJson, ) parent.startActivity(intent) NovaThemeManager.applyFadeTransition(parent) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b967f35a..f89516b8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -175,6 +175,11 @@ Desktop Display GPU-Native Test Launch Recommended + Review & Launch + Review launch profile + Polaris selected a different launch profile: %1$s + Launch + Retry High FPS Launch Options AI Preference: Auto AI Preference: Prefer Quality diff --git a/app/src/test/java/com/papi/nova/KotlinGameRuntimeMigrationTest.kt b/app/src/test/java/com/papi/nova/KotlinGameRuntimeMigrationTest.kt index b26c0f98..8f533816 100644 --- a/app/src/test/java/com/papi/nova/KotlinGameRuntimeMigrationTest.kt +++ b/app/src/test/java/com/papi/nova/KotlinGameRuntimeMigrationTest.kt @@ -54,6 +54,8 @@ class KotlinGameRuntimeMigrationTest { assertEquals("StreamWidth", Game.EXTRA_STREAM_WIDTH) assertEquals("StreamHeight", Game.EXTRA_STREAM_HEIGHT) assertEquals("StreamFps", Game.EXTRA_STREAM_FPS) + assertEquals("AiProfilePreference", Game.EXTRA_AI_PROFILE_PREFERENCE) + assertEquals("LaunchOptimization", Game.EXTRA_LAUNCH_OPTIMIZATION) assertEquals("ServerCommands", Game.EXTRA_SERVER_COMMANDS) assertEquals("DisplayID", Game.EXTRA_DISPLAY_ID) assertEquals("ArtemisStreaming", Game.CLIPBOARD_IDENTIFIER) diff --git a/app/src/test/java/com/papi/nova/api/PolarisApiClientParsingTest.kt b/app/src/test/java/com/papi/nova/api/PolarisApiClientParsingTest.kt index 32c1c109..ce46a0b8 100644 --- a/app/src/test/java/com/papi/nova/api/PolarisApiClientParsingTest.kt +++ b/app/src/test/java/com/papi/nova/api/PolarisApiClientParsingTest.kt @@ -395,4 +395,19 @@ class PolarisApiClientParsingTest { assertEquals("game-1", body.getString("game_id")) assertEquals("big-picture", body.getString("mode")) } + + @Test + fun buildOptimizationPath_includesHighFpsTrialWhenRequested() { + val path = PolarisApiClient.buildOptimizationPath( + device = "RetroidPocket6", + game = "Black Myth: Wukong", + preference = "high_fps", + trial = "high_fps" + ) + + assertEquals( + "/optimize?device=RetroidPocket6&game=Black+Myth%3A+Wukong&preference=high_fps&trial=high_fps", + path + ) + } } diff --git a/app/src/test/java/com/papi/nova/manager/StreamSyncManagerTest.kt b/app/src/test/java/com/papi/nova/manager/StreamSyncManagerTest.kt index 6b94a19f..d76c64e9 100644 --- a/app/src/test/java/com/papi/nova/manager/StreamSyncManagerTest.kt +++ b/app/src/test/java/com/papi/nova/manager/StreamSyncManagerTest.kt @@ -215,6 +215,80 @@ class StreamSyncManagerTest { assertEquals(120f, targetFps, 0.01f) } + @Test + fun requiresLaunchPreflightReview_ignoresMatchingRequestedAndEffectiveFps() { + val optimization = JSONObject( + "{\"requested_target_fps\":120,\"effective_target_fps\":120," + + "\"profile_state\":{\"preference\":\"high_fps\",\"preference_applied\":true}}" + ) + + assertFalse(StreamSyncManager.requiresLaunchPreflightReview(optimization)) + } + + @Test + fun requiresLaunchPreflightReview_ignoresUnappliedPreferenceWithoutMaterialOverride() { + val optimization = JSONObject( + "{\"requested_target_fps\":120,\"effective_target_fps\":120," + + "\"preference\":\"high_fps\",\"preference_applied\":false," + + "\"preference_blocked_reason\":\"none\"," + + "\"profile_state\":{\"preference\":\"high_fps\",\"preference_applied\":false," + + "\"preference_blocked_reason\":\"none\"}}" + ) + + assertFalse(StreamSyncManager.requiresLaunchPreflightReview(optimization)) + assertEquals( + "", + StreamSyncManager.launchPreflightReviewReason(optimization) + ) + } + + @Test + fun requiresLaunchPreflightReview_ignoresHighFpsBlockReasonWhenTargetMatches() { + val optimization = JSONObject( + "{\"requested_target_fps\":120,\"effective_target_fps\":120," + + "\"preference\":\"high_fps\",\"preference_applied\":false," + + "\"preference_blocked_reason\":\"host_render_limited\"," + + "\"profile_state\":{\"preference\":\"high_fps\",\"preference_applied\":false," + + "\"preference_blocked_reason\":\"host_render_limited\"}}" + ) + + assertFalse(StreamSyncManager.requiresLaunchPreflightReview(optimization)) + assertEquals( + "", + StreamSyncManager.launchPreflightReviewReason(optimization) + ) + } + + @Test + fun requiresLaunchPreflightReview_flagsMaterialFpsOverride() { + val optimization = JSONObject( + "{\"requested_target_fps\":120,\"effective_target_fps\":40," + + "\"preference_blocked_reason\":\"optimizer_selected_lower_fps\"," + + "\"profile_state\":{\"preference\":\"high_fps\",\"preference_applied\":false}}" + ) + + assertTrue(StreamSyncManager.requiresLaunchPreflightReview(optimization)) + assertEquals( + "optimizer_selected_lower_fps", + StreamSyncManager.launchPreflightReviewReason(optimization) + ) + } + + @Test + fun requiresLaunchPreflightReview_flagsExplicitlyBlockedNonAutoPreference() { + val optimization = JSONObject( + "{\"requested_target_fps\":120,\"effective_target_fps\":120," + + "\"profile_state\":{\"preference\":\"quality\",\"preference_applied\":false," + + "\"preference_blocked_reason\":\"recent_degraded_session\"}}" + ) + + assertTrue(StreamSyncManager.requiresLaunchPreflightReview(optimization)) + assertEquals( + "recent_degraded_session", + StreamSyncManager.launchPreflightReviewReason(optimization) + ) + } + @Test fun resolveDisplayCompatibleAutoSafeTargetFps_keepsFortyWhenOneTwentyAllowed() { val selected = StreamSyncManager.resolveDisplayCompatibleAutoSafeTargetFps( diff --git a/app/src/test/java/com/papi/nova/ui/NovaHudUiStateTest.kt b/app/src/test/java/com/papi/nova/ui/NovaHudUiStateTest.kt index 264dbe6d..49e0bf7a 100644 --- a/app/src/test/java/com/papi/nova/ui/NovaHudUiStateTest.kt +++ b/app/src/test/java/com/papi/nova/ui/NovaHudUiStateTest.kt @@ -124,6 +124,44 @@ class NovaHudUiStateTest { assertEquals(NovaHudTone.WARNING, state.fpsTone) } + @Test + fun autoSafeBitrateCapUsesExplicitHudLabel() { + val state = NovaHudUiState.from( + mode = NovaHudMode.FPS_ONLY, + fps = 118.7, + targetFps = 120.0, + latencyMs = 18, + codec = "hevc", + bitrateKbps = 12000, + width = 1920, + height = 1080, + status = status( + encoder = PolarisSessionStatus.EncoderStatus( + codec = "hevc_nvenc", + bitrateKbps = 12000, + fps = 120.0, + requestedClientFps = 120.0, + sessionTargetFps = 120.0, + encodeTargetFps = 120.0, + optimizationSource = "ai_cached", + optimizationCacheStatus = "hit", + targetResidency = "gpu" + ), + tuning = PolarisSessionStatus.TuningStatus( + adaptiveBitrateEnabled = true, + adaptiveTargetBitrateKbps = 12000, + adaptiveBaseBitrateKbps = 28000, + aiOptimizerEnabled = true + ) + ), + sparklineSamples = listOf(118f) + ) + + assertEquals("Auto Safe capped", state.autopilotLabel) + assertEquals("Auto Safe", state.autopilotHudLabel) + assertEquals(NovaHudTone.WARNING, state.statusTone) + } + @Test fun hudLabelsStayCompactForSpaceConstrainedOverlay() { val stable = NovaHudUiState.from( @@ -236,18 +274,19 @@ class NovaHudUiStateTest { displayMode: PolarisSessionStatus.DisplayModeStatus = PolarisSessionStatus.DisplayModeStatus( requested = "headless", effectiveHeadless = true + ), + tuning: PolarisSessionStatus.TuningStatus = PolarisSessionStatus.TuningStatus( + adaptiveBitrateEnabled = true, + adaptiveTargetBitrateKbps = 30000, + adaptiveBaseBitrateKbps = 30000, + aiOptimizerEnabled = true ) ) = PolarisSessionStatus( state = "streaming", streamingActive = true, adaptiveBitrateEnabled = true, aiOptimizerEnabled = true, - tuning = PolarisSessionStatus.TuningStatus( - adaptiveBitrateEnabled = true, - adaptiveTargetBitrateKbps = 30000, - adaptiveBaseBitrateKbps = 30000, - aiOptimizerEnabled = true - ), + tuning = tuning, encoder = encoder, capture = capture, health = health, diff --git a/app/src/test/java/com/papi/nova/ui/NovaLaunchProfileSummaryTest.kt b/app/src/test/java/com/papi/nova/ui/NovaLaunchProfileSummaryTest.kt new file mode 100644 index 00000000..176bc349 --- /dev/null +++ b/app/src/test/java/com/papi/nova/ui/NovaLaunchProfileSummaryTest.kt @@ -0,0 +1,160 @@ +package com.papi.nova.ui + +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@Config(sdk = [33]) +@RunWith(RobolectricTestRunner::class) +class NovaLaunchProfileSummaryTest { + @Test + fun highFpsRecoverySummaryNamesEffectiveLaunchAndRetry() { + val summary = buildNovaLaunchProfileSummary( + JSONObject( + "{" + + "\"source\":\"history_safe\"," + + "\"display_mode\":\"1920x1080x40\"," + + "\"effective_target_fps\":40," + + "\"target_bitrate_kbps\":6000," + + "\"preferred_codec\":\"hevc\"," + + "\"preference\":\"high_fps\"," + + "\"preference_applied\":false," + + "\"preference_blocked_reason\":\"history_safe_profile\"," + + "\"limiting_factor\":\"decoder\"," + + "\"preference_requested_profile\":{\"display_mode\":\"1920x1080x120\",\"target_fps\":120}," + + "\"profile_state\":{" + + "\"state\":\"recovering\"," + + "\"label\":\"Recovery\"," + + "\"reason\":\"Holding the safer launch profile until a clean session confirms recovery.\"," + + "\"preference_label\":\"Prefer High FPS\"," + + "\"preference_applied\":false," + + "\"preference_blocked_reason\":\"history_safe_profile\"," + + "\"current_profile\":{\"display_mode\":\"1920x1080x40\",\"target_fps\":40," + + "\"target_bitrate_kbps\":6000,\"preferred_codec\":\"hevc\"}," + + "\"last_result\":{\"grade\":\"B\",\"delivered_fps\":58.5,\"target_fps\":60," + + "\"primary_issue\":\"decoder_path\",\"updated_at\":1780000000}," + + "\"actions\":{\"can_retry_high_fps\":true,\"can_reset\":true}" + + "}" + + "}" + ), + nowSeconds = 1780000060L + ) + + requireNotNull(summary) + assertEquals("Launch Recovery 40 FPS", summary.primaryLaunchLabel) + assertEquals("Requested: Prefer High FPS / 120 FPS", summary.requestedLine) + assertEquals("Selected: Recovery / 40 FPS", summary.selectedLine) + assertEquals("Limited by: Decoder path", summary.limitingLine) + assertEquals("Recovery active from last session · 1 min ago", summary.freshnessLine) + assertEquals("Try 120 FPS once", summary.retryHighFpsLabel) + assertTrue(summary.showRetryHighFps) + assertTrue(summary.historyLines.contains("Last: grade B at 58.5/60 FPS")) + assertTrue(summary.historyLines.contains("Issue: Decoder path")) + assertTrue(summary.historyLines.contains("Next: one clean launch can release recovery, or reset this game profile.")) + } + + @Test + fun highFpsTrialSummaryNamesOneLaunchTrial() { + val summary = buildNovaLaunchProfileSummary( + JSONObject( + "{" + + "\"source\":\"device_db\"," + + "\"trial_profile\":true," + + "\"trial_kind\":\"high_fps\"," + + "\"display_mode\":\"1920x1080x120\"," + + "\"effective_target_fps\":120," + + "\"target_bitrate_kbps\":30000," + + "\"preferred_codec\":\"hevc\"," + + "\"preference\":\"high_fps\"," + + "\"preference_applied\":true," + + "\"preference_blocked_reason\":\"none\"," + + "\"preference_requested_profile\":{\"display_mode\":\"1920x1080x120\",\"target_fps\":120}," + + "\"profile_state\":{" + + "\"state\":\"trial\"," + + "\"label\":\"High FPS Trial\"," + + "\"reason\":\"Trying High FPS once; learned recovery remains active unless this launch grades cleanly.\"," + + "\"preference_label\":\"Prefer High FPS\"," + + "\"preference_applied\":true," + + "\"current_profile\":{\"display_mode\":\"1920x1080x120\",\"target_fps\":120," + + "\"target_bitrate_kbps\":30000,\"preferred_codec\":\"hevc\"}," + + "\"actions\":{\"can_retry_high_fps\":false,\"can_reset\":true}" + + "}" + + "}" + ) + ) + + requireNotNull(summary) + assertEquals("Launch High FPS Trial 120 FPS", summary.primaryLaunchLabel) + assertEquals("Requested: Prefer High FPS / 120 FPS", summary.requestedLine) + assertEquals("Selected: High FPS Trial / 120 FPS", summary.selectedLine) + assertFalse(summary.showRetryHighFps) + } + + @Test + fun highFpsSatisfiedSummaryDoesNotOfferRetry() { + val summary = buildNovaLaunchProfileSummary( + JSONObject( + "{" + + "\"display_mode\":\"1920x1080x120\"," + + "\"effective_target_fps\":120," + + "\"preference\":\"high_fps\"," + + "\"preference_applied\":false," + + "\"preference_blocked_reason\":\"host_render_limited\"," + + "\"preference_requested_profile\":{\"display_mode\":\"1920x1080x120\",\"target_fps\":120}," + + "\"profile_state\":{" + + "\"state\":\"recovering\"," + + "\"label\":\"Recovery\"," + + "\"reason\":\"Holding quality until the host render path reaches the stream FPS target.\"," + + "\"preference_label\":\"Prefer High FPS\"," + + "\"preference_applied\":false," + + "\"preference_blocked_reason\":\"host_render_limited\"," + + "\"current_profile\":{\"display_mode\":\"1920x1080x120\",\"target_fps\":120}," + + "\"last_result\":{\"grade\":\"A\",\"delivered_fps\":38.9,\"target_fps\":40," + + "\"primary_issue\":\"host_render\",\"updated_at\":1780000000}," + + "\"actions\":{\"can_retry_high_fps\":true}" + + "}" + + "}" + ), + nowSeconds = 1780000060L + ) + + requireNotNull(summary) + assertEquals("Launch High FPS 120 FPS", summary.primaryLaunchLabel) + assertEquals("Selected: High FPS / 120 FPS", summary.selectedLine) + assertFalse(summary.showRetryHighFps) + } + + @Test + fun steadyLastResultDoesNotRenderAsLimited() { + val summary = buildNovaLaunchProfileSummary( + JSONObject( + "{" + + "\"display_mode\":\"1920x1080x120\"," + + "\"effective_target_fps\":120," + + "\"preference\":\"auto\"," + + "\"preference_applied\":true," + + "\"preference_requested_profile\":{\"display_mode\":\"1920x1080x120\",\"target_fps\":120}," + + "\"profile_state\":{" + + "\"state\":\"stable\"," + + "\"label\":\"Quality\"," + + "\"reason\":\"Auto Quality is holding the current launch profile.\"," + + "\"preference_label\":\"Auto\"," + + "\"current_profile\":{\"display_mode\":\"1920x1080x120\",\"target_fps\":120}," + + "\"last_result\":{\"grade\":\"A\",\"delivered_fps\":120.1,\"target_fps\":120," + + "\"primary_issue\":\"steady\",\"updated_at\":1780000000}" + + "}" + + "}" + ), + nowSeconds = 1780000060L + ) + + requireNotNull(summary) + assertEquals("", summary.limitingLine) + assertFalse(summary.historyLines.contains("Issue: Steady")) + } +} 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 041a8630..e82eeeca 100644 --- a/app/src/test/java/com/papi/nova/ui/NovaLaunchSourceGuardTest.kt +++ b/app/src/test/java/com/papi/nova/ui/NovaLaunchSourceGuardTest.kt @@ -34,7 +34,7 @@ class NovaLaunchSourceGuardTest { fun libraryLaunchSynchronizesMangoHudBeforeStartingStream() { val activity = readSource("src/main/java/com/papi/nova/ui/NovaLibraryActivity.kt") val launchGame = activity.section( - "private fun launchGame(game: PolarisGame, withVirtualDisplay: Boolean)", + "private fun launchGame(", "private fun resumeActiveSession(" ) From 89d687a0ade5261544d69e7f41db0a9a670d4e6f Mon Sep 17 00:00:00 2001 From: papi Date: Wed, 20 May 2026 16:23:32 -0400 Subject: [PATCH 3/5] Refresh first-run welcome flow --- app/src/main/java/com/papi/nova/PcView.kt | 10 + .../com/papi/nova/ui/NovaWelcomeActivity.kt | 47 ++- .../res/layout-land/activity_nova_welcome.xml | 295 +++++++---------- .../main/res/layout/activity_nova_welcome.xml | 309 +++++++----------- .../papi/nova/ui/NovaWelcomeRefreshTest.kt | 61 ++++ 5 files changed, 355 insertions(+), 367 deletions(-) create mode 100644 app/src/test/java/com/papi/nova/ui/NovaWelcomeRefreshTest.kt diff --git a/app/src/main/java/com/papi/nova/PcView.kt b/app/src/main/java/com/papi/nova/PcView.kt index 380c2a05..e4cb0207 100644 --- a/app/src/main/java/com/papi/nova/PcView.kt +++ b/app/src/main/java/com/papi/nova/PcView.kt @@ -1098,6 +1098,16 @@ class PcView : AppCompatActivity(), AdapterFragmentCallbacks { } initializeViews(prefs) + handleWelcomeAction(intent.getStringExtra(NovaWelcomeActivity.EXTRA_WELCOME_ACTION)) + } + + private fun handleWelcomeAction(action: String?) { + if (action != NovaWelcomeActivity.ACTION_SCAN_QR) { + return + } + + intent.removeExtra(NovaWelcomeActivity.EXTRA_WELCOME_ACTION) + window.decorView.post { launchQrScanner() } } private fun startComputerUpdates() { diff --git a/app/src/main/java/com/papi/nova/ui/NovaWelcomeActivity.kt b/app/src/main/java/com/papi/nova/ui/NovaWelcomeActivity.kt index 31a8f3dd..bf85de94 100644 --- a/app/src/main/java/com/papi/nova/ui/NovaWelcomeActivity.kt +++ b/app/src/main/java/com/papi/nova/ui/NovaWelcomeActivity.kt @@ -1,9 +1,13 @@ package com.papi.nova.ui +import android.content.Context +import android.content.Intent import android.os.Bundle +import android.view.View import androidx.appcompat.app.AppCompatActivity -import com.papi.nova.R import com.papi.nova.PcView +import com.papi.nova.R +import com.papi.nova.preferences.AddComputerManually /** * First-launch welcome screen. Shows once, then never again. @@ -15,22 +19,39 @@ class NovaWelcomeActivity : AppCompatActivity() { super.onCreate(savedInstanceState) setContentView(R.layout.activity_nova_welcome) - findViewById(R.id.welcome_start_btn).setOnClickListener { - // Mark as seen - getSharedPreferences("nova_prefs", MODE_PRIVATE).edit() - .putBoolean("welcome_seen", true) - .commit() - val next = android.content.Intent(this, PcView::class.java) - intent.extras?.let { next.putExtras(it) } - startActivity(next) - finish() + findViewById(R.id.welcome_discover_btn).setOnClickListener { + finishWelcome(Intent(this, PcView::class.java)) + } + findViewById(R.id.welcome_add_manual_btn).setOnClickListener { + finishWelcome(Intent(this, AddComputerManually::class.java)) + } + findViewById(R.id.welcome_scan_qr_btn).setOnClickListener { + finishWelcome( + Intent(this, PcView::class.java) + .putExtra(EXTRA_WELCOME_ACTION, ACTION_SCAN_QR), + ) } } + private fun finishWelcome(next: Intent) { + getSharedPreferences(PREFS_NAME, MODE_PRIVATE).edit() + .putBoolean(KEY_WELCOME_SEEN, true) + .commit() + intent.extras?.let { next.putExtras(it) } + next.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + startActivity(next) + finish() + } + companion object { - fun shouldShow(context: android.content.Context): Boolean { - return !context.getSharedPreferences("nova_prefs", android.content.Context.MODE_PRIVATE) - .getBoolean("welcome_seen", false) + const val EXTRA_WELCOME_ACTION = "com.papi.nova.extra.WELCOME_ACTION" + const val ACTION_SCAN_QR = "scan_qr" + private const val PREFS_NAME = "nova_prefs" + private const val KEY_WELCOME_SEEN = "welcome_seen" + + fun shouldShow(context: Context): Boolean { + return !context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .getBoolean(KEY_WELCOME_SEEN, false) } } } diff --git a/app/src/main/res/layout-land/activity_nova_welcome.xml b/app/src/main/res/layout-land/activity_nova_welcome.xml index 13d358bf..3e61b33d 100644 --- a/app/src/main/res/layout-land/activity_nova_welcome.xml +++ b/app/src/main/res/layout-land/activity_nova_welcome.xml @@ -5,238 +5,197 @@ android:layout_height="match_parent" android:background="?android:colorBackground"> - - + android:orientation="horizontal" + android:paddingHorizontal="36dp" + android:paddingVertical="24dp"> - + android:paddingEnd="28dp"> + android:src="@drawable/ic_nova_star_foreground" /> + android:text="Welcome to Nova" + android:textColor="@color/nova_ice" + android:textSize="28sp" /> + android:gravity="center" + android:lineSpacingExtra="2dp" + android:text="A polished streaming home for Polaris, handhelds, TV, and Moonlight-compatible hosts." + android:textColor="@color/nova_text_secondary" + android:textSize="14sp" /> - + android:paddingStart="28dp"> - + android:layout_marginBottom="10dp" + android:background="@drawable/nova_card_bg_focusable" + android:orientation="vertical" + android:padding="14dp"> - - - - - - - + android:letterSpacing="0.08" + android:text="01 Find your host" + android:textAllCaps="true" + android:textColor="@color/nova_accent" + android:textSize="10sp" /> + + - + android:layout_marginBottom="10dp" + android:background="@drawable/nova_card_bg_focusable" + android:orientation="vertical" + android:padding="14dp"> - - - - - - - + android:letterSpacing="0.08" + android:text="02 Pair with confidence" + android:textAllCaps="true" + android:textColor="@color/nova_accent" + android:textSize="10sp" /> + + - + android:layout_marginBottom="16dp" + android:background="@drawable/nova_card_bg_focusable" + android:orientation="vertical" + android:padding="14dp"> - - - - - - - + android:letterSpacing="0.08" + android:text="03 Built for handhelds and TV" + android:textAllCaps="true" + android:textColor="@color/nova_accent" + android:textSize="10sp" /> + + - + android:baselineAligned="false" + android:orientation="horizontal"> - - - + + + + - - - - - + android:focusable="true" + android:nextFocusLeft="@id/welcome_add_manual_btn" + android:text="Scan QR" + android:textAllCaps="false" + android:textColor="@color/nova_text_primary" + app:cornerRadius="25dp" + app:strokeColor="@color/nova_focus_stroke_selector" + app:strokeWidth="2dp" /> - - - - diff --git a/app/src/main/res/layout/activity_nova_welcome.xml b/app/src/main/res/layout/activity_nova_welcome.xml index 4e04701e..8b0f0586 100644 --- a/app/src/main/res/layout/activity_nova_welcome.xml +++ b/app/src/main/res/layout/activity_nova_welcome.xml @@ -5,7 +5,6 @@ android:layout_height="match_parent" android:background="?android:colorBackground"> - @@ -19,227 +18,165 @@ + android:orientation="vertical" + android:paddingHorizontal="28dp" + android:paddingTop="40dp" + android:paddingBottom="28dp"> - + android:src="@drawable/ic_nova_star_foreground" /> + android:text="Welcome to Nova" + android:textColor="@color/nova_ice" + android:textSize="28sp" /> + android:gravity="center" + android:lineSpacingExtra="2dp" + android:text="A polished streaming home for Polaris, handhelds, TV, and Moonlight-compatible hosts." + android:textColor="@color/nova_text_secondary" + android:textSize="14sp" /> - + android:padding="18dp"> - - - - - - - - - - - - - - - + + - - - - - - - - - - - - - + + + + + - - - - - - - - - - - - - + + - - - - - - - - - - - + android:layout_marginTop="8dp" + android:text="Use standard Moonlight pairing, or scan a Polaris pairing QR when your host shows one." + android:textColor="@color/nova_text_primary" + android:textSize="16sp" /> - - + + + + + + - + app:cornerRadius="26dp" + app:strokeColor="@color/nova_focus_stroke_selector" + app:strokeWidth="2dp" /> + + + diff --git a/app/src/test/java/com/papi/nova/ui/NovaWelcomeRefreshTest.kt b/app/src/test/java/com/papi/nova/ui/NovaWelcomeRefreshTest.kt new file mode 100644 index 00000000..eee297ae --- /dev/null +++ b/app/src/test/java/com/papi/nova/ui/NovaWelcomeRefreshTest.kt @@ -0,0 +1,61 @@ +package com.papi.nova.ui + +import java.io.File +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class NovaWelcomeRefreshTest { + @Test + fun welcomeLayoutsExposeThreeControllerActions() { + val layouts = arrayOf( + "src/main/res/layout/activity_nova_welcome.xml", + "src/main/res/layout-land/activity_nova_welcome.xml", + ) + + for (layout in layouts) { + val xml = readFile(layout) + assertTrue("$layout should expose Discover hosts", xml.contains("@+id/welcome_discover_btn")) + assertTrue("$layout should expose Add manually", xml.contains("@+id/welcome_add_manual_btn")) + assertTrue("$layout should expose Scan QR", xml.contains("@+id/welcome_scan_qr_btn")) + assertTrue("$layout primary action should be D-pad focusable", buttonBlock(xml, "welcome_discover_btn").contains("android:focusable=\"true\"")) + assertTrue("$layout manual action should be D-pad focusable", buttonBlock(xml, "welcome_add_manual_btn").contains("android:focusable=\"true\"")) + assertTrue("$layout QR action should be D-pad focusable", buttonBlock(xml, "welcome_scan_qr_btn").contains("android:focusable=\"true\"")) + } + } + + @Test + fun welcomeCopyStaysScopedToVerifiedFlows() { + val portrait = readFile("src/main/res/layout/activity_nova_welcome.xml") + val landscape = readFile("src/main/res/layout-land/activity_nova_welcome.xml") + val copy = portrait + landscape + + assertTrue("welcome should mention Polaris", copy.contains("Polaris")) + assertTrue("welcome should mention Moonlight compatibility", copy.contains("Moonlight-compatible") || copy.contains("Moonlight pairing")) + assertTrue("welcome should frame QR as Polaris pairing only", copy.contains("Polaris pairing QR")) + assertFalse("welcome should not overclaim automatic QR or TOFU pairing", copy.contains("TOFU auto-pair")) + assertFalse("welcome should not overclaim AI tuning", copy.contains("AI-optimized")) + } + + @Test + fun welcomeActivityKeepsSeenFlagAndRoutesActions() { + val welcomeSource = readFile("src/main/java/com/papi/nova/ui/NovaWelcomeActivity.kt") + val pcViewSource = readFile("src/main/java/com/papi/nova/PcView.kt") + + assertTrue("welcome_seen should still be persisted", welcomeSource.contains("KEY_WELCOME_SEEN") && welcomeSource.contains("putBoolean(KEY_WELCOME_SEEN, true)")) + assertTrue("welcome completion should still commit synchronously before leaving", welcomeSource.contains(".commit()")) + assertTrue("manual add action should use the existing manual add screen", welcomeSource.contains("AddComputerManually::class.java")) + assertTrue("QR action should be explicit", welcomeSource.contains("EXTRA_WELCOME_ACTION") && welcomeSource.contains("ACTION_SCAN_QR")) + assertTrue("PcView should handle the welcome QR action through the wired scanner", pcViewSource.contains("handleWelcomeAction") && pcViewSource.contains("launchQrScanner()")) + } + + private fun buttonBlock(xml: String, id: String): String { + val idIndex = xml.indexOf("@+id/$id") + assertTrue("missing $id", idIndex >= 0) + val start = xml.lastIndexOf('<', idIndex).coerceAtLeast(0) + val end = xml.indexOf("/>", idIndex).let { if (it >= 0) it + 2 else xml.length } + return xml.substring(start, end) + } + + private fun readFile(path: String): String = File(path).readText() +} From 4937708ef52503915818bced63980d43fd853150 Mon Sep 17 00:00:00 2001 From: papi Date: Wed, 20 May 2026 16:25:52 -0400 Subject: [PATCH 4/5] Polish Nova empty and recovery states --- .../java/com/papi/nova/grid/PcGridAdapter.kt | 44 +++- .../com/papi/nova/ui/NovaLibraryActivity.kt | 230 +++++++++++++++--- app/src/main/res/values/strings.xml | 16 ++ 3 files changed, 247 insertions(+), 43 deletions(-) diff --git a/app/src/main/java/com/papi/nova/grid/PcGridAdapter.kt b/app/src/main/java/com/papi/nova/grid/PcGridAdapter.kt index 419f6fab..1ca4affa 100644 --- a/app/src/main/java/com/papi/nova/grid/PcGridAdapter.kt +++ b/app/src/main/java/com/papi/nova/grid/PcGridAdapter.kt @@ -89,17 +89,23 @@ class PcGridAdapter( statusDot?.setBackgroundResource(R.drawable.nova_status_online) if (statusText != null) { if (obj.details.pairState == PairingManager.PairState.PAIRED && obj.details.serverCert == null) { - statusText.text = "Repair pair" + statusText.setText(R.string.pcview_card_status_repair_pair) statusText.setTextColor(ContextCompat.getColor(context, R.color.nova_warning)) primaryAction?.setText(R.string.pcview_card_action_pair) - setStatusHint(statusHint, R.string.pcview_card_hint_pair) + setStatusHint(statusHint, R.string.pcview_card_hint_pair_repair) } else if (obj.details.pairState == PairingManager.PairState.NOT_PAIRED) { - statusText.text = if (obj.details.serverCert == null) "Pair required" else "Repair pair" + statusText.setText( + if (obj.details.serverCert == null) { + R.string.pcview_card_status_pair_required + } else { + R.string.pcview_card_status_repair_pair + } + ) statusText.setTextColor(ContextCompat.getColor(context, R.color.nova_warning)) primaryAction?.setText(R.string.pcview_card_action_pair) setStatusHint(statusHint, R.string.pcview_card_hint_pair) } else if (obj.details.runningGameId != 0) { - statusText.text = "Streaming" + statusText.setText(R.string.pcview_card_status_streaming) statusText.setTextColor(ContextCompat.getColor(context, R.color.nova_success)) primaryAction?.setText( if (obj.details.currentGameOwnedByClient == false) { @@ -110,19 +116,23 @@ class PcGridAdapter( ) setStatusHint(statusHint, R.string.pcview_card_hint_streaming) } else if (obj.details.libraryState == ComputerDetails.LibraryState.AVAILABLE) { - val addr = obj.details.activeAddress?.address ?: "" - statusText.text = "Library ready \u00b7 $addr" + statusText.text = context.getString( + R.string.pcview_card_status_library_ready_format, + formatAddressSuffix(obj.details.activeAddress?.address) + ) statusText.setTextColor(NovaThemeManager.getTextMutedColor(context)) primaryAction?.setText(R.string.pcview_card_action_open_library) setStatusHint(statusHint, R.string.pcview_card_hint_open_library) } else if (obj.details.libraryState == ComputerDetails.LibraryState.UNKNOWN) { - statusText.text = "Checking library" + statusText.setText(R.string.pcview_card_status_checking_library) statusText.setTextColor(NovaThemeManager.getTextMutedColor(context)) primaryAction?.setText(R.string.pcview_card_action_checking_library) setStatusHint(statusHint, R.string.pcview_card_hint_checking_library) } else { - val addr = obj.details.activeAddress?.address ?: "" - statusText.text = "Compatibility mode \u00b7 $addr" + statusText.text = context.getString( + R.string.pcview_card_status_compatibility_format, + formatAddressSuffix(obj.details.activeAddress?.address) + ) statusText.setTextColor(NovaThemeManager.getTextMutedColor(context)) primaryAction?.setText(R.string.pcview_card_action_open_apps) setStatusHint(statusHint, R.string.pcview_card_hint_open_apps) @@ -132,16 +142,21 @@ class PcGridAdapter( imgView.alpha = 0.4f statusDot?.setBackgroundResource(R.drawable.nova_status_offline) if (statusText != null) { - statusText.text = "Offline" + statusText.setText(R.string.pcview_card_status_offline) statusText.setTextColor(NovaThemeManager.getTextMutedColor(context)) } - primaryAction?.setText(R.string.pcview_card_action_wake) - setStatusHint(statusHint, R.string.pcview_card_hint_wake) + if (obj.details.macAddress != null) { + primaryAction?.setText(R.string.pcview_card_action_wake) + setStatusHint(statusHint, R.string.pcview_card_hint_wake) + } else { + primaryAction?.setText(R.string.pcview_card_action_refreshing) + setStatusHint(statusHint, R.string.pcview_card_hint_offline_no_wake) + } } else { imgView.alpha = 0.6f statusDot?.setBackgroundResource(R.drawable.nova_status_connecting) if (statusText != null) { - statusText.text = "Connecting\u2026" + statusText.setText(R.string.pcview_card_status_connecting) statusText.setTextColor(NovaThemeManager.getTextMutedColor(context)) } primaryAction?.setText(R.string.pcview_card_action_refreshing) @@ -181,6 +196,9 @@ class PcGridAdapter( primaryAction?.isSelected = hasFocus } + private fun formatAddressSuffix(address: String?): String = + address?.takeIf { it.isNotBlank() } ?: context.getString(R.string.pcview_card_status_local_network) + private fun setStatusHint(statusHint: TextView?, textRes: Int) { statusHint ?: return statusHint.setText(textRes) 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 c7e47016..90d7f45b 100644 --- a/app/src/main/java/com/papi/nova/ui/NovaLibraryActivity.kt +++ b/app/src/main/java/com/papi/nova/ui/NovaLibraryActivity.kt @@ -8,6 +8,11 @@ import android.widget.ImageView import android.widget.Toast import androidx.activity.OnBackPressedCallback import androidx.appcompat.app.AppCompatActivity +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background @@ -59,6 +64,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha @@ -692,6 +698,8 @@ class NovaLibraryActivity : AppCompatActivity() { apiClient = apiClient, restoreFocusGameId = restoreFocusGameId.takeUnless { restoreFocusGameInRecent }, onRefresh = onRefresh, + onManageServer = onManageServer, + onClearFilters = onClearFilters, onGameFocused = onGameFocused, onOpenDetail = onOpenDetail ) @@ -741,6 +749,8 @@ class NovaLibraryActivity : AppCompatActivity() { apiClient = apiClient, restoreFocusGameId = restoreFocusGameId.takeUnless { restoreFocusGameInRecent }, onRefresh = onRefresh, + onManageServer = onManageServer, + onClearFilters = onClearFilters, onGameFocused = onGameFocused, onOpenDetail = onOpenDetail ) @@ -1295,12 +1305,18 @@ class NovaLibraryActivity : AppCompatActivity() { apiClient: PolarisApiClient, restoreFocusGameId: String?, onRefresh: () -> Unit, + onManageServer: () -> Unit, + onClearFilters: () -> Unit, onGameFocused: (PolarisGame) -> Unit, onOpenDetail: (PolarisGame) -> Unit ) { NovaLibraryPanel(modifier = modifier, subtle = true) { if (loadErrorMessage != null && model.allGames.isEmpty()) { - NovaLibraryErrorState(message = loadErrorMessage, onRetry = onRefresh) + NovaLibraryErrorState( + message = loadErrorMessage, + onRetry = onRefresh, + onManageServer = onManageServer + ) } else if (isInitialLoading && model.allGames.isEmpty()) { NovaLibraryLoadingGrid(columns = columns, isLandscape = isLandscape) } else { @@ -1310,7 +1326,15 @@ class NovaLibraryActivity : AppCompatActivity() { modifier = Modifier.fillMaxSize() ) { if (model.filteredGames.isEmpty()) { - NovaLibraryEmptyState(model.emptyState) + val primaryAction = when (model.emptyState) { + NovaLibraryEmptyState.DEFAULT -> onManageServer + NovaLibraryEmptyState.RECENT -> onClearFilters + NovaLibraryEmptyState.FILTERED -> onClearFilters + } + NovaLibraryEmptyState( + emptyState = model.emptyState, + onPrimaryAction = primaryAction + ) } else { LazyVerticalGrid( columns = GridCells.Fixed(columns), @@ -1589,28 +1613,78 @@ class NovaLibraryActivity : AppCompatActivity() { private fun NovaLoadingCard(isLandscape: Boolean) { val colors = LocalNovaComposeColors.current val surfaces = LocalNovaLibrarySurfaces.current + val transition = rememberInfiniteTransition(label = "nova-library-loading") + val shimmerOffset by transition.animateFloat( + initialValue = -0.45f, + targetValue = 1.45f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 1400), + repeatMode = RepeatMode.Restart + ), + label = "nova-library-card-shimmer" + ) + val shimmerBrush = Brush.linearGradient( + colors = listOf( + surfaces.mediaPlaceholder.copy(alpha = 0.42f), + colors.accent.copy(alpha = 0.18f), + surfaces.mediaPlaceholder.copy(alpha = 0.42f) + ), + start = Offset(x = shimmerOffset * 620f, y = 0f), + end = Offset(x = (shimmerOffset + 0.32f) * 620f, y = 260f) + ) + val shape = RoundedCornerShape(14.dp) Box( modifier = Modifier .fillMaxWidth() .height(NovaLibraryUiStateMapper.gameCardHeightDp(compact = false, isLandscape = isLandscape).dp) - .clip(RoundedCornerShape(14.dp)) + .clip(shape) .background(surfaces.tile) - .border(1.dp, surfaces.tileBorder, RoundedCornerShape(14.dp)) + .border(1.dp, surfaces.tileBorder, shape) ) { Box( modifier = Modifier - .align(Alignment.BottomStart) + .fillMaxSize() + .background(shimmerBrush) + ) + Box( + modifier = Modifier + .align(Alignment.TopEnd) .padding(10.dp) - .fillMaxWidth(0.72f) - .height(12.dp) + .width(54.dp) + .height(18.dp) .clip(RoundedCornerShape(999.dp)) - .background(colors.divider.copy(alpha = 0.48f)) + .background(colors.accent.copy(alpha = 0.16f)) ) + Column( + modifier = Modifier + .align(Alignment.BottomStart) + .fillMaxWidth() + .padding(10.dp), + verticalArrangement = Arrangement.spacedBy(7.dp) + ) { + Box( + modifier = Modifier + .fillMaxWidth(0.74f) + .height(13.dp) + .clip(RoundedCornerShape(999.dp)) + .background(colors.divider.copy(alpha = 0.58f)) + ) + Box( + modifier = Modifier + .fillMaxWidth(0.46f) + .height(10.dp) + .clip(RoundedCornerShape(999.dp)) + .background(colors.divider.copy(alpha = 0.36f)) + ) + } } } @Composable - private fun NovaLibraryEmptyState(emptyState: NovaLibraryEmptyState) { + private fun NovaLibraryEmptyState( + emptyState: NovaLibraryEmptyState, + onPrimaryAction: () -> Unit + ) { val title = when (emptyState) { NovaLibraryEmptyState.DEFAULT -> stringResource(R.string.nova_library_empty_title_default) NovaLibraryEmptyState.RECENT -> stringResource(R.string.nova_library_empty_title_recent) @@ -1621,6 +1695,28 @@ class NovaLibraryActivity : AppCompatActivity() { NovaLibraryEmptyState.RECENT -> stringResource(R.string.nova_library_empty_hint_recent) NovaLibraryEmptyState.FILTERED -> stringResource(R.string.nova_library_empty_hint_filtered) } + val actionLabel = when (emptyState) { + NovaLibraryEmptyState.DEFAULT -> stringResource(R.string.nova_library_empty_action_manage) + NovaLibraryEmptyState.RECENT -> stringResource(R.string.nova_library_empty_action_clear) + NovaLibraryEmptyState.FILTERED -> stringResource(R.string.nova_library_empty_action_clear) + } + NovaLibraryRecoveryState( + eyebrow = stringResource(R.string.nova_library_empty_eyebrow), + title = title, + message = message, + primaryActionLabel = actionLabel, + onPrimaryAction = onPrimaryAction + ) + } + + @Composable + private fun NovaLibraryErrorState( + message: String, + onRetry: () -> Unit, + onManageServer: () -> Unit + ) { + val colors = LocalNovaComposeColors.current + val surfaces = LocalNovaLibrarySurfaces.current Box( modifier = Modifier .fillMaxSize() @@ -1628,30 +1724,78 @@ class NovaLibraryActivity : AppCompatActivity() { contentAlignment = Alignment.Center ) { Column( - modifier = Modifier.widthIn(max = 360.dp), + modifier = Modifier + .widthIn(max = 360.dp) + .clip(RoundedCornerShape(22.dp)) + .background(surfaces.panel) + .border(1.dp, surfaces.tileBorder, RoundedCornerShape(22.dp)) + .padding(18.dp), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp) + verticalArrangement = Arrangement.spacedBy(10.dp) ) { Text( - text = title, - color = LocalNovaComposeColors.current.textPrimary, + text = stringResource(R.string.nova_library_error_eyebrow).uppercase(Locale.getDefault()), + color = colors.accent, + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 0.7.sp, + textAlign = TextAlign.Center + ) + Text( + text = stringResource(R.string.nova_library_error_title), + color = colors.textPrimary, fontSize = 20.sp, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center ) Text( - text = message, - color = LocalNovaComposeColors.current.textSecondary, + text = stringResource(R.string.nova_library_error_hint), + color = colors.textSecondary, fontSize = 13.sp, + lineHeight = 18.sp, + textAlign = TextAlign.Center + ) + Text( + text = message, + color = colors.textMuted, + fontSize = 12.sp, + maxLines = 3, + overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center ) + NovaActionButton( + text = stringResource(R.string.nova_retry), + onClick = onRetry, + modifier = Modifier.fillMaxWidth(), + primary = true, + minHeight = 42.dp, + fontSize = 13.sp + ) + NovaActionButton( + text = stringResource(R.string.nova_library_error_action_manage), + onClick = onManageServer, + modifier = Modifier.fillMaxWidth(), + primary = false, + minHeight = 40.dp, + fontSize = 13.sp + ) } } } @Composable - private fun NovaLibraryErrorState(message: String, onRetry: () -> Unit) { + private fun NovaLibraryRecoveryState( + eyebrow: String, + title: String, + message: String, + primaryActionLabel: String, + onPrimaryAction: () -> Unit, + detail: String? = null, + secondaryActionLabel: String? = null, + onSecondaryAction: (() -> Unit)? = null + ) { val colors = LocalNovaComposeColors.current + val surfaces = LocalNovaLibrarySurfaces.current Box( modifier = Modifier .fillMaxSize() @@ -1659,39 +1803,65 @@ class NovaLibraryActivity : AppCompatActivity() { contentAlignment = Alignment.Center ) { Column( - modifier = Modifier.widthIn(max = 360.dp), + modifier = Modifier + .widthIn(max = 360.dp) + .clip(RoundedCornerShape(22.dp)) + .background(surfaces.panel) + .border(1.dp, surfaces.tileBorder, RoundedCornerShape(22.dp)) + .padding(18.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(10.dp) ) { Text( - text = stringResource(R.string.nova_library_error_title), - color = colors.textPrimary, - fontSize = 20.sp, + text = eyebrow.uppercase(Locale.getDefault()), + color = colors.accent, + fontSize = 11.sp, fontWeight = FontWeight.Bold, + letterSpacing = 0.7.sp, textAlign = TextAlign.Center ) Text( - text = stringResource(R.string.nova_library_error_hint), - color = colors.textSecondary, - fontSize = 13.sp, + text = title, + color = colors.textPrimary, + fontSize = 20.sp, + fontWeight = FontWeight.Bold, textAlign = TextAlign.Center ) Text( text = message, - color = colors.textMuted, - fontSize = 12.sp, - maxLines = 3, - overflow = TextOverflow.Ellipsis, + color = colors.textSecondary, + fontSize = 13.sp, + lineHeight = 18.sp, textAlign = TextAlign.Center ) + if (!detail.isNullOrBlank()) { + Text( + text = detail, + color = colors.textMuted, + fontSize = 12.sp, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center + ) + } NovaActionButton( - text = stringResource(R.string.nova_retry), - onClick = onRetry, + text = primaryActionLabel, + onClick = onPrimaryAction, modifier = Modifier.fillMaxWidth(), primary = true, - minHeight = 40.dp, + minHeight = 42.dp, fontSize = 13.sp ) + if (secondaryActionLabel != null && onSecondaryAction != null) { + NovaActionButton( + text = secondaryActionLabel, + onClick = onSecondaryAction, + modifier = Modifier.fillMaxWidth(), + primary = false, + minHeight = 40.dp, + fontSize = 13.sp + ) + } } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b967f35a..a92c8518 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -88,12 +88,23 @@ Pair Wake Refreshing + Pair required + Repair pair + Streaming + Library ready · %1$s + Checking library + Compatibility mode · %1$s + Offline + Connecting… + local network Pair this host before library or stream actions. + Repair pairing before library or stream actions. Resume or watch the active stream. Open the Polaris game library. Waiting for Polaris library readiness. Use the legacy app list for this host. Wake this host or check the network. + This host is offline and has no wake target saved. Refreshing host status. Choose a server for Nova Library No paired online server available for Nova Library @@ -157,14 +168,19 @@ Genre Selected Selected: %1$s + Library state No games found Import games in Polaris, then refresh this library. No recent games Launch something once and it will appear here. No matches Try a different filter or search term. + Manage server + Clear filters + Recovery Couldn\'t load library Check Polaris and try again. + Manage server Details Played %1$s Ready to play From 4869df258610de2b7a9e0026744937570b4383c4 Mon Sep 17 00:00:00 2001 From: papi Date: Wed, 20 May 2026 18:19:16 -0400 Subject: [PATCH 5/5] fix(shortcuts): use Polaris preflight for direct launches --- .../java/com/papi/nova/ShortcutTrampoline.kt | 109 +++++++++++++++++- .../java/com/papi/nova/utils/ServerHelper.kt | 27 +++++ .../papi/nova/ui/NovaLaunchSourceGuardTest.kt | 33 ++++++ 3 files changed, 165 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/papi/nova/ShortcutTrampoline.kt b/app/src/main/java/com/papi/nova/ShortcutTrampoline.kt index cedcda36..317c0d70 100644 --- a/app/src/main/java/com/papi/nova/ShortcutTrampoline.kt +++ b/app/src/main/java/com/papi/nova/ShortcutTrampoline.kt @@ -10,6 +10,8 @@ import android.os.Bundle import android.os.IBinder import android.util.Log import androidx.appcompat.app.AppCompatActivity +import com.papi.nova.api.PolarisApiClient +import com.papi.nova.api.PolarisGame import com.papi.nova.computers.ComputerDatabaseManager import com.papi.nova.computers.ComputerManagerListener import com.papi.nova.computers.ComputerManagerService @@ -20,11 +22,13 @@ import com.papi.nova.nvstream.http.PairingManager import com.papi.nova.nvstream.wol.WakeOnLanSender import com.papi.nova.preferences.PreferenceConfiguration import com.papi.nova.utils.CacheHelper +import com.papi.nova.utils.DeviceUtils import com.papi.nova.utils.Dialog import com.papi.nova.utils.ServerHelper import com.papi.nova.utils.ShortcutHelper import com.papi.nova.utils.SpinnerDialog import com.papi.nova.utils.UiHelper +import java.security.cert.CertificateEncodingException import org.xmlpull.v1.XmlPullParserException import java.io.BufferedReader import java.io.File @@ -50,6 +54,12 @@ class ShortcutTrampoline : AppCompatActivity() { private var managerBinder: ComputerManagerService.ComputerManagerBinder? = null + private data class ShortcutLaunchPlan( + val app: NvApp, + val profilePreference: String = "auto", + val launchOptimizationJson: String? = null, + ) + private val serviceConnection: ServiceConnection = object : ServiceConnection { override fun onServiceConnected(className: ComponentName, binder: IBinder) { val localBinder = binder as ComputerManagerService.ComputerManagerBinder @@ -109,6 +119,20 @@ class ShortcutTrampoline : AppCompatActivity() { } if (details.state != ComputerDetails.State.UNKNOWN) { + val shortcutLaunchPlan = if ( + details.state == ComputerDetails.State.ONLINE && + details.pairState == PairingManager.PairState.PAIRED && + app != null + ) { + preparePolarisShortcutLaunchPlan( + details, + app!!, + prefConfig.useVirtualDisplay, + ) + } else { + null + } + runOnUiThread { if (blockingLoadSpinner != null) { blockingLoadSpinner?.dismiss() @@ -127,18 +151,21 @@ class ShortcutTrampoline : AppCompatActivity() { ) { val currentApp = app if (currentApp != null) { + val launchPlan = shortcutLaunchPlan ?: ShortcutLaunchPlan(currentApp) if ( details.runningGameId == 0 || - details.runningGameId == currentApp.appId || - Objects.equals(details.runningGameUUID, currentApp.appUUID) + details.runningGameId == launchPlan.app.appId || + Objects.equals(details.runningGameUUID, launchPlan.app.appUUID) ) { intentStack.add( ServerHelper.createStartIntent( this@ShortcutTrampoline, - currentApp, + launchPlan.app, details, activeBinder, prefConfig.useVirtualDisplay, + launchPlan.profilePreference, + launchPlan.launchOptimizationJson, ), ) @@ -147,10 +174,12 @@ class ShortcutTrampoline : AppCompatActivity() { } else { val startIntent = ServerHelper.createStartIntent( this@ShortcutTrampoline, - currentApp, + launchPlan.app, details, activeBinder, prefConfig.useVirtualDisplay, + launchPlan.profilePreference, + launchPlan.launchOptimizationJson, ) UiHelper.displayQuitConfirmationDialog( @@ -599,6 +628,77 @@ class ShortcutTrampoline : AppCompatActivity() { ) } + private fun preparePolarisShortcutLaunchPlan( + details: ComputerDetails, + shortcutApp: NvApp, + withVirtualDisplay: Boolean, + ): ShortcutLaunchPlan { + val activeAddress = details.activeAddress ?: return ShortcutLaunchPlan(shortcutApp) + val serverCert = try { + details.serverCert?.encoded + } catch (e: CertificateEncodingException) { + LimeLog.warning("Nova: Shortcut launch could not encode server cert for Polaris preflight: ${e.message}") + null + } ?: return ShortcutLaunchPlan(shortcutApp) + + return try { + val apiClient = PolarisApiClient(this, activeAddress.address, details.httpsPort, serverCert) + val polarisGame = findPolarisShortcutGame(apiClient, shortcutApp) + ?: return ShortcutLaunchPlan(shortcutApp) + val launchApp = NvApp(polarisGame.name, polarisGame.id, polarisGame.appId, polarisGame.hdrSupported) + + val mangoHudSynced = apiClient.setMangoHud(polarisGame.id, polarisGame.mangohud) + if (!mangoHudSynced) { + LimeLog.warning("Nova: Shortcut launch MangoHUD state sync failed; continuing launch") + } + + syncShortcutLaunchPreflightSettings(apiClient, withVirtualDisplay) + val optimization = apiClient.getOptimization( + DeviceUtils.getModel(), + polarisGame.name, + SHORTCUT_PROFILE_PREFERENCE, + ) + + ShortcutLaunchPlan( + app = launchApp, + profilePreference = SHORTCUT_PROFILE_PREFERENCE, + launchOptimizationJson = optimization?.toString(), + ) + } catch (e: Exception) { + LimeLog.warning("Nova: Shortcut launch Polaris preflight failed: ${e.message}") + ShortcutLaunchPlan(shortcutApp) + } + } + + private fun findPolarisShortcutGame(apiClient: PolarisApiClient, shortcutApp: NvApp): PolarisGame? { + val shortcutUuid = shortcutApp.appUUID + val shortcutName = shortcutApp.appName + return apiClient.getGames(limit = 100).firstOrNull { game -> + (!shortcutUuid.isNullOrBlank() && shortcutUuid.equals(game.id, ignoreCase = true)) || + (shortcutApp.appId > 0 && game.appId == shortcutApp.appId) || + (!shortcutName.isNullOrBlank() && shortcutName.equals(game.name, ignoreCase = true)) + } + } + + private fun syncShortcutLaunchPreflightSettings( + apiClient: PolarisApiClient, + withVirtualDisplay: Boolean, + ) { + val preferences = PreferenceConfiguration.readPreferences(this) + val syncedSettings = apiClient.updateClientSettings( + streamDisplayMode = if (withVirtualDisplay) "host_virtual_display" else "headless_stream", + displayMode = PreferenceConfiguration.formatStreamingDisplayMode( + preferences.width, + preferences.height, + preferences.fps, + ), + targetBitrateKbps = preferences.bitrate.takeIf { it > 0 }, + ) + if (syncedSettings == null) { + LimeLog.warning("Nova: Shortcut launch preflight client settings sync failed; continuing launch") + } + } + private fun displayAppListError(e: Exception) { Log.e(TAG, "Error processing app list from cache", e) Dialog.displayDialog( @@ -672,6 +772,7 @@ class ShortcutTrampoline : AppCompatActivity() { companion object { private const val MAX_ART_FILE_CHARS = 64 * 1024 + private const val SHORTCUT_PROFILE_PREFERENCE = "auto" private const val TAG = "ShortcutTrampoline" } } diff --git a/app/src/main/java/com/papi/nova/utils/ServerHelper.kt b/app/src/main/java/com/papi/nova/utils/ServerHelper.kt index 662898de..c6896024 100644 --- a/app/src/main/java/com/papi/nova/utils/ServerHelper.kt +++ b/app/src/main/java/com/papi/nova/utils/ServerHelper.kt @@ -185,6 +185,29 @@ object ServerHelper { return createStartIntent(parent, app, computer, managerBinder, withVDisplay, false, false) } + @JvmStatic + fun createStartIntent( + parent: Activity, + app: NvApp, + computer: ComputerDetails, + managerBinder: ComputerManagerService.ComputerManagerBinder, + withVDisplay: Boolean, + profilePreference: String, + launchOptimizationJson: String?, + ): Intent { + return createStartIntent( + parent, + app, + computer, + managerBinder, + withVDisplay, + false, + false, + profilePreference, + launchOptimizationJson, + ) + } + @JvmStatic fun createStartIntent( parent: Activity, @@ -194,6 +217,8 @@ object ServerHelper { withVDisplay: Boolean, displayModeExplicit: Boolean, watchOnly: Boolean, + profilePreference: String = "auto", + launchOptimizationJson: String? = null, ): Intent { var serverCert: ByteArray? = null try { @@ -222,6 +247,8 @@ object ServerHelper { watchOnly, serverCommands, serverCert, + aiProfilePreference = profilePreference, + launchOptimizationJson = launchOptimizationJson, ) } 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 e82eeeca..61a75c40 100644 --- a/app/src/test/java/com/papi/nova/ui/NovaLaunchSourceGuardTest.kt +++ b/app/src/test/java/com/papi/nova/ui/NovaLaunchSourceGuardTest.kt @@ -46,6 +46,39 @@ class NovaLaunchSourceGuardTest { ) } + @Test + fun shortcutLaunchUsesPolarisPreflightBeforeStartingStream() { + val trampoline = readSource("src/main/java/com/papi/nova/ShortcutTrampoline.kt") + val serverHelper = readSource("src/main/java/com/papi/nova/utils/ServerHelper.kt") + val preparePlan = trampoline.section( + "private fun preparePolarisShortcutLaunchPlan(", + "private fun findPolarisShortcutGame(" + ) + val directLaunch = trampoline.section( + "if (currentApp != null) {", + "} else {\n finish()" + ) + + assertTrue( + "shortcut launch should resolve Polaris library metadata before direct game start", + preparePlan.contains("findPolarisShortcutGame(apiClient, shortcutApp)") && + trampoline.contains("apiClient.getGames(limit = 100)") + ) + assertTrue( + "shortcut launch should sync the Polaris client settings/profile contract before stream start", + preparePlan.contains("syncShortcutLaunchPreflightSettings(apiClient, withVirtualDisplay)") && + trampoline.contains("apiClient.updateClientSettings(") && + preparePlan.contains("apiClient.getOptimization(") + ) + assertTrue( + "shortcut launch should carry Polaris optimization/profile extras into Game just like library launches", + directLaunch.contains("launchPlan.profilePreference") && + directLaunch.contains("launchPlan.launchOptimizationJson") && + serverHelper.contains("Game.EXTRA_AI_PROFILE_PREFERENCE") && + serverHelper.contains("Game.EXTRA_LAUNCH_OPTIMIZATION") + ) + } + @Test fun libraryFollowsUpActiveSessionRefreshAfterReturningFromStream() { val activity = readSource("src/main/java/com/papi/nova/ui/NovaLibraryActivity.kt")