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