diff --git a/app/src/main/java/com/papi/nova/Game.kt b/app/src/main/java/com/papi/nova/Game.kt index dd62b5a4..59add8eb 100644 --- a/app/src/main/java/com/papi/nova/Game.kt +++ b/app/src/main/java/com/papi/nova/Game.kt @@ -54,7 +54,10 @@ import com.papi.nova.utils.PerformanceDataTracker import com.papi.nova.utils.ServerHelper import com.papi.nova.utils.ShortcutHelper import com.papi.nova.utils.SpinnerDialog +import com.papi.nova.ui.NovaSheetChrome +import com.papi.nova.ui.NovaThemeManager import com.papi.nova.utils.UiHelper +import com.google.android.material.bottomsheet.BottomSheetDialog import org.json.JSONObject @@ -77,6 +80,7 @@ import android.content.res.Configuration import android.graphics.Outline import android.graphics.Point import android.graphics.Rect +import android.graphics.Typeface import android.hardware.display.DisplayManager import android.hardware.input.InputManager import android.media.AudioManager @@ -107,6 +111,7 @@ import android.view.ViewParent import android.view.Window import android.view.WindowManager import android.widget.FrameLayout +import android.widget.LinearLayout import android.view.inputmethod.InputMethodManager import android.widget.TextView import android.widget.Toast @@ -481,9 +486,31 @@ getWindow().addFlags(WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN) clipboardManager = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - // Start the spinner - spinner = SpinnerDialog.displayDialog(this, getResources().getString(R.string.conn_establishing_title), -getResources().getString(R.string.conn_establishing_msg), true) + // Show Nova verbose session progress overlay immediately. The legacy spinner overlaps it. + novaProgressOverlay = com.papi.nova.ui.SessionProgressOverlay(this) + novaProgressOverlay?.show() + +appName = this@Game.getIntent().getStringExtra(EXTRA_APP_NAME) +pcName = this@Game.getIntent().getStringExtra(EXTRA_PC_NAME) + +host = this@Game.getIntent().getStringExtra(EXTRA_HOST) +port = this@Game.getIntent().getIntExtra(EXTRA_PORT, NvHTTP.DEFAULT_HTTP_PORT) +httpsPort = this@Game.getIntent().getIntExtra(EXTRA_HTTPS_PORT, 0) // 0 is treated as unknown +appUUID = this@Game.getIntent().getStringExtra(EXTRA_APP_UUID) +appId = this@Game.getIntent().getIntExtra(EXTRA_APP_ID, StreamConfiguration.INVALID_APP_ID) +uniqueId = this@Game.getIntent().getStringExtra(EXTRA_UNIQUEID) +vDisplay = this@Game.getIntent().getBooleanExtra(EXTRA_VDISPLAY, false) +var displayModeExplicit:Boolean = this@Game.getIntent().getBooleanExtra(EXTRA_DISPLAY_MODE_EXPLICIT, false) +watchOnlyRequested = this@Game.getIntent().getBooleanExtra(EXTRA_WATCH_ONLY, false) +watchStreamWidth = this@Game.getIntent().getIntExtra(EXTRA_STREAM_WIDTH, 0) +watchStreamHeight = this@Game.getIntent().getIntExtra(EXTRA_STREAM_HEIGHT, 0) +watchStreamFps = this@Game.getIntent().getFloatExtra(EXTRA_STREAM_FPS, 0f) +launchProfilePreference = this@Game.getIntent().getStringExtra(EXTRA_AI_PROFILE_PREFERENCE) ?: "" +launchOptimizationJson = this@Game.getIntent().getStringExtra(EXTRA_LAUNCH_OPTIMIZATION) +serverCmds = this@Game.getIntent().getStringArrayListExtra(EXTRA_SERVER_COMMANDS) ?: ArrayList() +var appSupportsHdr:Boolean = this@Game.getIntent().getBooleanExtra(EXTRA_APP_HDR, false) +var derCertData:ByteArray? = this@Game.getIntent().getByteArrayExtra(EXTRA_SERVER_CERT) + var currentDisplay:Display? = null @@ -539,9 +566,16 @@ displayHeight = if (shouldInvertDecoderResolution) prefConfig!!.width else prefC setPreferredOrientationForActivity() } -if (watchOnlyRequested && watchStreamWidth > 0 && watchStreamHeight > 0) +if (watchStreamWidth > 0 && watchStreamHeight > 0) +{ +if (watchOnlyRequested) { LimeLog.info("Nova: Watch mode using active stream resolution " + watchStreamWidth + "x" + watchStreamHeight) +} +else +{ +LimeLog.info("Nova: Launch using explicit stream resolution " + watchStreamWidth + "x" + watchStreamHeight) +} displayWidth = watchStreamWidth displayHeight = watchStreamHeight } @@ -684,26 +718,6 @@ catch (e:SecurityException) { e!!.printStackTrace() } -appName = this@Game.getIntent().getStringExtra(EXTRA_APP_NAME) -pcName = this@Game.getIntent().getStringExtra(EXTRA_PC_NAME) - -host = this@Game.getIntent().getStringExtra(EXTRA_HOST) -port = this@Game.getIntent().getIntExtra(EXTRA_PORT, NvHTTP.DEFAULT_HTTP_PORT) -httpsPort = this@Game.getIntent().getIntExtra(EXTRA_HTTPS_PORT, 0) // 0 is treated as unknown -appUUID = this@Game.getIntent().getStringExtra(EXTRA_APP_UUID) -appId = this@Game.getIntent().getIntExtra(EXTRA_APP_ID, StreamConfiguration.INVALID_APP_ID) -uniqueId = this@Game.getIntent().getStringExtra(EXTRA_UNIQUEID) -vDisplay = this@Game.getIntent().getBooleanExtra(EXTRA_VDISPLAY, false) -var displayModeExplicit:Boolean = this@Game.getIntent().getBooleanExtra(EXTRA_DISPLAY_MODE_EXPLICIT, false) -watchOnlyRequested = this@Game.getIntent().getBooleanExtra(EXTRA_WATCH_ONLY, false) -watchStreamWidth = this@Game.getIntent().getIntExtra(EXTRA_STREAM_WIDTH, 0) -watchStreamHeight = this@Game.getIntent().getIntExtra(EXTRA_STREAM_HEIGHT, 0) -watchStreamFps = this@Game.getIntent().getFloatExtra(EXTRA_STREAM_FPS, 0f) -launchProfilePreference = this@Game.getIntent().getStringExtra(EXTRA_AI_PROFILE_PREFERENCE) ?: "" -launchOptimizationJson = this@Game.getIntent().getStringExtra(EXTRA_LAUNCH_OPTIMIZATION) -serverCmds = this@Game.getIntent().getStringArrayListExtra(EXTRA_SERVER_COMMANDS) ?: ArrayList() -var appSupportsHdr:Boolean = this@Game.getIntent().getBooleanExtra(EXTRA_APP_HDR, false) -var derCertData:ByteArray? = this@Game.getIntent().getByteArrayExtra(EXTRA_SERVER_CERT) app = NvApp(if (appName != null) appName else "app", appUUID, appId, appSupportsHdr) @@ -724,7 +738,10 @@ e!!.printStackTrace() // Nova: set up Polaris integration without blocking stream startup on REST probes. com.papi.nova.manager.FeatureFlagManager.reset() novaApiClient = com.papi.nova.api.PolarisApiClient(this, host ?: "", httpsPort, serverCert) +if (novaProgressOverlay == null) +{ novaProgressOverlay = com.papi.nova.ui.SessionProgressOverlay(this) +} novaLockScreenOverlay = com.papi.nova.ui.LockScreenOverlay(this, novaApiClient!!) novaReconnectOverlay = com.papi.nova.ui.ReconnectOverlay(this) novaResilienceManager = com.papi.nova.manager.ConnectionResilienceManager( @@ -934,19 +951,26 @@ if (prefConfig!!.onscreenController) gamepadMask = gamepadMask or 1 } -var watchStreamFpsOverride:Boolean = watchOnlyRequested && watchStreamFps > 0f -var launchRefreshRate:Float = if (watchStreamFpsOverride) watchStreamFps else prefConfig!!.fps +var explicitStreamFpsOverride:Boolean = watchStreamFps > 0f +var launchRefreshRate:Float = if (explicitStreamFpsOverride) watchStreamFps else prefConfig!!.fps var maxSupportedLaunchRefreshRate:Float = getMaxSupportedRefreshRate(currentDisplay) -if (!watchStreamFpsOverride && (maxSupportedLaunchRefreshRate > 0 && launchRefreshRate > maxSupportedLaunchRefreshRate + 0.5f)) +if (!explicitStreamFpsOverride && (maxSupportedLaunchRefreshRate > 0 && launchRefreshRate > maxSupportedLaunchRefreshRate + 0.5f)) { LimeLog.info(("Clamping launch refresh rate from " + launchRefreshRate + " to display max " + maxSupportedLaunchRefreshRate)) launchRefreshRate = maxSupportedLaunchRefreshRate } -if (watchStreamFpsOverride) +if (explicitStreamFpsOverride) +{ +if (watchOnlyRequested) { LimeLog.info("Nova: Watch mode using active stream FPS " + watchStreamFps) } +else +{ +LimeLog.info("Nova: Launch using explicit stream FPS " + watchStreamFps) +} +} var autoSafeTargetFps:Float = com.papi.nova.manager.StreamSyncManager.resolveAutoSafeTargetFps( launchRefreshRate, launchOptimization @@ -1173,6 +1197,7 @@ spinner = null } // If we can't find an AVC decoder, we can't proceed +novaProgressOverlay?.dismiss() Dialog.displayDialog(this, getResources().getString(R.string.conn_error_title), "This device or ROM doesn't support hardware accelerated H.264 playback.", true) return @@ -4411,7 +4436,9 @@ override fun stageFailed(stage:String, portFlags:Int, errorCode:Int):Boolean { if (errorCode == 0 && portFlags != 0 && (portTestResult == MoonBridge.ML_TEST_RESULT_INCONCLUSIVE || portTestResult == 0)) { -spinner!!.setMessage(getResources().getString(R.string.unlocking_or_starting)) +runOnUiThread { +novaProgressOverlay?.updateState("unlocking_or_starting", getResources().getString(R.string.unlocking_or_starting)) +} return true } @@ -4422,6 +4449,7 @@ if (spinner != null) spinner!!.dismiss() spinner = null } +novaProgressOverlay?.dismiss() if (!displayedFailureDialog) { @@ -5862,29 +5890,76 @@ Handler(Looper.getMainLooper()).postDelayed({ getApplicationContext().startActiv overridePendingTransition(0, 0) }, 900) } fun quit() { -var context:Context? -if (isOnExternalDisplay && ExternalDisplayControlActivity.instance != null) +val dialogContext:Context = if (isOnExternalDisplay && ExternalDisplayControlActivity.instance != null) { -context = ExternalDisplayControlActivity.instance +ExternalDisplayControlActivity.instance!! } else { -context = this +this } -var builder:AlertDialog.Builder = AlertDialog.Builder(context) -builder.setTitle(R.string.game_dialog_title_quit_confirm) -builder.setMessage(R.string.game_dialog_message_quit_confirm) +fun dp(value:Float):Int = UiHelper.dpToPx(dialogContext, value).toInt() + +val sheet = BottomSheetDialog(dialogContext, R.style.NovaBottomSheet) +val content = NovaSheetChrome.createSheetContainer(dialogContext, horizontalPaddingDp = 24, topPaddingDp = 22, bottomPaddingDp = 24) + +content.addView(TextView(dialogContext).apply { +text = getString(R.string.game_dialog_title_quit_confirm) +setTextColor(NovaThemeManager.getTextPrimaryColor(dialogContext)) +textSize = 22f +typeface = Typeface.DEFAULT_BOLD +includeFontPadding = false +setPadding(0, 0, 0, dp(10f)) +NovaSheetChrome.styleSheetTitle(this) +}) -builder.setPositiveButton(getString(R.string.yes), { dialog, which-> +content.addView(TextView(dialogContext).apply { +text = getString(R.string.game_dialog_message_quit_confirm) +setTextColor(NovaThemeManager.getTextSecondaryColor(dialogContext)) +textSize = 14f +setLineSpacing(0f, 1.08f) +setPadding(0, 0, 0, dp(20f)) +}) + +val actions = LinearLayout(dialogContext).apply { +orientation = LinearLayout.HORIZONTAL +gravity = Gravity.CENTER_VERTICAL +} +val stayAction = TextView(dialogContext).apply { +text = getString(R.string.game_dialog_action_stay_in_game) +gravity = Gravity.CENTER +textSize = 15f +setPadding(dp(16f), dp(14f), dp(16f), dp(14f)) +NovaSheetChrome.styleSheetAction(this) +setOnClickListener { sheet.dismiss() } +layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f).apply { +marginEnd = dp(12f) +} +} +val endAction = TextView(dialogContext).apply { +text = getString(R.string.game_dialog_action_end_session) +gravity = Gravity.CENTER +textSize = 15f +setPadding(dp(16f), dp(14f), dp(16f), dp(14f)) +NovaSheetChrome.styleSheetAction(this, destructive = true) +setOnClickListener { +sheet.dismiss() quitOnStop = true markLocalSessionEnd() -dialog!!.dismiss() -finish() }) - -builder.setNegativeButton(getString(R.string.no), { dialog, which-> dialog!!.dismiss() }) +finish() +} +layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f) +} +actions.addView(stayAction) +actions.addView(endAction) +content.addView(actions) -var dialog:AlertDialog? = builder.create() -dialog!!.show() +sheet.setContentView(content) +sheet.setOnShowListener { +NovaSheetChrome.applyBottomSheetChrome(sheet, content, widthFraction = 0.52f, minLandscapeWidthDp = 520, maxLandscapeWidthDp = 760, maxHeightLandscape = 0.82f, maxHeightPortrait = 0.70f) +content.post { stayAction.requestFocus() } +} +sheet.show() } override fun showGameMenu(device:GameInputDevice?) { if (isOnExternalDisplay) diff --git a/app/src/main/java/com/papi/nova/GameMenu.kt b/app/src/main/java/com/papi/nova/GameMenu.kt index 0b959060..81656e60 100644 --- a/app/src/main/java/com/papi/nova/GameMenu.kt +++ b/app/src/main/java/com/papi/nova/GameMenu.kt @@ -3,16 +3,22 @@ package com.papi.nova import android.app.Activity import android.app.AlertDialog import android.content.Context +import android.graphics.Typeface import android.os.Handler import android.os.Looper import android.view.ContextThemeWrapper -import android.view.ViewTreeObserver -import android.widget.ArrayAdapter +import android.view.Gravity +import android.view.ViewGroup +import android.widget.LinearLayout +import android.widget.ScrollView +import android.widget.TextView import android.widget.Toast +import com.google.android.material.bottomsheet.BottomSheetDialog import com.papi.nova.binding.input.GameInputDevice import com.papi.nova.binding.input.KeyboardTranslator import com.papi.nova.preferences.PreferenceConfiguration import com.papi.nova.utils.KeyConfigHelper +import com.papi.nova.ui.NovaSheetChrome import com.papi.nova.utils.KeyMapper import java.util.concurrent.atomic.AtomicInteger @@ -30,7 +36,7 @@ class GameMenu( constructor(label: String?, runnable: Runnable?) : this(label, false, runnable) } - private var currentDialog: AlertDialog? = null + private var currentSheet: BottomSheetDialog? = null private fun getString(id: Int): String = game.resources.getString(id) @@ -67,45 +73,89 @@ class GameMenu( private fun showMenuDialog(title: String, options: Array) { val themeResId = game.applicationInfo.theme val themedContext = ContextThemeWrapper(dialogScreenContext, themeResId) - val builder = AlertDialog.Builder(themedContext) - builder.setTitle(title) - - val actions = ArrayAdapter(themedContext, android.R.layout.simple_list_item_1) - builder.setAdapter(actions) { _, which -> - val label = actions.getItem(which) - for (option in options) { - if (label != null && label == option.label) { + val sheet = BottomSheetDialog(themedContext) + val scrollView = ScrollView(themedContext) + val container = NovaSheetChrome.createSheetContainer(themedContext) + + val titleView = TextView(themedContext).apply { + text = title + textSize = 20f + typeface = Typeface.DEFAULT_BOLD + gravity = Gravity.START + setPadding(0, 0, 0, dp(themedContext, 12)) + NovaSheetChrome.styleSheetTitle(this) + } + container.addView( + titleView, + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + ) + + var firstActionRow: TextView? = null + options.forEach { option -> + val label = option.label ?: return@forEach + val row = TextView(themedContext).apply { + text = label + contentDescription = label + textSize = 16f + gravity = Gravity.CENTER_VERTICAL + minHeight = dp(themedContext, GAME_MENU_ROW_HEIGHT_DP) + setPadding(dp(themedContext, 16), 0, dp(themedContext, 16), 0) + NovaSheetChrome.styleSheetAction( + this, + destructive = label == getString(R.string.game_menu_quit_session) + ) + setOnClickListener { + hideMenu() run(option) - break } } + if (firstActionRow == null) { + firstActionRow = row + } + container.addView( + row, + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + dp(themedContext, GAME_MENU_ROW_HEIGHT_DP) + ).apply { + topMargin = dp(themedContext, 6) + } + ) } - builder.setOnCancelListener { hideMenu() } - - currentDialog?.dismiss() - currentDialog = builder.show() - - val window = currentDialog?.window - if (window != null) { - val decorView = window.decorView - decorView.viewTreeObserver.addOnGlobalLayoutListener( - object : ViewTreeObserver.OnGlobalLayoutListener { - override fun onGlobalLayout() { - decorView.viewTreeObserver.removeOnGlobalLayoutListener(this) - - Handler(Looper.getMainLooper()).post { - for (option in options) { - actions.add(option.label) - } - actions.notifyDataSetChanged() - } - } - }, + scrollView.addView( + container, + ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT ) + ) + + currentSheet?.dismiss() + currentSheet = sheet + sheet.setContentView(scrollView) + sheet.setOnCancelListener { + if (currentSheet == sheet) { + currentSheet = null + } + } + sheet.setOnDismissListener { + if (currentSheet == sheet) { + currentSheet = null + } } + sheet.show() + NovaSheetChrome.applyBottomSheetChrome( + sheet, + scrollView, + minLandscapeWidthDp = 520, + maxLandscapeWidthDp = 820 + ) + firstActionRow?.let { row -> row.post { row.requestFocus() } } } - private fun showSpecialKeysMenu() { val options = ArrayList() @@ -304,10 +354,12 @@ class GameMenu( if (serverCmds.isEmpty()) { val themeResId = game.applicationInfo.theme val themedContext = ContextThemeWrapper(dialogScreenContext, themeResId) - AlertDialog.Builder(themedContext) + val serverCommandDialog = AlertDialog.Builder(themedContext) .setTitle(R.string.game_dialog_title_server_cmd_empty) .setMessage(R.string.game_dialog_message_server_cmd_empty) - .show() + .create() + NovaSheetChrome.applyAlertDialogChrome(serverCommandDialog) + serverCommandDialog.show() } else { hideMenu() showServerCmd(serverCmds) @@ -342,19 +394,22 @@ class GameMenu( } override fun hideMenu() { - if (currentDialog != null && currentDialog?.isShowing == true) { - currentDialog?.dismiss() - } - currentDialog = null + currentSheet?.dismiss() + currentSheet = null } override fun isMenuOpen(): Boolean { - return currentDialog != null && currentDialog?.isShowing == true + return currentSheet?.isShowing == true + } + + private fun dp(context: Context, value: Int): Int { + return (value * context.resources.displayMetrics.density).toInt() } companion object { const val KEY_UP_DELAY: Long = 25 private const val TEST_GAME_FOCUS_DELAY: Long = 10 + private const val GAME_MENU_ROW_HEIGHT_DP: Int = 52 const val PREF_NAME: String = "specialPrefs" const val KEY_NAME: String = "special_key" } diff --git a/app/src/main/java/com/papi/nova/ui/NovaSheetChrome.kt b/app/src/main/java/com/papi/nova/ui/NovaSheetChrome.kt index 930cb1d5..1559e7c0 100644 --- a/app/src/main/java/com/papi/nova/ui/NovaSheetChrome.kt +++ b/app/src/main/java/com/papi/nova/ui/NovaSheetChrome.kt @@ -1,9 +1,11 @@ package com.papi.nova.ui +import android.app.AlertDialog import android.content.Context import android.content.res.Configuration import android.graphics.Color import android.graphics.drawable.GradientDrawable +import android.graphics.drawable.StateListDrawable import android.view.View import android.view.ViewGroup import android.view.WindowManager @@ -81,7 +83,6 @@ object NovaSheetChrome { measuredView.post { val resources = context.resources val isLandscape = resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE - val density = resources.displayMetrics.density val displayWidth = resources.displayMetrics.widthPixels val displayHeight = resources.displayMetrics.heightPixels val maxHeight = (displayHeight * if (isLandscape) maxHeightLandscape else maxHeightPortrait).toInt() @@ -128,6 +129,37 @@ object NovaSheetChrome { } } + + fun applyAlertDialogChrome(dialog: AlertDialog, destructivePositive: Boolean = false) { + dialog.setOnShowListener { + val context = dialog.context + dialog.window?.let { window -> + window.setDimAmount(SCRIM_ALPHA) + window.addFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) + window.setBackgroundDrawable(createAlertDialogBackground(context)) + } + val alertTitleId = context.resources.getIdentifier("alertTitle", "id", "android") + dialog.findViewById(alertTitleId)?.setTextColor(NovaThemeManager.getTextPrimaryColor(context)) + dialog.findViewById(android.R.id.message)?.setTextColor(NovaThemeManager.getTextPrimaryColor(context)) + dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.let { button -> + button.setTextColor(if (destructivePositive) ContextCompat.getColor(context, R.color.nova_error) else NovaThemeManager.getAccentColor(context)) + } + dialog.getButton(AlertDialog.BUTTON_NEGATIVE)?.let { button -> + button.setTextColor(NovaThemeManager.getTextSecondaryColor(context)) + } + } + } + + fun createAlertDialogBackground(context: Context): GradientDrawable { + val radius = dp(context, SHEET_CORNER_RADIUS_DP).toFloat() + return GradientDrawable().apply { + shape = GradientDrawable.RECTANGLE + setColor(createSheetSurfaceColor(context)) + cornerRadius = radius + setStroke(dp(context, 1), getSheetStrokeColor(context)) + } + } + fun styleSheetTitle(title: TextView) { title.setTextColor(NovaThemeManager.getTextPrimaryColor(title.context)) } @@ -180,17 +212,36 @@ object NovaSheetChrome { } } - private fun createActionBackground(context: Context): GradientDrawable { + private fun createActionBackground(context: Context): StateListDrawable { + return StateListDrawable().apply { + addState( + intArrayOf(android.R.attr.state_pressed), + createActionStateBackground(context, fillAccentBlend = 0.24f, strokeAccentBlend = 0.58f) + ) + addState( + intArrayOf(android.R.attr.state_focused), + createActionStateBackground(context, fillAccentBlend = 0.18f, strokeAccentBlend = 0.50f) + ) + addState( + intArrayOf(), + createActionStateBackground(context, fillAccentBlend = 0f, strokeAccentBlend = 0.18f) + ) + } + } + + private fun createActionStateBackground( + context: Context, + fillAccentBlend: Float, + strokeAccentBlend: Float + ): GradientDrawable { val radius = dp(context, 16).toFloat() + val surface = createSheetSurfaceColor(context) + val accent = NovaThemeManager.getAccentColor(context) return GradientDrawable().apply { shape = GradientDrawable.RECTANGLE - setColor(Color.TRANSPARENT) + setColor(if (fillAccentBlend > 0f) ColorUtils.blendARGB(surface, accent, fillAccentBlend) else Color.TRANSPARENT) cornerRadius = radius - setStroke(dp(context, 1), ColorUtils.blendARGB( - NovaThemeManager.getDialogBackgroundColor(context), - NovaThemeManager.getAccentColor(context), - 0.18f - )) + setStroke(dp(context, 1), ColorUtils.blendARGB(surface, accent, strokeAccentBlend)) } } diff --git a/app/src/main/java/com/papi/nova/ui/NovaStreamOverlayContent.kt b/app/src/main/java/com/papi/nova/ui/NovaStreamOverlayContent.kt index 837866f4..931e9368 100644 --- a/app/src/main/java/com/papi/nova/ui/NovaStreamOverlayContent.kt +++ b/app/src/main/java/com/papi/nova/ui/NovaStreamOverlayContent.kt @@ -137,6 +137,15 @@ data class NovaSessionProgressUiState( progressFraction = 0.94f, aliases = setOf("input stream initialization", "input stream establishment") ), + StageCopy( + state = "unlocking_or_starting", + title = "Waiting on host...", + stageLabel = "Host readiness", + confidenceLabel = "Server starting or unlocking", + confidenceDetail = "The host is starting the app or unlocking before video can continue.", + progressFraction = 0.96f, + aliases = setOf("unlocking or starting", "server is starting or computer is unlocking") + ), StageCopy( state = "host_locked", title = "Host locked", diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4f5b1498..ab57586a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -442,8 +442,8 @@ Check your firewall and port forwarding rules for port(s): - Establishing Connection - Starting connection + Starting stream + Preparing video, audio, and controller input… Warning: Your active network connection is metered! Average frame decoding latency: hardware decoder latency: @@ -807,6 +807,12 @@ Session Nova HUD Tap HUD to cycle modes; long-press to open Command Center. + HUD Opacity + Adjust NovaHUD glass without dimming telemetry text. + Enable Nova HUD first to change glass opacity. + Set HUD opacity to %1$d percent + Selected + Not selected Stats Overlay Copy HUD Diagnostics Privacy-safe stream summary for bug reports. @@ -1068,8 +1074,10 @@ An error occurred: Save OSK profile Advanced Menu - Really want to quit? - Please make sure you have no unsaved progress. Quitting the session will terminate your current running application. + End this Nova session? + This closes the host app and the resumable stream. Use Disconnect from Command Center if you want the host to keep running. + End session + Stay in game Quit Session Server Commands Fetch Clipboard diff --git a/app/src/test/java/com/papi/nova/ui/NovaStreamOverlayUiStateTest.kt b/app/src/test/java/com/papi/nova/ui/NovaStreamOverlayUiStateTest.kt index d190b301..90f42527 100644 --- a/app/src/test/java/com/papi/nova/ui/NovaStreamOverlayUiStateTest.kt +++ b/app/src/test/java/com/papi/nova/ui/NovaStreamOverlayUiStateTest.kt @@ -131,6 +131,18 @@ class NovaStreamOverlayUiStateTest { assertTrue(state.completedStages.isEmpty()) } + @Test + fun progressStateMapsUnlockingOrStartingRetryToVerboseHostReadinessCopy() { + val state = NovaSessionProgressUiState.from("unlocking_or_starting", "Server is starting or computer is unlocking, please wait…") + + assertEquals("unlocking_or_starting", state.state) + assertEquals("Waiting on host...", state.title) + assertEquals("Host readiness", state.stageLabel) + assertEquals("Server starting or unlocking", state.confidenceLabel) + assertEquals("The host is starting the app or unlocking before video can continue.", state.confidenceDetail) + assertEquals(0.96f, state.progressFraction, 0.001f) + } + @Test fun progressStateMapsLockedHostToNovaUnlockPrompt() { val state = NovaSessionProgressUiState.from("host_locked") 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 1192769e..636136f4 100644 --- a/app/src/test/java/com/papi/nova/ui/NovaThemeResourcesTest.kt +++ b/app/src/test/java/com/papi/nova/ui/NovaThemeResourcesTest.kt @@ -28,14 +28,6 @@ class NovaThemeResourcesTest { assertEquals("Miami Nebula", names[values.indexOf("miami")]) } - @Test - fun preferencesThemeSummaryMentionsMiami() { - val preferencesXml = File("src/main/res/xml/preferences.xml").readText() - - assertTrue(preferencesXml.contains("android:key=\"nova_theme\"")) - assertTrue(preferencesXml.contains("Miami Nebula")) - } - @Test fun pspPortableChromeIsASelectableThemeValueAndLabel() { val context = ApplicationProvider.getApplicationContext() @@ -50,6 +42,8 @@ class NovaThemeResourcesTest { fun portableChromeAliasesToDefaultThemeAndBaseAccentAvoidsPurpleTaskMetadata() { val colors = File("src/main/res/values/colors_nova.xml").readText() val manager = File("src/main/java/com/papi/nova/ui/NovaThemeManager.kt").readText() + val styles = File("src/main/res/values/styles.xml").readText() + val stylesV14 = File("src/main/res/values-v14/styles.xml").readText() assertTrue(manager.contains("THEME_PORTABLE_CHROME")) assertTrue(manager.contains("portable_chrome")) @@ -199,9 +193,87 @@ class NovaThemeResourcesTest { assertTrue("native sheet backgrounds should alpha the active theme surface instead of using opaque slabs", sheetChrome.contains("ColorUtils.setAlphaComponent") && sheetChrome.contains("getSheetGlassAlpha")) assertTrue("high contrast can remain more opaque for readability", sheetChrome.contains("HIGH_CONTRAST_SHEET_GLASS_ALPHA")) assertTrue("shared scrim should be light enough for NovaHUD/game context to remain visible", sheetChrome.contains("const val SCRIM_ALPHA = 0.22f")) - assertTrue("action rows should remain transparent glass rows, not opaque mini slabs", sheetChrome.contains("setColor(Color.TRANSPARENT)")) + assertTrue("default action row state should remain transparent glass, not an opaque mini slab", sheetChrome.contains("fillAccentBlend = 0f") && sheetChrome.contains("Color.TRANSPARENT")) assertTrue("Compose library surfaces should reuse shared glass alpha language", composeTheme.contains("NovaSheetChrome.SHEET_GLASS_ALPHA")) assertTrue("game detail Compose drawer should reuse the shared native sheet radius token", gameDetail.contains("NovaSheetChrome.SHEET_CORNER_RADIUS_DP.dp")) } + + @Test + fun sessionQuitConfirmationUsesNovaGlassBottomSheetInsteadOfRawAlertDialog() { + val sheetChrome = File("src/main/java/com/papi/nova/ui/NovaSheetChrome.kt").readText() + val game = File("src/main/java/com/papi/nova/Game.kt").readText() + val quitBody = game.substringAfter("fun quit() {").substringBefore("override fun showGameMenu") + + assertTrue("shared chrome should still expose AlertDialog styling for remaining legacy session popups", sheetChrome.contains("applyAlertDialogChrome")) + assertTrue("quit confirmation should be rebuilt as a Nova bottom sheet so it shares drawer/HUD glass chrome", quitBody.contains("BottomSheetDialog")) + assertTrue("quit confirmation should build its own themed glass sheet container", quitBody.contains("NovaSheetChrome.createSheetContainer")) + assertTrue("quit confirmation should apply shared bottom-sheet chrome", quitBody.contains("NovaSheetChrome.applyBottomSheetChrome(sheet")) + assertTrue("quit confirmation should style custom action rows through shared sheet chrome", quitBody.contains("NovaSheetChrome.styleSheetAction")) + assertFalse("quit confirmation should not use a raw AlertDialog shell", quitBody.contains("AlertDialog.Builder")) + assertFalse("quit confirmation should not use platform dialog buttons", quitBody.contains("setPositiveButton") || quitBody.contains("setNegativeButton")) + assertTrue("quit confirmation should use Nova-themed session action copy", game.contains("R.string.game_dialog_action_end_session") && game.contains("R.string.game_dialog_action_stay_in_game")) + assertFalse("quit confirmation should drop the old generic streaming button labels", game.contains("game_dialog_action_end_stream") || game.contains("game_dialog_action_keep_streaming")) + } + + @Test + fun sessionProgressAndQuitCopyMatchesNovaSessionSemantics() { + val strings = File("src/main/res/values/strings.xml").readText() + + assertTrue("connection spinner title should say stream, not old connection/session language", strings.contains("""Starting stream""")) + assertTrue("connection spinner message should mention video/audio/input readiness", strings.contains("Preparing video, audio, and controller input")) + assertTrue("quit title should be Nova-session language, not raw stream-control wording", strings.contains("""End this Nova session?""")) + assertTrue("quit message should distinguish ending the host app from disconnect/resume", strings.contains("This closes the host app and the resumable stream")) + assertTrue("quit destructive action should say End session", strings.contains("""End session""")) + assertTrue("quit safe action should say Stay in game", strings.contains("""Stay in game""")) + assertFalse("old Keep streaming / End stream labels should not remain in the quit dialog copy", strings.contains("Keep streaming") || strings.contains("End stream and quit app?") || strings.contains("game_dialog_action_end_stream")) + } + + @Test + fun gameStartupUsesSessionProgressOverlayInsteadOfLegacySpinnerPopup() { + val game = File("src/main/java/com/papi/nova/Game.kt").readText() + val startup = game.substringAfter("setContentView(R.layout.activity_game)").substringBefore("appName =") + + assertTrue("Game startup should create the verbose Nova session progress overlay immediately", startup.contains("SessionProgressOverlay(this)")) + assertTrue("Game startup should show the verbose Nova session progress overlay immediately", startup.contains("novaProgressOverlay?.show()")) + assertFalse("Game startup must not show the legacy Starting stream spinner over the verbose progress overlay", startup.contains("SpinnerDialog.displayDialog")) + assertFalse("Startup retry path must not assume a legacy spinner exists", game.contains("spinner!!.setMessage(getResources().getString(R.string.unlocking_or_starting))")) + assertTrue("Startup retry path should report host readiness through the verbose progress overlay", game.contains("novaProgressOverlay?.updateState(\"unlocking_or_starting\"")) + } + @Test + fun legacyGameMenuUsesNovaGlassBottomSheetInsteadOfRawAlertList() { + val source = File("src/main/java/com/papi/nova/GameMenu.kt").readText() + val showMenuDialog = source.substringAfter("private fun showMenuDialog(").substringBefore("private fun showSpecialKeysMenu") + + assertTrue("GameMenu should render its in-stream menu as a Material bottom sheet", source.contains("BottomSheetDialog")) + assertTrue("GameMenu should use shared Nova glass sheet containers", showMenuDialog.contains("NovaSheetChrome.createSheetContainer")) + assertTrue("GameMenu should apply shared Nova bottom-sheet chrome", showMenuDialog.contains("NovaSheetChrome.applyBottomSheetChrome")) + assertTrue("GameMenu title should use shared sheet title styling", showMenuDialog.contains("NovaSheetChrome.styleSheetTitle")) + assertTrue("GameMenu rows should use shared focusable sheet action styling", showMenuDialog.contains("NovaSheetChrome.styleSheetAction")) + assertFalse("GameMenu list must not use raw AlertDialog.Builder for the menu shell", showMenuDialog.contains("AlertDialog.Builder")) + assertFalse("GameMenu list must not use Android simple_list_item_1 rows", showMenuDialog.contains("android.R.layout.simple_list_item_1")) + assertFalse("GameMenu list must not use ArrayAdapter-backed legacy rows", showMenuDialog.contains("ArrayAdapter")) + assertTrue("server-command empty dialog should still receive Nova alert chrome", source.contains("NovaSheetChrome.applyAlertDialogChrome(serverCommandDialog")) + } + + + @Test + fun noAvcDecoderErrorDismissesSessionProgressOverlayBeforeDialog() { + val game = File("src/main/java/com/papi/nova/Game.kt").readText() + val noAvcBlock = game.substringAfter("if (!decoderRenderer!!.isAvcSupported)").substringBefore("return") + + assertTrue("No-AVC decoder error path should dismiss the verbose session progress overlay before showing the fatal dialog", noAvcBlock.contains("novaProgressOverlay?.dismiss()")) + assertTrue("No-AVC decoder error path should still dismiss the legacy spinner for compatibility", noAvcBlock.contains("spinner!!.dismiss()")) + assertTrue("No-AVC decoder error path should show the hardware H.264 support dialog after cleanup", noAvcBlock.contains("Dialog.displayDialog")) + } + + @Test + fun sheetActionRowsExposeDpadFocusedAndPressedFeedback() { + val sheetChrome = File("src/main/java/com/papi/nova/ui/NovaSheetChrome.kt").readText() + + assertTrue("sheet action rows should use a stateful background so D-pad focus is visible", sheetChrome.contains("StateListDrawable")) + assertTrue("sheet action rows should define a focused state", sheetChrome.contains("android.R.attr.state_focused")) + assertTrue("sheet action rows should define a pressed state", sheetChrome.contains("android.R.attr.state_pressed")) + assertTrue("focused/pressed rows should blend with the active theme accent", sheetChrome.contains("createActionStateBackground") && sheetChrome.contains("NovaThemeManager.getAccentColor")) + } }