diff --git a/app/src/androidTest/java/com/papi/nova/ui/NovaGameDetailComposeTest.kt b/app/src/androidTest/java/com/papi/nova/ui/NovaGameDetailComposeTest.kt index a94d3052..bdf939ae 100644 --- a/app/src/androidTest/java/com/papi/nova/ui/NovaGameDetailComposeTest.kt +++ b/app/src/androidTest/java/com/papi/nova/ui/NovaGameDetailComposeTest.kt @@ -7,6 +7,7 @@ import androidx.test.ext.junit.rules.ActivityScenarioRule import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import com.papi.nova.api.PolarisClientSettings +import com.papi.nova.api.PolarisApiClient import com.papi.nova.shared.polaris.model.PolarisGame import com.papi.nova.ui.compose.NovaComposeTheme import org.junit.Assert.assertTrue @@ -113,7 +114,23 @@ class NovaGameDetailComposeTest { iconAvailable = false, iconPresentationKey = "", iconLoader = {}, - coverLoader = {} + coverLoader = {}, + destination = NovaGameDetailDestination.OVERVIEW, + steamDecision = null, + reviewExpanded = false, + // No host is reachable under test; the backdrop simply draws nothing. + apiClient = PolarisApiClient( + InstrumentationRegistry.getInstrumentation().targetContext, + "", + ), + sourceLabel = "Steam", + onDestination = {}, + onSteamChoice = {}, + // no session under test, so the primary action stays a launch + activeSession = null, + onResumeSession = {}, + onEndSession = {}, + onDismissDestination = {}, ) } } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index cbd91979..d8df620b 100755 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -294,6 +294,7 @@ diff --git a/app/src/main/java/com/papi/nova/api/PolarisGameJsonAdapter.kt b/app/src/main/java/com/papi/nova/api/PolarisGameJsonAdapter.kt index 214bf197..e51652c8 100644 --- a/app/src/main/java/com/papi/nova/api/PolarisGameJsonAdapter.kt +++ b/app/src/main/java/com/papi/nova/api/PolarisGameJsonAdapter.kt @@ -43,6 +43,23 @@ object PolarisGameJsonAdapter { coverUrl = json.optString("cover_url", ""), genres = fetchStringArray(json.optJSONArray("genres")), lastLaunched = json.optLong("last_launched", 0), + beatTime = json.optJSONObject("beat_time")?.let { beat -> + PolarisGame.BeatTime( + mainSeconds = beat.optLong("main_seconds", 0).coerceAtLeast(0), + extrasSeconds = beat.optLong("extras_seconds", 0).coerceAtLeast(0), + completionistSeconds = beat.optLong("completionist_seconds", 0).coerceAtLeast(0), + matchedName = beat.optString("matched_name", ""), + url = beat.optString("url", ""), + cachedAt = beat.optLong("cached_at", 0).coerceAtLeast(0) + ) + }, + playTime = json.optJSONObject("play_time")?.let { played -> + PolarisGame.PlayTime( + seconds = played.optLong("seconds", 0).coerceAtLeast(0), + source = played.optString("source", ""), + readAt = played.optLong("read_at", 0).coerceAtLeast(0) + ) + }, mangohud = json.optBoolean("mangohud", false), hdrSupported = json.optBoolean("hdr_supported", false), launchMode = launchMode, diff --git a/app/src/main/java/com/papi/nova/ui/NovaArtworkStudio.kt b/app/src/main/java/com/papi/nova/ui/NovaArtworkStudio.kt index ac5005e4..7be75ca9 100644 --- a/app/src/main/java/com/papi/nova/ui/NovaArtworkStudio.kt +++ b/app/src/main/java/com/papi/nova/ui/NovaArtworkStudio.kt @@ -288,10 +288,12 @@ fun NovaArtworkStudio( choicePreviewLoader: (ImageView, PolarisArtworkChoice) -> Unit, currentArtworkPresentationKey: (String) -> String, currentArtworkLoader: (ImageView, String) -> Unit, + /** True when the studio is the destination rather than a row inside one. */ + initiallyExpanded: Boolean = false, ) { val colors = LocalNovaComposeColors.current val surfaces = LocalNovaLibrarySurfaces.current - var expanded by remember(initialQuery) { mutableStateOf(false) } + var expanded by remember(initialQuery) { mutableStateOf(initiallyExpanded) } var query by remember(initialQuery) { mutableStateOf(initialQuery) } val title = stringResource(R.string.nova_artwork_studio_title) val summary = stringResource(R.string.nova_artwork_studio_summary) diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt index c07c712a..420914d7 100644 --- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt +++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt @@ -153,6 +153,17 @@ class NovaGameDetailActivity : NovaActivity() { private val onGameUpdated: ((PolarisGame) -> Unit)? = { game -> updatedGame = game } + /** Resume and End need stream credentials this window does not carry, so it asks. */ + private fun finishWithSessionRequest(request: String) { + setResult( + RESULT_OK, + Intent() + .putExtra(EXTRA_RESULT_SESSION, request) + .putExtra(EXTRA_RESULT_GAME, updatedGame?.let { PolarisGameJson.encode(it) }), + ) + finish() + } + private val onRefreshArtwork: ((PolarisGame, (NovaArtworkMutationResult) -> Unit) -> Unit)? = { game, onResult -> artworkViewModel.refreshArtwork(game = game, onResult = onResult) } @@ -176,6 +187,20 @@ class NovaGameDetailActivity : NovaActivity() { /** Artwork and MangoHUD edits made here; handed back so the library can merge them. */ private var updatedGame: PolarisGame? = null + private var destination by mutableStateOf(NovaGameDetailDestination.OVERVIEW) + + /** + * Set when Polaris reports desktop Steam active. It turns Launch mode into the + * three-way choice that used to be a bottom sheet raised over the content. + */ + private var steamDecision by mutableStateOf(null) + + /** The preflight review, expanded on the Overview rather than raised as an alert. */ + private var reviewExpanded by mutableStateOf(false) + + /** The host's session, when it is this game's. Null means nothing is running. */ + private var activeSession by mutableStateOf(null) + override fun onCreate(savedInstanceState: Bundle?) { NovaThemeManager.applyTheme(this) super.onCreate(savedInstanceState) @@ -206,6 +231,7 @@ class NovaGameDetailActivity : NovaActivity() { this, object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { + if (dismissActiveDetailDestination()) return publishGameUpdate() finish() } @@ -213,6 +239,40 @@ class NovaGameDetailActivity : NovaActivity() { ) setUpDetail(game, apiClient) + refreshActiveSession(game) + } + + /** + * Unwinds one level: an expanded review collapses, a destination returns to the + * Overview, and only then does back leave for the library. + */ + private fun dismissActiveDetailDestination(): Boolean = when { + reviewExpanded -> { + reviewExpanded = false + true + } + destination != NovaGameDetailDestination.OVERVIEW -> { + destination = NovaGameDetailDestination.OVERVIEW + steamDecision = null + true + } + else -> false + } + + /** + * Polaris reports one session at a time, so it only matters here when it is this + * game's. Matched on the UUID Polaris uses, falling back to the numeric app id. + */ + private fun refreshActiveSession(game: PolarisGame) { + lifecycleScope.launch { + val session = withContext(Dispatchers.IO) { + runCatching { NovaLibraryActiveSessionUiState.from(apiClient.getSessionStatus()) } + .getOrNull() + } + activeSession = session?.takeIf { + it.gameUuid.equals(game.id, ignoreCase = true) || it.gameId == game.appId + } + } } /** Carries artwork or MangoHUD edits back even when the window closes without launching. */ @@ -355,11 +415,36 @@ class NovaGameDetailActivity : NovaActivity() { allowedModes = allowedModes ) currentGame = currentGame.copy(launchMode = updatedLaunchMode) + NovaLaunchModeOverrides.save(this@NovaGameDetailActivity, currentGame, mode) refreshUiState() optimizationState = NovaGameDetailOptimizationState() loadOptimization(profilePreference, usesVirtualDisplay = mode == "virtual_display") } + fun launchConfirmed(mirrorDesktop: Boolean, forcePrivateAfterSteamClose: Boolean = false) { + onLaunch?.invoke( + currentGame.copy(mangohud = mangoHudEnabled), + uiState.playUsesVirtualDisplay, + mirrorDesktop, + forcePrivateAfterSteamClose, + profilePreference, + optimizationState.rawOptimization + ) + finish() + } + + fun resetProfile() { + resetWorking = true + lifecycleScope.launch { + withContext(Dispatchers.IO) { + apiClient.clearOptimizerProfile(deviceName, currentGame.name) + } + optimizationState = NovaGameDetailOptimizationState() + loadOptimization(profilePreference) + resetWorking = false + } + } + setContentView( ComposeView(this).apply { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnDetachedFromWindow) @@ -407,47 +492,43 @@ class NovaGameDetailActivity : NovaActivity() { 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( + val decision = 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) + when { + // A choice of where to run belongs in the destination named that, + // not in a sheet raised over the artwork. + decision.required -> { + steamDecision = decision + destination = NovaGameDetailDestination.LAUNCH_MODE + } + // The review is a statement about the profile, and the status + // line is where the profile lives, so it expands in place. + optimizationState.reviewRequired && !reviewExpanded -> { + reviewExpanded = true + } + else -> launchConfirmed(false) + } + }, + destination = destination, + steamDecision = steamDecision, + reviewExpanded = reviewExpanded, + apiClient = apiClient, + sourceLabel = currentGame.sourceLabel, + onDestination = { next -> destination = next }, + onDismissDestination = { dismissActiveDetailDestination() }, + activeSession = activeSession, + onResumeSession = { finishWithSessionRequest(RESULT_SESSION_RESUME) }, + onEndSession = { finishWithSessionRequest(RESULT_SESSION_END) }, + onSteamChoice = { choice -> + when (choice) { + NovaSteamLaunchChoice.PRIVATE_STREAM -> + launchConfirmed(mirrorDesktop = false, forcePrivateAfterSteamClose = false) + NovaSteamLaunchChoice.MIRROR_DESKTOP -> + launchConfirmed(mirrorDesktop = true, forcePrivateAfterSteamClose = false) + NovaSteamLaunchChoice.CLOSE_STEAM_THEN_PRIVATE -> + launchConfirmed(mirrorDesktop = false, forcePrivateAfterSteamClose = true) } }, onLaunchOptions = { @@ -459,7 +540,13 @@ class NovaGameDetailActivity : NovaActivity() { profileOptionsState = null } }, - onLaunchModeSelected = ::selectLaunchMode, + onLaunchModeSelected = { mode -> + // The concept: choosing sets the mode and returns to the Overview, + // whose readout reflects it. Staying put left the pill showing the + // previous mode until the window was opened again. + selectLaunchMode(mode) + destination = NovaGameDetailDestination.OVERVIEW + }, onLaunchOptionSelected = { option -> fun launchSelected(mirrorDesktop: Boolean, forcePrivateAfterSteamClose: Boolean = false) { val selectedLaunchOptimization = option.launchOptimization ?: optimizationState.rawOptimization @@ -480,12 +567,10 @@ class NovaGameDetailActivity : NovaActivity() { 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) } - ) + // Same destination the primary action routes to; picking an + // explicit option does not change where the choice belongs. + steamDecision = desktopSteamDecision + destination = NovaGameDetailDestination.LAUNCH_MODE } else { launchSelected(mirrorDesktop = false) } @@ -733,7 +818,8 @@ class NovaGameDetailActivity : NovaActivity() { game = game, defaultToVirtualDisplay = defaultToVirtualDisplay, clientSettings = clientSettings, - profilePreference = profilePreference + profilePreference = profilePreference, + launchModeOverride = NovaLaunchModeOverrides.load(this@NovaGameDetailActivity, game), ) } @@ -919,74 +1005,7 @@ class NovaGameDetailActivity : NovaActivity() { ) } - 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 @@ -1206,7 +1225,7 @@ class NovaGameDetailActivity : NovaActivity() { "" } val sourceText = listOf( - stateLabel.takeIf { it.isNotBlank() }, + stateLabel.takeIf { it.isNotBlank() && stateLabel != titleLabel }, profileState?.optString("preference_label", "")?.takeIf { it.isNotBlank() }, lastResultText.takeIf { it.isNotBlank() }, sourceLabel.takeIf { it.isNotBlank() && sourceLabel != titleLabel }, @@ -1359,6 +1378,9 @@ class NovaGameDetailActivity : NovaActivity() { 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_SESSION = "nova.detail.result.session" + const val RESULT_SESSION_RESUME = "resume" + const val RESULT_SESSION_END = "end" const val EXTRA_RESULT_GAME = "nova.detail.result.game" const val RESULT_KEY_VIRTUAL_DISPLAY = "virtualDisplay" diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt index 434f76cc..54e6560f 100644 --- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt +++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt @@ -1,23 +1,12 @@ package com.papi.nova.ui -import android.app.Dialog -import android.content.Context -import android.content.res.Configuration -import android.os.Bundle -import android.text.format.DateUtils -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup import android.widget.ImageView -import android.widget.ScrollView -import android.widget.Toast -import androidx.appcompat.app.AlertDialog import androidx.compose.foundation.ScrollState import androidx.compose.foundation.background import androidx.compose.foundation.border -import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectVerticalDragGestures import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.focusGroup import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints @@ -25,18 +14,15 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.safeContent import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.windowInsetsPadding @@ -44,7 +30,6 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -57,13 +42,13 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance import androidx.compose.ui.res.colorResource import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics @@ -72,41 +57,21 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.viewinterop.AndroidView -import androidx.core.content.ContextCompat -import androidx.core.widget.NestedScrollView import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import com.google.android.material.bottomsheet.BottomSheetBehavior -import com.google.android.material.bottomsheet.BottomSheetDialog -import com.google.android.material.bottomsheet.BottomSheetDialogFragment -import com.papi.nova.LimeLog import com.papi.nova.R import com.papi.nova.api.PolarisApiClient import com.papi.nova.api.PolarisArtworkChoice import com.papi.nova.api.PolarisArtworkMatchCandidate -import com.papi.nova.api.PolarisClientSettings -import com.papi.nova.api.PolarisStreamDisplayMode import com.papi.nova.shared.polaris.model.PolarisGame -import com.papi.nova.manager.StreamSyncManager -import com.papi.nova.preferences.PreferenceConfiguration import com.papi.nova.ui.compose.LocalNovaComposeColors import com.papi.nova.ui.compose.LocalNovaLibrarySurfaces import com.papi.nova.ui.compose.LocalNovaMenuOpacityScale import com.papi.nova.ui.compose.NovaActionButton import com.papi.nova.ui.compose.NovaBadge -import com.papi.nova.ui.compose.NovaComposeTheme import com.papi.nova.ui.compose.NovaControllerHint -import com.papi.nova.ui.compose.NovaControllerHintBar import com.papi.nova.ui.compose.NovaFocusableCard -import com.papi.nova.utils.DeviceUtils -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import org.json.JSONObject -import java.util.Locale -import kotlin.math.abs -import kotlin.math.round internal fun canPublishArtworkMutationUiForState(state: Lifecycle.State?): Boolean = state?.isAtLeast(Lifecycle.State.CREATED) == true @@ -213,7 +178,7 @@ data class NovaSteamLaunchModeOptionsState( @Composable -fun NovaGameDetailContent( +internal fun NovaGameDetailContent( uiState: NovaGameDetailUiState, launchIntro: String, recommendedBadge: String, @@ -280,152 +245,199 @@ fun NovaGameDetailContent( iconPresentationKey: String, iconLoader: (ImageView) -> Unit, iconContentDescription: String = "", - coverLoader: (ImageView) -> Unit + coverLoader: (ImageView) -> Unit, + destination: NovaGameDetailDestination, + steamDecision: NovaDesktopSteamLaunchDecision?, + reviewExpanded: Boolean, + apiClient: PolarisApiClient, + sourceLabel: String, + onDestination: (NovaGameDetailDestination) -> Unit, + onSteamChoice: (NovaSteamLaunchChoice) -> Unit, + activeSession: NovaLibraryActiveSessionUiState?, + onResumeSession: () -> Unit, + onEndSession: () -> Unit, + onDismissDestination: () -> Unit, ) { - val colors = LocalNovaComposeColors.current - val surfaces = LocalNovaLibrarySurfaces.current val verticalScroll = rememberScrollState() val playFocusRequester = remember { FocusRequester() } val detailsFocusRequester = remember { FocusRequester() } - Column( - modifier = modifier - .fillMaxWidth() - .fillMaxHeight() - .clip(RoundedCornerShape(topStart = NovaSheetChrome.SHEET_CORNER_RADIUS_DP.dp, topEnd = NovaSheetChrome.SHEET_CORNER_RADIUS_DP.dp)) - .background(surfaces.panel) - ) { - NovaGameDetailScrollableContent( - scrollState = verticalScroll, - modifier = Modifier.weight(1f) - ) { - GameDetailsPanel( - uiState = uiState, - lastPlayedText = lastPlayedText, - coverContentDescription = coverContentDescription, - coverLoader = coverLoader, - artworkState = artworkState, - heroAvailable = heroAvailable, - heroPresentationKey = heroPresentationKey, - heroLoader = heroLoader, - heroContentDescription = heroContentDescription, - logoAvailable = logoAvailable, - logoPresentationKey = logoPresentationKey, - logoLoader = logoLoader, - logoContentDescription = logoContentDescription, - iconAvailable = iconAvailable, - iconPresentationKey = iconPresentationKey, - iconLoader = iconLoader, - iconContentDescription = iconContentDescription, - ) - - LaunchControlsPanel( - uiState = uiState, - launchIntro = launchIntro, - launchModeTitle = launchModeTitle, - recommendedBadge = recommendedBadge, - launchOptionsLabel = launchOptionsLabel, - profilePreferenceLabel = profilePreferenceLabel, - profileSummary = optimizationState.profileSummary, - resetProfileLabel = resetProfileLabel, - resetProfileWorking = resetProfileWorking, - headlessModeLabel = headlessModeLabel, - virtualDisplayModeLabel = virtualDisplayModeLabel, - playFocusRequester = playFocusRequester, - detailsFocusRequester = detailsFocusRequester, - onLaunchOptions = onLaunchOptions, - onLaunchModeSelected = onLaunchModeSelected, - onProfilePreference = onProfilePreference, - onRetryHighFps = onRetryHighFps, - onResetProfile = onResetProfile - ) + Box(modifier = modifier.fillMaxSize()) { + NovaGameDetailOverview( + uiState = uiState, + apiClient = apiClient, + playLabel = playLabel, + lastPlayedText = lastPlayedText, + sourceLabel = sourceLabel, + optimizationState = optimizationState, + reviewExpanded = reviewExpanded, + showLaunchModeAction = uiState.showLaunchOptionsButton, + logoAvailable = logoAvailable, + logoPresentationKey = logoPresentationKey, + logoLoader = logoLoader, + logoContentDescription = logoContentDescription, + playFocusRequester = playFocusRequester, + onPrimaryLaunch = onPrimaryLaunch, + onRetryHighFps = onRetryHighFps, + onResetProfile = onResetProfile, + onDestination = onDestination, + activeSession = activeSession, + onResumeSession = onResumeSession, + onEndSession = onEndSession, + // While a destination is open the Overview is scenery. Without this, a d-pad + // press walks out of the panel onto a control dimmed behind the scrim. + modifier = if (destination == NovaGameDetailDestination.OVERVIEW) { + Modifier + } else { + Modifier.focusGroup().focusProperties { canFocus = false } + }, + ) - launchOptionsState?.let { - NovaLaunchOptionsSheet( - state = it, - onLaunch = onLaunchOptionSelected, - onDismiss = onDismissLaunchOptions - ) - } + when (destination) { + NovaGameDetailDestination.OVERVIEW -> Unit - profileOptionsState?.let { - NovaProfilePreferenceSheet( - state = it, - onSelected = onProfilePreferenceSelected, - onDismiss = onDismissProfileOptions - ) + NovaGameDetailDestination.LAUNCH_MODE -> NovaGameDetailPanel( + eyebrow = stringResource(R.string.nova_library_launch_mode_title), + headline = stringResource(R.string.nova_game_detail_where_it_runs), + readout = if (steamDecision != null) { + stringResource(R.string.nova_desktop_steam_title) + } else { + optimizationState.profileSummary?.selectedLine.orEmpty() + }, + scrollState = verticalScroll, + onDismiss = onDismissDestination, + ) { + val decision = steamDecision + if (decision != null) { + NovaDesktopSteamLaunchDecisionRows( + decision = decision, + onChoice = onSteamChoice, + ) + } else { + LaunchControls( + uiState = uiState, + launchIntro = launchIntro, + launchModeTitle = launchModeTitle, + launchOptionsLabel = launchOptionsLabel, + profileSummary = optimizationState.profileSummary, + headlessModeLabel = headlessModeLabel, + virtualDisplayModeLabel = virtualDisplayModeLabel, + playFocusRequester = playFocusRequester, + detailsFocusRequester = detailsFocusRequester, + onLaunchOptions = onLaunchOptions, + onLaunchModeSelected = onLaunchModeSelected, + ) + launchOptionsState?.let { + NovaLaunchOptionsSheet( + state = it, + onLaunch = onLaunchOptionSelected, + onDismiss = onDismissLaunchOptions + ) + } + } } - SteamLaunchModeCard( - visible = uiState.showSteamLaunchMode, - label = steamLaunchLabel, - modeLabel = steamLaunchModeLabel, - caption = steamLaunchCaption, - warning = uiState.steamLaunchWarning, - onClick = onSteamLaunchMode - ) + NovaGameDetailDestination.TUNE -> NovaGameDetailPanel( + eyebrow = stringResource(R.string.nova_game_detail_tune), + headline = stringResource( + AutoQualityProfilePreferences.shortLabelRes(uiState.profilePreference), + ), + readout = listOf( + optimizationState.profileSummary?.selectedLine, + optimizationState.profileSummary?.freshnessLine, + ).filter { !it.isNullOrBlank() }.joinToString(" · "), + scrollState = verticalScroll, + onDismiss = onDismissDestination, + ) { + // A picker is what you are doing with the destination while it is + // open, not a row inside one of its groups, so it spans the body, + // above the groups rather than below the fold they push it past. + profileOptionsState?.let { + NovaProfilePreferenceSheet( + state = it, + onSelected = onProfilePreferenceSelected, + onDismiss = onDismissProfileOptions + ) + } + steamLaunchOptionsState?.let { state -> + NovaSteamLaunchModeSheet( + state = state, + onSelected = onSteamLaunchModeSelected, + onDismiss = onDismissSteamLaunchModeOptions + ) + } - steamLaunchOptionsState?.let { state -> - NovaSteamLaunchModeSheet( - state = state, - onSelected = onSteamLaunchModeSelected, - onDismiss = onDismissSteamLaunchModeOptions + NovaGameDetailGroupLabel(stringResource(R.string.nova_game_detail_group_state)) + // The concept leads State with the profile itself, and it is the only way + // into the preference picker now that launch mode no longer carries a + // second copy of Tune's controls. + NovaSteamChoiceRow( + label = stringResource(R.string.nova_game_detail_profile_label), + caption = stringResource(R.string.nova_game_detail_profile_caption), + enabled = true, + onClick = onProfilePreference, + value = stringResource( + AutoQualityProfilePreferences.shortLabelRes(uiState.profilePreference), + ), ) - } - - if (mangoHudEnabled) { - MangoHudPassiveStatus( - label = mangoHudStatusLabel, - caption = mangoHudStatusCaption, - warning = mangoHudWarning + SteamLaunchModeCard( + visible = uiState.showSteamLaunchMode, + label = steamLaunchLabel, + modeLabel = steamLaunchModeLabel, + caption = steamLaunchCaption, + warning = uiState.steamLaunchWarning, + onClick = onSteamLaunchMode ) + if (mangoHudEnabled) { + MangoHudPassiveStatus( + label = mangoHudStatusLabel, + caption = mangoHudStatusCaption, + warning = mangoHudWarning + ) + } + NovaGameDetailGroupLabel(stringResource(R.string.nova_game_detail_group_actions)) + LaunchProfileSummaryActions( + summary = optimizationState.profileSummary, + resetProfileLabel = resetProfileLabel, + resetProfileWorking = resetProfileWorking, + onRetryHighFps = onRetryHighFps, + onResetProfile = onResetProfile, + ) + NovaGameDetailGroupLabel(stringResource(R.string.nova_game_detail_group_insight)) + optimizationState.ai?.let { InsightCard(card = it) } + optimizationState.stability?.let { InsightCard(card = it) } } - optimizationState.ai?.let { - InsightCard(card = it) - } - - optimizationState.stability?.let { - InsightCard(card = it) + // The studio opens with a Row of weighted Columns, so it needs the window + // rather than the 438dp panel the other two destinations use. + NovaGameDetailDestination.ARTWORK -> NovaGameDetailFullScreen( + eyebrow = stringResource(R.string.nova_artwork_studio_title), + headline = uiState.game.name, + scrollState = verticalScroll, + onDismiss = onDismissDestination, + ) { + NovaArtworkStudio( + initiallyExpanded = true, + state = artworkState, + initialQuery = uiState.game.name, + onRefresh = onRefreshArtwork, + onSearch = onSearchArtwork, + onIdentitySelected = onIdentitySelected, + onChangeIdentity = onIdentityChange, + onKindSelected = onKindSelected, + onChoiceSelected = onChoiceSelected, + onReset = onStudioAction, + onApply = onApplyArtwork, + onCancel = onStudioAction, + onClear = onClearArtwork, + onTransform = onLogoTransform, + candidatePreviewLoader = candidatePreviewLoader, + choicePreviewLoader = choicePreviewLoader, + currentArtworkPresentationKey = currentArtworkPresentationKey, + currentArtworkLoader = currentArtworkLoader, + ) } - - NovaArtworkStudio( - state = artworkState, - initialQuery = uiState.game.name, - onRefresh = onRefreshArtwork, - onSearch = onSearchArtwork, - onIdentitySelected = onIdentitySelected, - onChangeIdentity = onIdentityChange, - onKindSelected = onKindSelected, - onChoiceSelected = onChoiceSelected, - onReset = onStudioAction, - onApply = onApplyArtwork, - onCancel = onStudioAction, - onClear = onClearArtwork, - onTransform = onLogoTransform, - candidatePreviewLoader = candidatePreviewLoader, - choicePreviewLoader = choicePreviewLoader, - currentArtworkPresentationKey = currentArtworkPresentationKey, - currentArtworkLoader = currentArtworkLoader, - ) - - NovaControllerHintBar( - hints = novaGameDetailControllerHints(), - compact = true, - modifier = Modifier - .fillMaxWidth() - .padding(start = 14.dp, end = 14.dp, top = 12.dp) - ) } - - NovaGameDetailLaunchFooter( - playLabel = playLabel, - enabled = uiState.playEnabled, - onPrimaryLaunch = onPrimaryLaunch, - playFocusRequester = playFocusRequester, - detailsFocusRequester = detailsFocusRequester, - contentInsets = WindowInsets.safeContent.only(WindowInsetsSides.Bottom) - ) } } @@ -964,58 +976,6 @@ private fun NovaGameDetailIdentity( } } -@Composable -private fun LaunchControlsPanel( - uiState: NovaGameDetailUiState, - launchIntro: String, - launchModeTitle: String, - recommendedBadge: String, - launchOptionsLabel: String, - profilePreferenceLabel: String, - profileSummary: NovaLaunchProfileSummary?, - resetProfileLabel: String, - resetProfileWorking: Boolean, - headlessModeLabel: String, - virtualDisplayModeLabel: String, - playFocusRequester: FocusRequester, - detailsFocusRequester: FocusRequester, - onLaunchOptions: () -> Unit, - onLaunchModeSelected: (String) -> Unit, - onProfilePreference: () -> Unit, - onRetryHighFps: () -> Unit, - onResetProfile: () -> Unit -) { - NovaDetailPanel( - modifier = Modifier - .fillMaxWidth() - .padding(start = 14.dp, end = 14.dp, top = 10.dp), - contentDescription = "Launch controls", - accent = true, - contentPadding = PaddingValues(12.dp) - ) { - LaunchControls( - uiState = uiState, - launchIntro = launchIntro, - launchModeTitle = launchModeTitle, - recommendedBadge = recommendedBadge, - launchOptionsLabel = launchOptionsLabel, - profilePreferenceLabel = profilePreferenceLabel, - profileSummary = profileSummary, - resetProfileLabel = resetProfileLabel, - resetProfileWorking = resetProfileWorking, - headlessModeLabel = headlessModeLabel, - virtualDisplayModeLabel = virtualDisplayModeLabel, - playFocusRequester = playFocusRequester, - detailsFocusRequester = detailsFocusRequester, - onLaunchOptions = onLaunchOptions, - onLaunchModeSelected = onLaunchModeSelected, - onProfilePreference = onProfilePreference, - onRetryHighFps = onRetryHighFps, - onResetProfile = onResetProfile - ) - } -} - @Composable private fun MetadataBadges(game: PolarisGame) { val horizontalScroll = rememberScrollState() @@ -1058,59 +1018,25 @@ private fun LaunchControls( uiState: NovaGameDetailUiState, launchIntro: String, launchModeTitle: String, - recommendedBadge: String, launchOptionsLabel: String, - profilePreferenceLabel: String, profileSummary: NovaLaunchProfileSummary?, - resetProfileLabel: String, - resetProfileWorking: Boolean, headlessModeLabel: String, virtualDisplayModeLabel: String, playFocusRequester: FocusRequester, detailsFocusRequester: FocusRequester, onLaunchOptions: () -> Unit, onLaunchModeSelected: (String) -> Unit, - onProfilePreference: () -> Unit, - onRetryHighFps: () -> Unit, - onResetProfile: () -> Unit ) { val colors = LocalNovaComposeColors.current Column { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = launchModeTitle, - modifier = Modifier.weight(1f), - color = colors.textPrimary, - fontSize = 16.sp, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - if (uiState.showRecommendedModeBadge) { - Spacer(modifier = Modifier.width(8.dp)) - NovaBadge( - text = recommendedBadge, - color = colors.onAccent, - backgroundColor = colors.accent.copy(alpha = 0.86f), - borderColor = colors.accent, - fontWeight = FontWeight.SemiBold, - fontSize = 11.sp, - contentPadding = PaddingValues(horizontal = 10.dp, vertical = 5.dp) - ) - } - } - Text( text = launchIntro, - modifier = Modifier.padding(top = 6.dp), + modifier = Modifier.padding(horizontal = NovaGameDetailInset), color = if (uiState.virtualDisplayUnavailable) colors.warning else colors.textSecondary, fontSize = 11.sp, - lineHeight = 13.sp, - maxLines = 2, + lineHeight = 14.sp, + maxLines = 4, overflow = TextOverflow.Ellipsis ) @@ -1144,94 +1070,49 @@ private fun LaunchControls( } if (uiState.showLaunchOptionsButton || uiState.showVirtualUnavailableHint) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = 10.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - LaunchModeChoicePill( - label = headlessModeLabel, - status = when { - uiState.playMode == "headless" -> "Selected" - uiState.recommendedMode == "headless" && uiState.headlessAllowed -> "Recommended" - uiState.headlessAllowed -> "Available" - else -> "Unavailable" - }, - recommended = uiState.recommendedMode == "headless" && uiState.headlessAllowed, - selected = uiState.playMode == "headless", - unavailable = !uiState.headlessAllowed, - onClick = { onLaunchModeSelected("headless") }, - modifier = Modifier.weight(1f) - ) - LaunchModeChoicePill( - label = virtualDisplayModeLabel, - status = when { - uiState.virtualDisplayUnavailable -> "Unavailable" - uiState.playMode == "virtual_display" -> "Selected" - uiState.recommendedMode == "virtual_display" && uiState.virtualDisplayAllowed -> "Recommended" - uiState.virtualDisplayAllowed -> "Available" - else -> "Unavailable" - }, - recommended = uiState.recommendedMode == "virtual_display" && uiState.virtualDisplayAllowed, - selected = uiState.playMode == "virtual_display", - unavailable = uiState.virtualDisplayUnavailable || !uiState.virtualDisplayAllowed, - onClick = { onLaunchModeSelected("virtual_display") }, - modifier = Modifier.weight(1f) - ) - } - } - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = 9.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - if (uiState.showLaunchOptionsButton) { - NovaActionButton( - text = launchOptionsLabel, - onClick = onLaunchOptions, - modifier = Modifier.weight(1f), - contentDescription = launchOptionsLabel, - minHeight = 42.dp, - cornerRadius = 10.dp, - fontSize = 12.sp, - contentPadding = PaddingValues(horizontal = 10.dp, vertical = 8.dp) - ) - } - NovaActionButton( - text = profilePreferenceLabel, - onClick = onProfilePreference, - modifier = if (uiState.showLaunchOptionsButton) Modifier.weight(1f) else Modifier.fillMaxWidth(), - contentDescription = profilePreferenceLabel, - minHeight = 42.dp, - cornerRadius = 10.dp, - fontSize = 12.sp, - contentPadding = PaddingValues(horizontal = 10.dp, vertical = 8.dp) + // The two modes are a list of choices, so they read as rows like the rest of + // the drawer, each carrying its standing as the value. + NovaSteamChoiceRow( + label = headlessModeLabel, + caption = stringResource(R.string.nova_game_detail_headless_caption), + enabled = uiState.headlessAllowed, + onClick = { onLaunchModeSelected("headless") }, + value = when { + uiState.playMode == "headless" -> "Selected" + uiState.hasRecommendation && uiState.recommendedMode == "headless" && + uiState.headlessAllowed -> "Recommended" + uiState.headlessAllowed -> "Available" + else -> "Unavailable" + }, + ) + NovaSteamChoiceRow( + label = virtualDisplayModeLabel, + caption = if (uiState.showVirtualUnavailableHint) { + uiState.virtualDisplayUnavailableReason + } else { + stringResource(R.string.nova_game_detail_virtual_caption) + }, + enabled = uiState.virtualDisplayAllowed && !uiState.virtualDisplayUnavailable, + onClick = { onLaunchModeSelected("virtual_display") }, + value = when { + uiState.virtualDisplayUnavailable -> "Unavailable" + uiState.playMode == "virtual_display" -> "Selected" + uiState.hasRecommendation && uiState.recommendedMode == "virtual_display" && + uiState.virtualDisplayAllowed -> "Recommended" + uiState.virtualDisplayAllowed -> "Available" + else -> "Unavailable" + }, ) } - profileSummary?.let { - LaunchProfileSummaryInline( - summary = it, - onRetryHighFps = onRetryHighFps + if (uiState.showLaunchOptionsButton) { + NovaSteamChoiceRow( + label = launchOptionsLabel, + caption = stringResource(R.string.nova_game_detail_more_settings_caption), + enabled = true, + onClick = onLaunchOptions, ) } - - NovaActionButton( - text = resetProfileLabel, - onClick = onResetProfile, - modifier = Modifier - .fillMaxWidth() - .padding(top = 8.dp), - enabled = !resetProfileWorking, - contentDescription = resetProfileLabel, - minHeight = 36.dp, - cornerRadius = 10.dp, - fontSize = 11.sp, - contentPadding = PaddingValues(horizontal = 9.dp, vertical = 7.dp) - ) } } @@ -1262,6 +1143,7 @@ internal fun LaunchProfilePrimaryNotice( Column( modifier = Modifier .fillMaxWidth() + .padding(horizontal = NovaGameDetailInset) .padding(top = 8.dp) .clip(RoundedCornerShape(12.dp)) .background(toneColor.copy(alpha = 0.14f)) @@ -1274,9 +1156,12 @@ internal fun LaunchProfilePrimaryNotice( ) { NovaBadge( text = badgeLabel, - color = toneColor, - backgroundColor = surfaces.control.copy(alpha = 0.72f * LocalNovaMenuOpacityScale.current), - borderColor = toneColor.copy(alpha = 0.35f), + // A badge is a surface, not media: translucent control over a ground + // already tinted with this tone put amber on amber. Opaque tone, with + // ink picked from the tone itself, reads in every theme. + color = if (toneColor.luminance() > 0.5f) Color.Black else Color.White, + backgroundColor = toneColor, + borderColor = Color.Transparent, fontWeight = FontWeight.SemiBold ) Text( @@ -1331,122 +1216,6 @@ internal fun LaunchProfilePrimaryNotice( } } -@Composable -private fun LaunchProfileSummaryInline( - summary: NovaLaunchProfileSummary, - onRetryHighFps: () -> Unit -) { - val colors = LocalNovaComposeColors.current - Column( - modifier = Modifier - .fillMaxWidth() - .padding(top = 10.dp) - .semantics { contentDescription = "Launch profile summary" } - ) { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 8.dp) - .heightIn(min = 1.dp, max = 1.dp) - .background(colors.divider.copy(alpha = 0.55f)) - ) - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = "Launch Profile", - color = colors.accent, - fontSize = 12.sp, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - ProfileSummaryText(summary.selectedLine, topPadding = 4) - ProfileSummaryText(summary.requestedLine) - ProfileSummaryText(summary.limitingLine) - ProfileSummaryText(summary.reasonLine) - } - } - - if (summary.historyLines.isNotEmpty()) { - Text( - text = summary.historyLines.first(), - modifier = Modifier.padding(top = 4.dp), - color = colors.textMuted, - fontSize = 10.sp, - lineHeight = 13.sp, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - } else { - ProfileSummaryText(summary.freshnessLine) - } - - if (summary.showRetryHighFps) { - NovaActionButton( - text = summary.retryHighFpsLabel, - onClick = onRetryHighFps, - modifier = Modifier - .fillMaxWidth() - .padding(top = 8.dp), - contentDescription = summary.retryHighFpsLabel, - minHeight = 36.dp, - cornerRadius = 8.dp, - fontSize = 11.sp, - contentPadding = PaddingValues(horizontal = 10.dp, vertical = 7.dp) - ) - } - } -} - -@Composable -private fun LaunchModeChoicePill( - label: String, - status: String, - recommended: Boolean, - selected: Boolean, - unavailable: Boolean, - onClick: () -> Unit, - modifier: Modifier = Modifier -) { - val colors = LocalNovaComposeColors.current - val statusColor = when { - unavailable -> colors.warning - selected || recommended -> colors.accent - else -> colors.textMuted - } - - NovaFocusableCard( - modifier = modifier.heightIn(min = 52.dp), - onClick = onClick, - enabled = !unavailable, - contentDescription = "$label. $status", - contentPadding = PaddingValues(horizontal = 10.dp, vertical = 8.dp) - ) { - Column { - Text( - text = label, - color = colors.textPrimary, - fontSize = 12.sp, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Text( - text = status, - modifier = Modifier.padding(top = 3.dp), - color = statusColor, - fontSize = 10.sp, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - } -} - @Composable private fun ProfileSummaryText(text: String, topPadding: Int = 3) { if (text.isBlank()) return @@ -1688,40 +1457,13 @@ private fun SteamLaunchModeCard( ) { if (!visible) return - val colors = LocalNovaComposeColors.current - NovaFocusableCard( - modifier = Modifier - .fillMaxWidth() - .padding(start = 14.dp, end = 14.dp, top = 10.dp) - .heightIn(min = 58.dp), + NovaSteamChoiceRow( + label = label, + caption = caption, + enabled = true, onClick = onClick, - contentDescription = "$label. $modeLabel. $caption", - contentPadding = PaddingValues(start = 12.dp, top = 9.dp, end = 12.dp, bottom = 9.dp) - ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = label, - color = colors.textPrimary, - fontSize = 12.sp, - fontWeight = FontWeight.SemiBold - ) - Text( - text = caption, - modifier = Modifier.padding(top = 2.dp), - color = if (warning) colors.warning else colors.textSecondary, - fontSize = 9.sp, - lineHeight = 12.sp, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - } - NovaBadge(text = modeLabel, color = if (warning) colors.warning else colors.textSecondary) - } - } + value = modeLabel, + ) } @Composable @@ -1730,61 +1472,44 @@ private fun MangoHudPassiveStatus( caption: String, warning: Boolean ) { - val colors = LocalNovaComposeColors.current - val surfaces = LocalNovaLibrarySurfaces.current - Row( - modifier = Modifier - .fillMaxWidth() - .padding(start = 14.dp, end = 14.dp, top = 8.dp) - .semantics { contentDescription = "$label. $caption" }, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - NovaBadge( - text = label, - color = if (warning) colors.warning else colors.textSecondary, - backgroundColor = surfaces.control.copy(alpha = 0.56f * LocalNovaMenuOpacityScale.current), - borderColor = if (warning) colors.warning.copy(alpha = 0.44f) else surfaces.tileBorder, - fontSize = 10.sp, - contentPadding = PaddingValues(horizontal = 9.dp, vertical = 4.dp) - ) - Text( - text = caption, - modifier = Modifier.weight(1f), - color = colors.textMuted, - fontSize = 10.sp, - lineHeight = 13.sp, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - } + // A readout, not an action: same row, no chevron to imply otherwise. + NovaSteamChoiceRow( + label = label, + caption = caption, + enabled = !warning, + ) } @Composable private fun InsightCard(card: NovaGameDetailInsightCard) { val colors = LocalNovaComposeColors.current - NovaDetailPanel( + Box( modifier = Modifier .fillMaxWidth() - .padding(start = 14.dp, end = 14.dp, top = 10.dp), - accent = !card.isWarning, - warning = card.isWarning, - contentPadding = PaddingValues(12.dp) + .padding(horizontal = NovaGameDetailInset) + .padding(top = 12.dp, bottom = 2.dp), ) { Column { - Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = card.label, + color = if (card.isWarning) colors.warning else colors.accent, + fontSize = if (card.isWarning) 13.sp else 14.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + if (card.source.isNotBlank()) { + // Six facts joined by separators is a metadata line, not a tag; in a chip + // it could only ellipsise, so it wraps under the title instead. Text( - text = card.label, - color = if (card.isWarning) colors.warning else colors.accent, - fontSize = if (card.isWarning) 13.sp else 14.sp, - fontWeight = FontWeight.SemiBold, - maxLines = 1, + text = card.source, + modifier = Modifier.padding(top = 2.dp), + color = colors.textMuted, + fontSize = 10.sp, + lineHeight = 13.sp, + maxLines = 2, overflow = TextOverflow.Ellipsis ) - if (card.source.isNotBlank()) { - Spacer(modifier = Modifier.width(8.dp)) - NovaBadge(text = card.source, color = colors.textMuted) - } } Text( text = card.settings, @@ -1803,7 +1528,7 @@ private fun InsightCard(card: NovaGameDetailInsightCard) { color = colors.textMuted, fontSize = 10.sp, lineHeight = 13.sp, - maxLines = 3, + maxLines = 5, overflow = TextOverflow.Ellipsis ) } diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt new file mode 100644 index 00000000..5a84c9fd --- /dev/null +++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt @@ -0,0 +1,613 @@ +package com.papi.nova.ui + +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.focusable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeContent +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.composed +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.em +import androidx.compose.ui.unit.sp +import kotlinx.coroutines.delay +import com.papi.nova.R +import com.papi.nova.ui.compose.LocalNovaComposeColors +import com.papi.nova.ui.compose.LocalNovaLibrarySurfaces +import com.papi.nova.ui.compose.NovaControllerHint +import com.papi.nova.ui.compose.NovaControllerHintBar + +/** The three ways a launch can go when Polaris reports desktop Steam active. */ +internal enum class NovaSteamLaunchChoice { + PRIVATE_STREAM, + MIRROR_DESKTOP, + CLOSE_STEAM_THEN_PRIVATE, +} + +/** + * A drill-in that sits beside the game rather than on top of it. The header is pinned and + * the body scrolls, so focus drives the scroll rather than the reverse. + */ +@Composable +internal fun NovaGameDetailPanel( + eyebrow: String, + headline: String, + readout: String, + scrollState: ScrollState, + onDismiss: () -> Unit, + content: @Composable () -> Unit, +) { + val colors = LocalNovaComposeColors.current + val surfaces = LocalNovaLibrarySurfaces.current + + BoxWithConstraints( + modifier = Modifier + .fillMaxSize() + .background(NovaGameDetailScrim.copy(alpha = NOVA_DETAIL_SCRIM_ALPHA)) + // The dimmed area beside the panel is the game you came from, so tapping it + // is the same gesture as pressing back. + .novaDismissOnTap(onDismiss) + .testTag("nova-game-detail-scrim"), + ) { + // The panel exists so the game stays visible beside what you are changing. In + // portrait there is nothing to sit beside, so it takes the window instead of + // squeezing a phone-width column inside a phone. + val widthFraction = if (maxHeight > maxWidth) 1f else NOVA_DETAIL_PANEL_WIDTH_FRACTION + val shortViewport = maxHeight < NOVA_DETAIL_SHORT_VIEWPORT + Column( + modifier = Modifier + .align(Alignment.CenterEnd) + .fillMaxHeight() + .fillMaxWidth(widthFraction) + // Translucent, so the game reads underneath and the panel is a layer over + // it rather than another screen. Separation comes from the outside scrim. + .background(colors.window.copy(alpha = NOVA_DETAIL_PANEL_ALPHA)) + .background(surfaces.panel) + // Taps inside the panel are not taps outside it. + .novaDismissOnTap {} + // Vertical only. A cutout must not eat text, but it need not stop a row + // background from reaching the edge it is drawn against. + .windowInsetsPadding(WindowInsets.safeContent.only(WindowInsetsSides.Vertical)) + .padding(vertical = if (shortViewport) 10.dp else 20.dp) + .testTag("nova-game-detail-panel"), + ) { + NovaGameDetailDestinationHeader( + eyebrow = eyebrow, + headline = headline, + readout = readout, + compact = shortViewport, + onDismiss = onDismiss, + ) + Column( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .novaFadeAtCut() + .novaHoldsFirstFocus() + .verticalScroll(scrollState), + content = { content() }, + ) + NovaGameDetailDestinationHints() + } + } +} + +/** + * Takes focus for its first focusable child once it has been laid out. Without this a + * destination opens with focus left behind on the Overview, so the d-pad has nothing to + * move. The delay is the same one the library uses: the request only lands after layout. + */ +@Composable +private fun Modifier.novaHoldsFirstFocus(): Modifier { + val requester = remember { FocusRequester() } + LaunchedEffect(Unit) { + delay(NOVA_DETAIL_FOCUS_SETTLE_MS) + runCatching { requester.requestFocus() } + } + return focusRequester(requester).focusGroup() +} + +/** + * Dissolves the last band of a scrolling body, so what passes under the hint bar reads + * as continuing rather than as clipped. It erases content alpha instead of painting a + * ground: the panel is translucent, and a solid band would stripe window colour across + * the artwork showing through it. + */ +private fun Modifier.novaFadeAtCut(): Modifier = this + .graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen } + .drawWithContent { + drawContent() + val fade = NOVA_DETAIL_BOTTOM_FADE.toPx().coerceAtMost(size.height) + drawRect( + brush = Brush.verticalGradient( + colors = listOf(Color.Black, Color.Transparent), + startY = size.height - fade, + endY = size.height, + ), + topLeft = Offset(0f, size.height - fade), + size = Size(size.width, fade), + blendMode = BlendMode.DstIn, + ) + } + +/** A tap target that swallows the gesture, with no ripple to imply a button. */ +private fun Modifier.novaDismissOnTap(onDismiss: () -> Unit): Modifier = composed { + clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onDismiss, + ) +} + +/** + * A drill-in that needs the window. Used by Artwork, whose studio lays itself out as a + * Row of weighted Columns and cannot fold into a panel. + */ +@Composable +internal fun NovaGameDetailFullScreen( + eyebrow: String, + headline: String, + scrollState: ScrollState, + onDismiss: () -> Unit, + content: @Composable () -> Unit, +) { + val colors = LocalNovaComposeColors.current + val surfaces = LocalNovaLibrarySurfaces.current + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + val shortViewport = maxHeight < NOVA_DETAIL_SHORT_VIEWPORT + Column( + modifier = Modifier + .fillMaxSize() + // Solid: there is no outside here, so translucency would only print the + // Overview through the studio rather than reveal anything new. + .background(colors.window) + .background(surfaces.panel) + .windowInsetsPadding(WindowInsets.safeContent) + .padding( + horizontal = NovaGameDetailInset, + vertical = if (shortViewport) 10.dp else 20.dp, + ) + .testTag("nova-game-detail-fullscreen"), + ) { + NovaGameDetailDestinationHeader( + eyebrow = eyebrow, + headline = headline, + readout = "", + compact = shortViewport, + onDismiss = onDismiss, + ) + Column( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .novaFadeAtCut() + .novaHoldsFirstFocus() + .verticalScroll(scrollState), + content = { content() }, + ) + NovaGameDetailDestinationHints() + } + } +} + +/** Every destination says how to act and how to get back. */ +@Composable +private fun NovaGameDetailDestinationHints() { + NovaControllerHintBar( + hints = listOf( + NovaControllerHint( + key = stringResource(R.string.nova_controller_hint_a), + label = stringResource(R.string.nova_controller_hint_select), + ), + NovaControllerHint( + key = stringResource(R.string.nova_controller_hint_b), + label = stringResource(R.string.nova_controller_hint_back), + ), + ), + compact = true, + modifier = Modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.safeContent.only(WindowInsetsSides.Horizontal)) + .padding(horizontal = NovaGameDetailInset) + .padding(top = 10.dp), + ) +} + +@Composable +private fun NovaGameDetailDestinationHeader( + eyebrow: String, + headline: String, + readout: String, + compact: Boolean = false, + onDismiss: () -> Unit = {}, +) { + val colors = LocalNovaComposeColors.current + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.safeContent.only(WindowInsetsSides.Horizontal)) + .padding(horizontal = NovaGameDetailInset) + .padding(bottom = if (compact) 6.dp else 14.dp), + ) { + Column(modifier = Modifier.weight(1f)) { + if (!compact) { + Text( + text = eyebrow, + color = colors.textMuted, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 0.22.em, + ) + } + Text( + text = if (compact) "$eyebrow · $headline" else headline, + color = colors.textPrimary, + fontSize = if (compact) 17.sp else 27.sp, + fontWeight = FontWeight.Bold, + maxLines = if (compact) 1 else 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = if (compact) 0.dp else 3.dp), + ) + if (readout.isNotBlank() && !compact) { + Text( + text = readout, + color = colors.textSecondary, + fontSize = 11.sp, + letterSpacing = 0.10.em, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 5.dp), + ) + } + } + // Portrait and the studio have no outside to tap, so the way out is always here. + NovaGameDetailCloseControl(onDismiss) + } +} + +/** The touch equivalent of back, for the destinations that fill the window. */ +@Composable +private fun NovaGameDetailCloseControl(onDismiss: () -> Unit) { + val colors = LocalNovaComposeColors.current + val surfaces = LocalNovaLibrarySurfaces.current + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .padding(start = 12.dp) + .clip(RoundedCornerShape(NovaGameDetailCornerRadius)) + .background(surfaces.control) + .border(1.dp, colors.divider.copy(alpha = 0.6f), RoundedCornerShape(NovaGameDetailCornerRadius)) + .novaDismissOnTap(onDismiss) + .padding(horizontal = 12.dp, vertical = 7.dp) + .testTag("nova-game-detail-close"), + ) { + Text( + text = stringResource(R.string.nova_game_detail_close), + color = colors.textSecondary, + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + ) + } +} + +/** + * Divides what you read from what you do. The sheet presented both as one list, so a + * readout like "MangoHUD: On" sat in the same shape as "Reset profile" — one is a + * statement, the other has consequences. + */ +@Composable +internal fun NovaGameDetailGroupLabel(text: String) { + val colors = LocalNovaComposeColors.current + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.safeContent.only(WindowInsetsSides.Horizontal)) + .padding(horizontal = NovaGameDetailInset) + .padding(top = 16.dp, bottom = 6.dp), + ) { + Text( + text = text.uppercase(), + color = colors.textMuted, + fontSize = 8.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 0.22.em, + ) + Box( + modifier = Modifier + .weight(1f) + .height(1.dp) + .background(colors.divider.copy(alpha = 0.5f)), + ) + } +} + +/** Retry and reset: the two things in Tune that change something rather than report it. */ +@Composable +internal fun LaunchProfileSummaryActions( + summary: NovaLaunchProfileSummary?, + resetProfileLabel: String, + resetProfileWorking: Boolean, + onRetryHighFps: () -> Unit, + onResetProfile: () -> Unit, +) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth(), + ) { + if (summary?.showRetryHighFps == true) { + NovaSteamChoiceRow( + label = summary.retryHighFpsLabel.ifBlank { + stringResource(R.string.nova_library_retry_high_fps) + }, + caption = "", + enabled = true, + onClick = onRetryHighFps, + ) + } + NovaSteamChoiceRow( + label = resetProfileLabel, + caption = "", + enabled = !resetProfileWorking, + onClick = onResetProfile, + ) + } +} + +/** + * The desktop-Steam choice, as rows in Launch mode rather than a sheet over the artwork. + * A blocked option stays visible and inert: hiding it loses the reason it is blocked, + * which is the part worth reading. + */ +@Composable +internal fun NovaDesktopSteamLaunchDecisionRows( + decision: NovaDesktopSteamLaunchDecision, + onChoice: (NovaSteamLaunchChoice) -> Unit, +) { + val colors = LocalNovaComposeColors.current + + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth().testTag("nova-game-detail-steam-decision"), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(NovaGameDetailCornerRadius)) + .background(colors.warning.copy(alpha = 0.13f)) + .border( + 1.dp, + colors.warning.copy(alpha = 0.46f), + RoundedCornerShape(NovaGameDetailCornerRadius), + ) + .padding(horizontal = 12.dp, vertical = 10.dp), + ) { + Text( + text = stringResource(R.string.nova_desktop_steam_title), + color = colors.textPrimary, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = decision.reason.ifBlank { + stringResource(R.string.nova_desktop_steam_message) + }, + color = colors.textSecondary, + fontSize = 11.sp, + lineHeight = 15.sp, + modifier = Modifier.padding(top = 4.dp), + ) + } + + NovaSteamChoiceRow( + label = stringResource(R.string.nova_desktop_steam_private_stream), + caption = decision.privateStreamUnavailableReason, + enabled = decision.privateStreamEnabled, + onClick = { onChoice(NovaSteamLaunchChoice.PRIVATE_STREAM) }, + ) + if (decision.forcePrivateAfterSteamCloseEnabled) { + NovaSteamChoiceRow( + label = decision.forcePrivateAfterSteamCloseLabel.ifBlank { + stringResource(R.string.nova_desktop_steam_force_private) + }, + caption = stringResource(R.string.nova_desktop_steam_force_private_caption), + enabled = true, + onClick = { onChoice(NovaSteamLaunchChoice.CLOSE_STEAM_THEN_PRIVATE) }, + ) + } + NovaSteamChoiceRow( + label = stringResource(R.string.nova_desktop_steam_mirror_desktop), + caption = stringResource(R.string.nova_desktop_steam_mirror_caption), + enabled = decision.mirrorDesktopEnabled, + onClick = { onChoice(NovaSteamLaunchChoice.MIRROR_DESKTOP) }, + ) + } +} + +/** + * One row of a drawer. Full bleed rather than a card: the panel already supplies the + * inset, so a rounded box inside it only adds air. The value sits at the right in + * tabular figures, and focus is an inset bar and a tint, so the row never moves. + */ +@Composable +internal fun NovaSteamChoiceRow( + label: String, + caption: String, + enabled: Boolean, + onClick: (() -> Unit)? = null, + value: String = "", +) { + val colors = LocalNovaComposeColors.current + var focused by remember { mutableStateOf(false) } + val actionable = onClick != null && enabled + val accentBar = colors.accent + val hairline = colors.divider.copy(alpha = 0.45f) + val barWidth = NOVA_DETAIL_ROW_FOCUS_BAR + // The accent is light on a dark surface and dark on a light one, so the same alpha + // is a whisper in one theme and an inverted block in the other. Scale it by the + // polarity; the bar, not the fill, is what says this row has focus. + // + // Polarity comes from the text rather than colors.window, because under Portable + // Chrome the panel takes its lightness from surfaces.panel layered over the window, + // so the window is the wrong ground to ask and the tint stayed at full strength. + val tint = colors.accent.copy( + alpha = if (colors.textPrimary.luminance() < 0.5f) 0.07f else 0.16f, + ) + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = NOVA_DETAIL_ROW_MIN_HEIGHT) + .onFocusChanged { focused = it.isFocused || it.hasFocus } + .then( + if (actionable) { + Modifier.clickable(role = Role.Button) { onClick?.invoke() } + } else { + Modifier + } + ) + // Explicit, like every other focusable in the app: clickable alone did not + // register the row as a focus target and the d-pad had nothing to reach. + .focusable(enabled = actionable) + .background(if (focused && actionable) tint else Color.Transparent) + .drawBehind { + if (focused && actionable) { + drawRect(color = accentBar, size = Size(barWidth.toPx(), size.height)) + } + drawRect( + color = hairline, + topLeft = Offset(0f, size.height - 1f), + size = Size(size.width, 1f), + ) + } + .windowInsetsPadding(WindowInsets.safeContent.only(WindowInsetsSides.Horizontal)) + .padding(start = NovaGameDetailInset, end = NovaGameDetailInset, top = 9.dp, bottom = 9.dp) + .semantics { contentDescription = if (value.isBlank()) label else "$label. $value" }, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = label, + color = if (enabled) colors.textPrimary else colors.textMuted, + fontSize = 15.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (caption.isNotBlank()) { + Text( + text = caption, + color = colors.textMuted, + fontSize = 11.sp, + lineHeight = 14.sp, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 2.dp), + ) + } + } + if (value.isNotBlank()) { + Text( + text = value, + color = if (enabled) colors.textSecondary else colors.textMuted, + fontSize = 15.sp, + fontWeight = FontWeight.Medium, + // a value read against other values, so the digits line up + style = LocalTextStyle.current.copy(fontFeatureSettings = "tnum"), + maxLines = 1, + modifier = Modifier.padding(start = 12.dp), + ) + } + if (actionable) { + Text( + text = "\u203a", + color = colors.textMuted, + fontSize = 17.sp, + modifier = Modifier.padding(start = 10.dp, end = 4.dp), + ) + } + } +} + +/** 438dp of an 832dp landscape shell, as drawn. */ +private const val NOVA_DETAIL_PANEL_WIDTH_FRACTION = 0.53f + +/** Every row is at least a full action's worth of height. */ +private val NOVA_DETAIL_ROW_MIN_HEIGHT = 48.dp + +/** The focused row grows a bar at its edge instead of a border that moves it. */ +private val NOVA_DETAIL_ROW_FOCUS_BAR = 3.dp + +/** The body dissolves over this much before the hint bar, marking the cut. */ +private val NOVA_DETAIL_BOTTOM_FADE = 52.dp + +/** + * A scrim is a shadow, not a surface, so it does not follow the theme. Painting it in + * the window colour turned into a white veil under Portable Chrome. + */ +private val NovaGameDetailScrim = Color.Black + +/** Enough of the game stays visible for the panel to read as a layer over it. */ +private const val NOVA_DETAIL_SCRIM_ALPHA = 0.58f + +/** Translucent enough to show artwork, opaque enough to keep body text legible. */ +private const val NOVA_DETAIL_PANEL_ALPHA = 0.80f + +/** Long enough for the body to be laid out, so the focus request has a target. */ +private const val NOVA_DETAIL_FOCUS_SETTLE_MS = 75L + +/** Below this a phone in landscape has no height to spare for chrome. */ +private val NOVA_DETAIL_SHORT_VIEWPORT = 500.dp diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt new file mode 100644 index 00000000..e67b641d --- /dev/null +++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt @@ -0,0 +1,882 @@ +package com.papi.nova.ui + +import android.widget.ImageView +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.focusable +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeContent +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.key +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.em +import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView +import com.papi.nova.R +import com.papi.nova.api.PolarisApiClient +import com.papi.nova.shared.polaris.model.PolarisGame +import com.papi.nova.ui.compose.LocalNovaComposeColors +import com.papi.nova.ui.compose.LocalNovaLibrarySurfaces +import com.papi.nova.ui.compose.NovaActionButton +import com.papi.nova.ui.compose.NovaControllerHint +import com.papi.nova.ui.compose.NovaControllerHintBar + +/** Where the detail window currently is. Back unwinds one level before leaving. */ +internal enum class NovaGameDetailDestination { OVERVIEW, LAUNCH_MODE, TUNE, ARTWORK } + +/** Content insets shared by the Overview and the destinations that sit beside it. */ +internal val NovaGameDetailInset = 28.dp +internal val NovaGameDetailFloor = 58.dp + +/** Every focusable control clears the accessible target floor. */ +internal val NovaGameDetailActionHeight = 48.dp + +/** Matches the library's surface radius; the sharp edge was a deliberate choice there. */ +internal val NovaGameDetailCornerRadius = 8.dp + +/** + * The landing screen of the detail window. + * + * The artwork is the subject and nothing sits on it in a card: the content block rests on + * the scrim floor in one reading order — who the game is, a hairline, what pressing the + * primary action will do, then what you can do. Layout is complete before the artwork + * arrives, so a slow or failed load changes the backdrop and moves nothing. + */ +@Composable +internal fun NovaGameDetailOverview( + uiState: NovaGameDetailUiState, + apiClient: PolarisApiClient, + playLabel: String, + lastPlayedText: String?, + sourceLabel: String, + optimizationState: NovaGameDetailOptimizationState, + reviewExpanded: Boolean, + showLaunchModeAction: Boolean, + logoAvailable: Boolean, + logoPresentationKey: String, + logoLoader: (ImageView) -> Unit, + logoContentDescription: String, + playFocusRequester: FocusRequester, + onPrimaryLaunch: () -> Unit, + onRetryHighFps: () -> Unit, + onResetProfile: () -> Unit, + onDestination: (NovaGameDetailDestination) -> Unit, + activeSession: NovaLibraryActiveSessionUiState?, + onResumeSession: () -> Unit, + onEndSession: () -> Unit, + modifier: Modifier = Modifier, +) { + val colors = LocalNovaComposeColors.current + val game = uiState.game + + BoxWithConstraints(modifier = modifier.fillMaxSize().testTag("nova-game-detail-overview")) { + val portrait = maxHeight > maxWidth + + if (portrait) { + // A 3:1 hero cropped to a phone's aspect shows a slice, not a subject, so it + // gets a 16:9 band and dissolves into the ground rather than filling behind. + NovaLibraryCinematicBackdrop( + game = game, + apiClient = apiClient, + strength = 1f, + modifier = Modifier + .align(Alignment.TopStart) + .fillMaxWidth() + .aspectRatio(16f / 9f) + .novaFadeToGround(colors.window), + ) + } else { + NovaLibraryCinematicBackdrop(game = game, apiClient = apiClient, strength = 1f) + } + + Column( + modifier = Modifier + .align(if (portrait) Alignment.TopStart else Alignment.BottomStart) + .fillMaxWidth() + .then(if (portrait) Modifier.padding(top = 176.dp) else Modifier) + .windowInsetsPadding(WindowInsets.safeContent) + .padding(start = NovaGameDetailInset, end = NovaGameDetailInset, bottom = 10.dp), + ) { + NovaGameDetailTitle( + game = game, + logoAvailable = logoAvailable, + logoPresentationKey = logoPresentationKey, + logoLoader = logoLoader, + logoContentDescription = logoContentDescription, + ) + + Text( + text = novaGameDetailIdentityLine(sourceLabel, lastPlayedText, game).uppercase(), + color = colors.textSecondary, + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = 0.17.em, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 11.dp), + ) + + // The hairline divides names from numbers: identity above, machine state below. + Box( + modifier = Modifier + .padding(top = 11.dp) + .width(330.dp) + .height(1.dp) + .background( + Brush.horizontalGradient( + colorStops = arrayOf( + 0.0f to colors.accent, + 0.30f to colors.accent.copy(alpha = 0.44f), + 0.62f to colors.textMuted.copy(alpha = 0.16f), + 1.0f to Color.Transparent, + ), + ), + ), + ) + + // A duration is a number, so it sits below the hairline with the rest of them. + // The concept's gauge needs How Long To Beat to have something to measure + // against; until that exists it draws the played figure alone, and a game no + // launcher owns draws nothing rather than claiming nobody has played it. + NovaGameDetailBeatGauge( + gameName = uiState.game.name, + playTime = uiState.game.playTime, + beatTime = uiState.game.beatTime, + ) + + NovaGameDetailStatusLine( + uiState = uiState, + optimizationState = optimizationState, + modifier = Modifier.padding(top = 11.dp), + ) + + if (reviewExpanded) { + LaunchProfileReviewNotice( + optimizationState = optimizationState, + modifier = Modifier.padding(top = 10.dp), + ) + } + + NovaGameDetailActions( + stacked = portrait, + uiState = uiState, + optimizationState = optimizationState, + playLabel = playLabel, + reviewExpanded = reviewExpanded, + showLaunchModeAction = showLaunchModeAction, + playFocusRequester = playFocusRequester, + onPrimaryLaunch = onPrimaryLaunch, + onRetryHighFps = onRetryHighFps, + onResetProfile = onResetProfile, + onDestination = onDestination, + activeSession = activeSession, + onResumeSession = onResumeSession, + onEndSession = onEndSession, + modifier = Modifier.padding(top = 16.dp), + ) + + if (!portrait) { + NovaGameDetailFooter(modifier = Modifier.fillMaxWidth().padding(top = 14.dp)) + } + } + + // On a tall screen the floor belongs at the bottom, not trailing the actions. + if (portrait) { + NovaGameDetailFooter( + modifier = Modifier + .align(Alignment.BottomStart) + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.safeContent) + .padding(horizontal = NovaGameDetailInset, vertical = 10.dp), + ) + } + + } +} + +/** + * The floor: what the buttons do on the left, the Polaris mark on the right. Borderless, + * because a bordered container here would be one more box on a screen whose point is that + * nothing sits on the artwork in a box. + */ +@Composable +private fun NovaGameDetailFooter(modifier: Modifier = Modifier) { + val colors = LocalNovaComposeColors.current + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier.testTag("nova-game-detail-footer"), + ) { + novaGameDetailOverviewHints().forEach { hint -> + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.padding(end = 18.dp), + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(20.dp) + .clip(RoundedCornerShape(percent = 50)) + .background(colors.accent.copy(alpha = 0.22f)), + ) { + Text(hint.key, color = colors.textPrimary, fontSize = 9.sp, fontWeight = FontWeight.Bold) + } + Text(hint.label, color = colors.textSecondary, fontSize = 11.sp) + } + } + Spacer(modifier = Modifier.weight(1f)) + Text( + text = stringResource(R.string.nova_polaris_wordmark), + color = colors.textMuted, + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 0.20.em, + ) + } +} + +/** + * Curated logo artwork replaces the title outright when it is ready at first composition. + * A logo that arrives later is ignored: swapping a settled title for one is a visible jump, + * and the title is never wrong. + */ +@Composable +private fun NovaGameDetailTitle( + game: PolarisGame, + logoAvailable: Boolean, + logoPresentationKey: String, + logoLoader: (ImageView) -> Unit, + logoContentDescription: String, +) { + if (logoAvailable) { + key(logoPresentationKey) { + AndroidView( + factory = { context -> + ImageView(context).apply { + scaleType = ImageView.ScaleType.FIT_START + contentDescription = logoContentDescription + logoLoader(this) + } + }, + modifier = Modifier + .sizeIn(maxWidth = 200.dp, maxHeight = 64.dp) + .semantics { contentDescription = logoContentDescription } + .testTag("nova-game-detail-logo"), + ) + } + } else { + Text( + text = game.name, + color = LocalNovaComposeColors.current.textPrimary, + fontSize = 38.sp, + fontWeight = FontWeight.Bold, + letterSpacing = (-0.03).em, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.testTag("nova-game-detail-title"), + ) + } +} + +/** + * What pressing the primary action will do, read as an instrument line rather than a chip: + * an indicator lamp, then mode and profile set with tabular figures. + */ +@Composable +private fun NovaGameDetailStatusLine( + uiState: NovaGameDetailUiState, + optimizationState: NovaGameDetailOptimizationState, + modifier: Modifier = Modifier, +) { + val colors = LocalNovaComposeColors.current + val summary = optimizationState.profileSummary + val limited = optimizationState.reviewRequired || + summary?.noticeTone == NovaLaunchProfileNoticeTone.WARNING + val lamp = when { + limited -> colors.warning + summary == null -> colors.textMuted + else -> colors.accent + } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(9.dp), + modifier = modifier.testTag("nova-game-detail-status"), + ) { + Box(modifier = Modifier.size(7.dp).clip(RoundedCornerShape(percent = 50)).background(lamp)) + Text( + text = novaGameDetailStatusText(uiState, summary).uppercase(), + color = colors.textPrimary, + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = 0.11.em, + // these are measurements, so the digits line up rather than dance + style = LocalTextStyle.current.copy( + fontFeatureSettings = "tnum", + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +/** + * The action lane. Play holds first focus; the rail is gated so it never grows a node that + * leads nowhere. While a review is expanded the lane becomes the review's own choices — + * the three buttons of the alert this replaces. + */ +@Composable +private fun NovaGameDetailActions( + stacked: Boolean, + uiState: NovaGameDetailUiState, + optimizationState: NovaGameDetailOptimizationState, + playLabel: String, + reviewExpanded: Boolean, + showLaunchModeAction: Boolean, + playFocusRequester: FocusRequester, + onPrimaryLaunch: () -> Unit, + onRetryHighFps: () -> Unit, + onResetProfile: () -> Unit, + onDestination: (NovaGameDetailDestination) -> Unit, + activeSession: NovaLibraryActiveSessionUiState?, + onResumeSession: () -> Unit, + onEndSession: () -> Unit, + modifier: Modifier = Modifier, +) { + val lane: @Composable (@Composable () -> Unit) -> Unit = { content -> + if (stacked) { + Column( + verticalArrangement = Arrangement.spacedBy(10.dp), + modifier = modifier.fillMaxWidth(), + content = { content() }, + ) + } else { + Row( + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = modifier, + content = { content() }, + ) + } + } + + // Stacked buttons sized to their own labels look ragged; in a column they share a width. + val itemWidth: Modifier = if (stacked) Modifier.fillMaxWidth() else Modifier + + lane { + // Precedence: someone else's session, then yours, then the ordinary launch. + // Launching over a session another device owns would take their display. + NovaGameDetailAction( + text = when { + activeSession?.watchOnly == true -> stringResource(R.string.nova_game_detail_watch) + activeSession != null -> stringResource(R.string.nova_game_detail_resume) + else -> playLabel + }, + onClick = if (activeSession != null) onResumeSession else onPrimaryLaunch, + enabled = uiState.playEnabled || activeSession != null, + primary = activeSession?.watchOnly != true, + glyph = stringResource(R.string.nova_controller_hint_a), + modifier = itemWidth + .focusRequester(playFocusRequester) + .testTag("nova-game-detail-primary"), + ) + + if (activeSession != null && !activeSession.watchOnly) { + NovaGameDetailAction( + text = stringResource(R.string.nova_game_detail_end_session), + onClick = onEndSession, + mark = "◼", + modifier = itemWidth, + ) + } + + if (reviewExpanded) { + if (optimizationState.profileSummary?.showRetryHighFps == true) { + NovaGameDetailAction( + text = stringResource(R.string.nova_library_retry_high_fps), + onClick = onRetryHighFps, + mark = "\u25B2", + modifier = itemWidth, + ) + } + NovaGameDetailAction( + text = stringResource(R.string.nova_library_reset_game_profile), + onClick = onResetProfile, + mark = "\u21BA", + modifier = itemWidth, + ) + } else { + if (showLaunchModeAction) { + NovaGameDetailAction( + text = stringResource(R.string.nova_library_launch_mode_title), + onClick = { onDestination(NovaGameDetailDestination.LAUNCH_MODE) }, + mark = "\u229E", + modifier = itemWidth, + ) + } + NovaGameDetailAction( + text = stringResource(R.string.nova_game_detail_tune), + onClick = { onDestination(NovaGameDetailDestination.TUNE) }, + mark = "\u2699", + modifier = itemWidth, + ) + NovaGameDetailAction( + text = stringResource(R.string.nova_artwork_studio_title), + onClick = { onDestination(NovaGameDetailDestination.ARTWORK) }, + mark = "\u25C8", + modifier = itemWidth, + ) + } + } +} + +/** + * How long this has been played, against how long it takes. + * + * Drawn as the concept draws it: the played figure bright at the left, the three + * estimates dimmer at the right and labelled so a number means something, and a bar + * whose full width is the completionist figure with the other two cut through it as + * notches — ground-coloured with a light ring, overhanging top and bottom, so they read + * as gaps in the bar rather than marks on top of it. + * + * The partial cases are the concept's own answers, because a gauge that guesses is worse + * than one that says less: + * + * - both bar, notches, figure and estimates + * - played only the figure alone; there is nothing to measure it against + * - estimate only "Not started" over an empty track + * - neither nothing, and the hairline above still separates identity from state + * - past the end the bar caps and the figure keeps counting, which is the true number + */ +@Composable +private fun NovaGameDetailBeatGauge( + gameName: String, + playTime: PolarisGame.PlayTime?, + beatTime: PolarisGame.BeatTime?, +) { + val colors = LocalNovaComposeColors.current + val surfaces = LocalNovaLibrarySurfaces.current + val uriHandler = LocalUriHandler.current + + val playedSeconds = playTime?.seconds?.takeIf { it > 0 } ?: 0L + val fullWidthSeconds = beatTime?.longestSeconds?.takeIf { it > 0 } ?: 0L + if (playedSeconds <= 0L && fullWidthSeconds <= 0L) { + return + } + + val figures = LocalTextStyle.current.copy(fontFeatureSettings = "tnum") + var estimateFocused by remember { mutableStateOf(false) } + + Column(modifier = Modifier.padding(top = 10.dp).testTag("nova-game-detail-played")) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.width(NOVA_GAUGE_WIDTH), + ) { + Text( + text = if (playedSeconds > 0L) { + val minutes = playedSeconds / 60 + if (minutes >= 60) { + stringResource(R.string.nova_game_detail_played_hours, minutes / 60) + } else { + stringResource(R.string.nova_game_detail_played_minutes, minutes) + } + } else { + stringResource(R.string.nova_game_detail_not_started) + }.uppercase(), + color = colors.textPrimary, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 0.12.em, + maxLines = 1, + style = figures, + ) + + Spacer(modifier = Modifier.weight(1f)) + + val parts = listOfNotNull( + beatTime?.mainSeconds?.takeIf { it > 0 } + ?.let { stringResource(R.string.nova_game_detail_beat_main, it / 3600) }, + beatTime?.extrasSeconds?.takeIf { it > 0 } + ?.let { stringResource(R.string.nova_game_detail_beat_extras, it / 3600) }, + beatTime?.completionistSeconds?.takeIf { it > 0 } + ?.let { stringResource(R.string.nova_game_detail_beat_complete, it / 3600) }, + ) + + if (parts.isNotEmpty()) { + val page = beatTime?.url.orEmpty() + val linked = page.isNotBlank() + val ring = if (estimateFocused && linked) colors.accent else Color.Transparent + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier + .clip(RoundedCornerShape(NOVA_GAUGE_CHIP_RADIUS)) + .background( + if (estimateFocused && linked) { + colors.accent.copy(alpha = 0.14f) + } else { + Color.Transparent + } + ) + .border(1.dp, ring, RoundedCornerShape(NOVA_GAUGE_CHIP_RADIUS)) + .then( + // A page makes this a control; without one it is a readout and + // has no business in the focus lane. + if (linked) { + Modifier + .onFocusChanged { estimateFocused = it.isFocused || it.hasFocus } + .clickable(role = Role.Button) { uriHandler.openUri(page) } + .focusable() + } else { + Modifier + } + ) + .padding(horizontal = 7.dp, vertical = 3.dp), + ) { + Text( + text = parts.joinToString(" · ").uppercase(), + color = colors.textSecondary, + fontSize = 10.sp, + fontWeight = FontWeight.Medium, + letterSpacing = 0.10.em, + maxLines = 1, + style = figures, + ) + if (linked) { + Text( + text = "\u2197", + color = if (estimateFocused) colors.accent else colors.textMuted, + fontSize = 10.sp, + ) + } + } + } + } + + if (fullWidthSeconds > 0L) { + val track = colors.textPrimary.copy(alpha = 0.10f) + val fillEnd = colors.accent + val fillStart = lerp(colors.accent, Color.White, 0.28f) + val notchInk = colors.window + val notchRing = colors.textPrimary.copy(alpha = 0.34f) + val played = (playedSeconds.toFloat() / fullWidthSeconds.toFloat()).coerceIn(0f, 1f) + val notches = listOfNotNull( + beatTime?.mainSeconds?.takeIf { it > 0 && it < fullWidthSeconds }, + beatTime?.extrasSeconds?.takeIf { it > 0 && it < fullWidthSeconds }, + ).map { it.toFloat() / fullWidthSeconds.toFloat() } + + Canvas( + modifier = Modifier + .padding(top = 6.dp) + .width(NOVA_GAUGE_WIDTH) + .height(NOVA_GAUGE_BAR + NOVA_GAUGE_NOTCH_OVERHANG * 2), + ) { + val barTop = NOVA_GAUGE_NOTCH_OVERHANG.toPx() + val barHeight = NOVA_GAUGE_BAR.toPx() + val radius = CornerRadius(barHeight / 2f, barHeight / 2f) + + drawRoundRect( + color = track, + topLeft = Offset(0f, barTop), + size = Size(size.width, barHeight), + cornerRadius = radius, + ) + if (played > 0f) { + drawRoundRect( + brush = Brush.horizontalGradient(listOf(fillStart, fillEnd)), + topLeft = Offset(0f, barTop), + size = Size(size.width * played, barHeight), + cornerRadius = radius, + ) + } + + val notchWidth = NOVA_GAUGE_NOTCH.toPx() + val ringWidth = notchWidth + 2f + notches.forEach { fraction -> + val centre = size.width * fraction + // The ring first, so the notch reads as a gap cut through the bar. + drawRoundRect( + color = notchRing, + topLeft = Offset(centre - ringWidth / 2f, 0f), + size = Size(ringWidth, size.height), + cornerRadius = CornerRadius(ringWidth / 2f, ringWidth / 2f), + ) + drawRoundRect( + color = notchInk, + topLeft = Offset(centre - notchWidth / 2f, 1f), + size = Size(notchWidth, size.height - 2f), + cornerRadius = CornerRadius(notchWidth / 2f, notchWidth / 2f), + ) + } + } + } + + // A fuzzy match that went wrong looks exactly like one that went right, so the + // name it actually found is shown whenever it is not plainly the same game. + val matched = beatTime?.matchedName.orEmpty() + if (matched.isNotBlank() && novaSameTitle(matched, gameName).not()) { + Text( + text = stringResource(R.string.nova_game_detail_matched_as, matched), + color = colors.textMuted, + fontSize = 10.sp, + letterSpacing = 0.06.em, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .padding(top = 5.dp) + .width(NOVA_GAUGE_WIDTH), + ) + } + } +} + +/** + * Whether two titles are the same game as far as a reader cares. + * + * Punctuation and case disagree constantly between a launcher and a catalogue, and + * saying so every time would bury the cases that matter under noise. + */ +private fun novaSameTitle(left: String, right: String): Boolean { + fun fold(value: String) = buildString { + value.forEach { if (it.isLetterOrDigit()) append(it.lowercaseChar()) } + } + return fold(left) == fold(right) +} + +/** Matches the hairline above it, so the two read as one column. */ +private val NOVA_GAUGE_WIDTH = 330.dp +private val NOVA_GAUGE_BAR = 4.dp +private val NOVA_GAUGE_NOTCH = 1.5.dp +/** The notches overhang the bar, which is what makes them read as cuts through it. */ +private val NOVA_GAUGE_NOTCH_OVERHANG = 2.dp +private val NOVA_GAUGE_CHIP_RADIUS = 5.dp + +/** Dissolves the hero band into the window colour instead of cutting against it. */ +private fun Modifier.novaFadeToGround(ground: Color): Modifier = drawWithContent { + drawContent() + drawRect( + brush = Brush.verticalGradient( + colorStops = arrayOf( + 0.62f to Color.Transparent, + 1.0f to ground, + ), + ), + ) +} + +/** + * One action in the lane. The primary carries the button it is bound to and an accent + * gradient; the rest are quiet, hairline-bordered and marked. Focus is a ring and a + * tint — never a scale or an offset, which is the contract the poster cards settled on. + */ +@Composable +private fun NovaGameDetailAction( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + primary: Boolean = false, + glyph: String? = null, + mark: String? = null, +) { + val colors = LocalNovaComposeColors.current + val surfaces = LocalNovaLibrarySurfaces.current + val interactionSource = remember { MutableInteractionSource() } + var focused by remember { mutableStateOf(false) } + val shape = RoundedCornerShape(NovaGameDetailCornerRadius) + + val background = if (primary && enabled) { + Brush.linearGradient( + listOf( + colors.accent, + lerp(colors.accent, Color.White, 0.28f), + lerp(colors.accent, Color.White, 0.62f), + ), + ) + } else { + SolidColor(surfaces.control.copy(alpha = 1f)) + } + val label = when { + primary && enabled -> colors.onAccent + enabled -> colors.textPrimary + else -> colors.textMuted + } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(9.dp), + modifier = modifier + .heightIn(min = NovaGameDetailActionHeight) + .clip(shape) + .background(background, shape) + .border( + width = if (focused) 2.dp else 1.dp, + color = if (focused) colors.accent else surfaces.tileBorder, + shape = shape, + ) + .onFocusChanged { focused = it.isFocused } + .clickable( + enabled = enabled, + interactionSource = interactionSource, + indication = null, + onClick = onClick, + ) + .semantics { contentDescription = text } + .padding(horizontal = 16.dp, vertical = 10.dp), + ) { + if (glyph != null) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(20.dp) + .clip(RoundedCornerShape(percent = 50)) + .background(colors.window.copy(alpha = 0.88f)), + ) { + Text( + text = glyph, + color = colors.textPrimary, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + ) + } + } + if (mark != null) { + Text(text = mark, color = label.copy(alpha = 0.62f), fontSize = 13.sp) + } + Text( + text = text, + color = label, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun LaunchProfileReviewNotice( + optimizationState: NovaGameDetailOptimizationState, + modifier: Modifier = Modifier, +) { + val colors = LocalNovaComposeColors.current + val summary = optimizationState.profileSummary + val detail = listOf( + summary?.noticeDetail, + summary?.noticeRecommendation, + summary?.reasonLine, + ).firstOrNull { !it.isNullOrBlank() } + ?: stringResource( + R.string.nova_library_preflight_review_message, + optimizationState.reviewReason.ifBlank { "fps_override" }, + ) + + Column( + modifier = modifier + .sizeIn(maxWidth = 520.dp) + .clip(RoundedCornerShape(NovaGameDetailCornerRadius)) + .background(colors.warning.copy(alpha = 0.13f)) + .border(1.dp, colors.warning.copy(alpha = 0.46f), RoundedCornerShape(NovaGameDetailCornerRadius)) + .padding(horizontal = 12.dp, vertical = 10.dp) + .testTag("nova-game-detail-review"), + ) { + Text( + text = stringResource(R.string.nova_library_preflight_review_title), + color = colors.textPrimary, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = detail, + color = colors.textSecondary, + fontSize = 11.sp, + lineHeight = 15.sp, + modifier = Modifier.padding(top = 5.dp), + ) + } +} + +/** Source, when it was last played, and its primary genre — who the game is. */ +private fun novaGameDetailIdentityLine( + sourceLabel: String, + lastPlayedText: String?, + game: PolarisGame, +): String = listOf(sourceLabel, lastPlayedText, game.genres.firstOrNull()) + .filter { !it.isNullOrBlank() } + .joinToString(" · ") + +/** Mode, profile and freshness — what the primary action will do. */ +private fun novaGameDetailStatusText( + uiState: NovaGameDetailUiState, + summary: NovaLaunchProfileSummary?, +): String { + return listOf( + uiState.hostStreamDisplayModeLabel.takeIf { uiState.playUsesVirtualDisplay }, + summary?.selectedLine, + summary?.limitingLine?.takeIf { it.isNotBlank() } ?: summary?.freshnessLine, + ).filter { !it.isNullOrBlank() }.joinToString(" · ") +} + +/** + * B unwinds a level, X reaches Tune. A is not repeated here: the primary action already + * carries it, and this window exists to remove duplication. + */ +@Composable +private fun novaGameDetailOverviewHints(): List = listOf( + NovaControllerHint( + key = stringResource(R.string.nova_controller_hint_b), + label = stringResource(R.string.nova_controller_hint_close), + ), +) diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailUiState.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailUiState.kt index a86ef644..c5f8c1e8 100644 --- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailUiState.kt +++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailUiState.kt @@ -11,6 +11,8 @@ data class NovaGameDetailUiState( val launchChoice: PolarisGame.LaunchModeChoice, val preferredMode: String, val recommendedMode: String, + /** Whether anything actually recommended it, rather than it echoing the preference. */ + val hasRecommendation: Boolean, val headlessAllowed: Boolean, val virtualDisplayAllowed: Boolean, val virtualDisplayUnavailable: Boolean, @@ -43,10 +45,22 @@ data class NovaGameDetailUiState( game: PolarisGame, defaultToVirtualDisplay: Boolean, clientSettings: PolarisClientSettings?, - profilePreference: String + profilePreference: String, + /** A mode chosen for this game on this client, which outranks the host default. */ + launchModeOverride: String? = null, ): NovaGameDetailUiState { val choice = game.resolveLaunchModeChoice(defaultToVirtualDisplay, clientSettings) + // Only a deliberate choice reaches here, so it answers before the host does. + // The contract's preferredMode stays below the host, as it means the app's own + // default rather than anyone's decision. + val chosen = launchModeOverride + ?.takeIf { it.isNotBlank() } + ?.let { PolarisGame.resolveLaunchMode(it, choice.headlessAllowed, choice.virtualDisplayAllowed) } val playMode = when { + chosen == "virtual_display" && choice.virtualDisplayAllowed && + !choice.virtualDisplayUnavailable -> "virtual_display" + chosen == "headless" && choice.headlessAllowed -> "headless" + choice.recommendedMode == "virtual_display" && choice.virtualDisplayAllowed -> "virtual_display" choice.recommendedMode == "headless" && choice.headlessAllowed -> "headless" choice.headlessAllowed -> "headless" @@ -84,6 +98,10 @@ data class NovaGameDetailUiState( launchChoice = choice, preferredMode = choice.preferredMode, recommendedMode = choice.recommendedMode, + // The resolver falls back to the preference when neither the host nor + // the contract recommends anything, so ask whether either did. + hasRecommendation = choice.hostDefaultMode.isNotBlank() || + !game.launchMode?.recommendedMode.isNullOrBlank(), headlessAllowed = choice.headlessAllowed, virtualDisplayAllowed = choice.virtualDisplayAllowed, virtualDisplayUnavailable = choice.virtualDisplayUnavailable, diff --git a/app/src/main/java/com/papi/nova/ui/NovaLaunchModeOverrides.kt b/app/src/main/java/com/papi/nova/ui/NovaLaunchModeOverrides.kt new file mode 100644 index 00000000..3db15b84 --- /dev/null +++ b/app/src/main/java/com/papi/nova/ui/NovaLaunchModeOverrides.kt @@ -0,0 +1,34 @@ +package com.papi.nova.ui + +import android.content.Context +import com.papi.nova.shared.polaris.model.PolarisGame + +/** + * Where the launch mode you choose for a game is remembered. + * + * It cannot live in the game's launch contract: preferredMode there means the app's own + * default and is deliberately outranked by the host's configured display mode, so a + * choice written into it is resolved away. This is a separate, higher answer to the same + * question — what this client should do for this game — and it is only ever written by + * choosing in the Launch Mode destination. + */ +object NovaLaunchModeOverrides { + + private const val PREFS_NAME = "nova_prefs" + private const val KEY_PREFIX = "launch_mode_override_" + + private fun key(game: PolarisGame): String = + KEY_PREFIX + game.id.ifBlank { game.appId.toString() } + + fun load(context: Context, game: PolarisGame): String? = + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .getString(key(game), null) + ?.takeIf { it.isNotBlank() } + + fun save(context: Context, game: PolarisGame, mode: String) { + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .edit() + .putString(key(game), mode) + .apply() + } +} 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 ddcd916e..bdeb2ead 100644 --- a/app/src/main/java/com/papi/nova/ui/NovaLibraryActivity.kt +++ b/app/src/main/java/com/papi/nova/ui/NovaLibraryActivity.kt @@ -712,6 +712,13 @@ class NovaLibraryActivity : NovaActivity() { return consumed } + private fun queryActiveSessionAsync(onResult: (NovaLibraryActiveSessionUiState?) -> Unit) { + lifecycleScope.launch { + val session = withContext(Dispatchers.IO) { runCatching { queryActiveSession() }.getOrNull() } + onResult(session) + } + } + private fun queryActiveSession(): NovaLibraryActiveSessionUiState? { return NovaLibraryActiveSessionUiState.from(apiClient.getSessionStatus()) } @@ -797,6 +804,14 @@ class NovaLibraryActivity : NovaActivity() { data.getStringExtra(NovaGameDetailActivity.EXTRA_RESULT_GAME) ?.let { PolarisGameJson.decode(it) } ?.let { updated -> allGames = allGames.map { if (it.id == updated.id) updated else it } } + when (data.getStringExtra(NovaGameDetailActivity.EXTRA_RESULT_SESSION)) { + // The window saw the session but cannot act on it: resuming and ending both + // need stream credentials that live here. + NovaGameDetailActivity.RESULT_SESSION_RESUME -> + queryActiveSessionAsync { session -> session?.let { resumeActiveSession(it) } } + NovaGameDetailActivity.RESULT_SESSION_END -> + queryActiveSessionAsync { session -> session?.let { endActiveSession(it) } } + } val launch = data.getStringExtra(NovaGameDetailActivity.EXTRA_RESULT_LAUNCH) ?: return val request = try { @@ -1287,6 +1302,7 @@ class NovaLibraryActivity : NovaActivity() { null -> Unit } }, + onOpenDetail = model.hero.game?.let { game -> { onOpenDetail(game) } }, onGameFocused = onGameFocused ) } @@ -1365,6 +1381,7 @@ class NovaLibraryActivity : NovaActivity() { null -> Unit } }, + onOpenDetail = model.hero.game?.let { game -> { onOpenDetail(game) } }, onGameFocused = onGameFocused ) } @@ -1520,6 +1537,12 @@ class NovaLibraryActivity : NovaActivity() { apiClient: PolarisApiClient, onPrimaryAction: () -> Unit, onSecondaryAction: (() -> Unit)? = null, + /** + * The card opens the game; the buttons do the thing. Without this the running + * game was the one entry whose detail could not be reached at all, because the + * whole card resumed and the grid omits it while a session is live. + */ + onOpenDetail: (() -> Unit)? = null, onGameFocused: (PolarisGame) -> Unit ) { val colors = LocalNovaComposeColors.current @@ -1562,7 +1585,7 @@ class NovaLibraryActivity : NovaActivity() { .onFocusChanged { focused = it.isFocused || it.hasFocus } - .combinedClickable(onClick = onPrimaryAction) + .combinedClickable(onClick = onOpenDetail ?: onPrimaryAction) .focusable() .padding(if (compact) 8.dp else 16.dp), horizontalArrangement = Arrangement.spacedBy(if (compact) 6.dp else 16.dp), diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 151b520e..9c400298 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -262,6 +262,28 @@ Version and client identity. %1$s System + Resume + Watch + End session + Tune + Where it runs + State + Actions + Insight + Close + Not started + Estimate for %1$s + Main %1$d h + Extras %1$d h + 100%% %1$d h + %1$d h played + %1$d min played + Streams without touching the physical desktop + A dedicated display on the host, at your resolution + Resolution, bitrate and codec for this launch + Optimization profile + How Polaris picks quality for this game + ✦ POLARIS A B X diff --git a/app/src/test/java/com/papi/nova/api/PolarisApiClientParsingTest.kt b/app/src/test/java/com/papi/nova/api/PolarisApiClientParsingTest.kt index 7b10f013..a37dbd38 100644 --- a/app/src/test/java/com/papi/nova/api/PolarisApiClientParsingTest.kt +++ b/app/src/test/java/com/papi/nova/api/PolarisApiClientParsingTest.kt @@ -991,4 +991,26 @@ class PolarisApiClientParsingTest { assertEquals("big-picture", body.getString("mode")) } + @Test + fun playTimeIsCarriedFromTheHostAndAbsentIsNotZero() { + val played = PolarisGameJsonAdapter.fromJson( + JSONObject( + """{id:abc,name:Control,play_time:{seconds:143520,source:steam,read_at:1754470000}}""" + ) + ) + assertEquals(143520L, played.playTime?.seconds) + assertEquals("steam", played.playTime?.source) + + // No launcher owns the answer: null, so the gauge can be omitted rather than + // drawn claiming nobody has played it. + val unowned = PolarisGameJsonAdapter.fromJson(JSONObject("""{id:abc,name:Control}""")) + assertNull(unowned.playTime) + + // Owned, but never played, is a different answer and keeps its object. + val untouched = PolarisGameJsonAdapter.fromJson( + JSONObject("""{id:abc,name:Control,play_time:{seconds:0,source:steam}}""") + ) + assertNotNull(untouched.playTime) + assertEquals(0L, untouched.playTime?.seconds) + } } 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 af2e2a7d..1b7fb68f 100644 --- a/app/src/test/java/com/papi/nova/ui/NovaArtworkStudioSourceGuardTest.kt +++ b/app/src/test/java/com/papi/nova/ui/NovaArtworkStudioSourceGuardTest.kt @@ -81,7 +81,9 @@ class NovaArtworkStudioSourceGuardTest { @Test fun detailSheetUsesArtworkStudioInsteadOfThePosterCentricFixMatchFlow() { val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") + - readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") + + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt") + + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.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") @@ -120,7 +122,9 @@ class NovaArtworkStudioSourceGuardTest { @Test fun studioMutationsAreOwnedByRetainedViewModelAndUiCallbacksAreLifecycleGated() { val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") + - readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") + + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt") + + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.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") @@ -189,7 +193,9 @@ class NovaArtworkStudioSourceGuardTest { @Test fun gameDetailsPreservesCachedHeroAvailabilityAndStablePresentationIdentity() { val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") + - readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") + + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt") + + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt") val contentCall = detail.section( "NovaGameDetailContent(", "\n loadOptimization(profilePreference)", @@ -244,7 +250,9 @@ class NovaArtworkStudioSourceGuardTest { fun applyIsOneExplicitAtomicMutationAndFailureRequiresRefetch() { val studio = readSource("src/main/java/com/papi/nova/ui/NovaArtworkStudio.kt") val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") + - readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") + + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt") + + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt") val updater = readSource("src/main/java/com/papi/nova/ui/NovaArtworkLibraryUpdater.kt") val stateReducer = studio.section( "fun reduce(action: NovaArtworkStudioAction)", @@ -309,7 +317,9 @@ class NovaArtworkStudioSourceGuardTest { fun resetAndCancelDiscardDraftLocallyWithoutServerMutation() { val studio = readSource("src/main/java/com/papi/nova/ui/NovaArtworkStudio.kt") val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") + - readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") + + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt") + + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt") val resetButton = studio.section( "text = stringResource(R.string.nova_artwork_studio_reset)", "text = stringResource(R.string.nova_artwork_studio_apply)", @@ -334,7 +344,9 @@ class NovaArtworkStudioSourceGuardTest { @Test fun ordinaryDetailAndLibraryLoadingCannotStartChoiceOrProviderMutationTraffic() { val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") + - readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") + + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt") + + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt") val library = readSource("src/main/java/com/papi/nova/ui/NovaLibraryActivity.kt") val detailLoad = detail.section( "private fun loadArtworkState(", @@ -351,7 +363,9 @@ class NovaArtworkStudioSourceGuardTest { @Test fun studioRefreshUsesTheRetainedPerGameMutationCoordinator() { val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") + - readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") + + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt") + + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.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") 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 5bfae711..469cebc2 100644 --- a/app/src/test/java/com/papi/nova/ui/NovaComposeSourceGuardTest.kt +++ b/app/src/test/java/com/papi/nova/ui/NovaComposeSourceGuardTest.kt @@ -575,9 +575,9 @@ class NovaComposeSourceGuardTest { hero.contains("if (!compact && hero.badges.isNotEmpty())") ) assertTrue( - "hero card itself should activate the same primary action when D-pad focus lands on the container", - hero.contains(".combinedClickable(onClick = onPrimaryAction)") && - hero.indexOf(".combinedClickable(onClick = onPrimaryAction)") in 0 until hero.indexOf(".focusable()") + "hero card opens the game and falls back to the primary action, so a running game is still reachable while the grid omits it", + hero.contains(".combinedClickable(onClick = onOpenDetail ?: onPrimaryAction)") && + hero.indexOf(".combinedClickable(onClick = onOpenDetail ?: onPrimaryAction)") in 0 until hero.indexOf(".focusable()") ) assertTrue( "hero focus should update the focused backdrop/focus restore model for D-pad users", @@ -1036,17 +1036,12 @@ class NovaComposeSourceGuardTest { val detail = readNovaGameDetail() val detailsPanel = detail.section( "private fun GameDetailsPanel(", - "@Composable\nprivate fun LaunchControlsPanel(" + "@Composable\nprivate fun LaunchControls(" ) val launchFooter = detail.section( "internal fun NovaGameDetailLaunchFooter(", "@Composable\nprivate fun NovaDetailPanel(" ) - val launchModePill = detail.section( - "private fun LaunchModeChoicePill(", - "@Composable\nprivate fun ProfileSummaryText(" - ) - assertTrue( "Retroid landscape first paint should keep either Hero or Poster identity inside the compact launch header ceiling", detailsPanel.contains(".heightIn(min = 136.dp)") && @@ -1066,104 +1061,49 @@ class NovaComposeSourceGuardTest { assertTrue( "pinned primary launch and mode choice controls should stay compact enough for Retroid landscape", launchFooter.contains("minHeight = 48.dp") && - launchModePill.contains("modifier = modifier.heightIn(min = 52.dp)") + detail.contains("NOVA_DETAIL_ROW_MIN_HEIGHT = 48.dp") ) } @Test fun gameDetailLaunchControlsPrioritizePrimaryPlayFocus() { val detail = readNovaGameDetail() - val launchControls = detail.section( - "private fun LaunchControls(", - "@Composable\nprivate fun LaunchProfileSummaryInline(" - ) - val launchFooter = detail.section( - "internal fun NovaGameDetailLaunchFooter(", - "@Composable\nprivate fun NovaDetailPanel(" - ) - val sheetContent = detail.section( - "fun NovaGameDetailContent(", - "@Composable\nprivate fun NovaGameDetailScrollableContent(" + val overview = detail.section( + "internal fun NovaGameDetailOverview(", + "private fun NovaGameDetailTitle(" ) - val scrollableContent = detail.section( - "private fun NovaGameDetailScrollableContent(", - "@Composable\nprivate fun novaGameDetailControllerHints(" + val actions = detail.section( + "private fun NovaGameDetailActions(", + "private fun NovaGameDetailAction(" ) - val scrollBody = sheetContent.blockStartingAt("NovaGameDetailScrollableContent(") assertTrue( - "game detail should focus the pinned primary play action when the sheet opens", - sheetContent.contains("val playFocusRequester = remember { FocusRequester() }") && - launchFooter.contains("playFocusRequester.requestFocus()") && - launchFooter.contains(".focusRequester(playFocusRequester)") - ) - assertTrue( - "game detail should preserve an explicit controller focus route between pinned Play and More details", - sheetContent.contains("val detailsFocusRequester = remember { FocusRequester() }") && - launchControls.contains("detailsFocusRequester = detailsFocusRequester") && - launchControls.contains("playFocusRequester = playFocusRequester") && - launchFooter.contains(".focusProperties { up = detailsFocusRequester }") && - launchControls.contains(".focusProperties { down = playFocusRequester }") + "the primary action holds first focus, and nothing scrolls above it", + detail.contains("val playFocusRequester = remember { FocusRequester() }") && + actions.contains(".focusRequester(playFocusRequester)") && + actions.contains("primary = activeSession?.watchOnly != true") && + !overview.contains("verticalScroll") ) assertTrue( - "the pinned footer should keep Play as a full-width primary action above the safe-content bottom inset", - launchFooter.section("text = playLabel", "enabled = enabled") - .contains(".fillMaxWidth()\n .focusRequester(playFocusRequester)") && - launchFooter.contains(".windowInsetsPadding(contentInsets)") && - launchFooter.contains(".padding(start = 14.dp, end = 14.dp, top = 4.dp, bottom = 18.dp)") && - sheetContent.contains("contentInsets = WindowInsets.safeContent.only(WindowInsetsSides.Bottom)") + "the action lane is one row, so a D-pad walk never leaves it", + actions.contains("Row(") && + actions.contains("horizontalArrangement = Arrangement.spacedBy(10.dp)") ) assertTrue( - "the game-detail root must remain bounded while the detail body owns the remaining height and vertical scrolling", - sheetContent.contains(".fillMaxHeight()") && - sheetContent.contains("modifier = Modifier.weight(1f)") && - scrollableContent.contains(".verticalScroll(scrollState)") + "every focusable action clears the accessible target floor", + detail.contains("internal val NovaGameDetailActionHeight = 48.dp") && + detail.contains("heightIn(min = NovaGameDetailActionHeight)") ) assertTrue( - "the pinned Play footer must be composed after and outside the scrollable detail lambda", - scrollBody.contains("GameDetailsPanel(") && - scrollBody.contains("LaunchControlsPanel(") && - scrollBody.contains("NovaControllerHintBar(") && - !scrollBody.contains("NovaGameDetailLaunchFooter(") && - sheetContent.indexOf("NovaGameDetailLaunchFooter(") > - sheetContent.indexOf(scrollBody) + scrollBody.length + "the rail is gated rather than decorative: Launch mode appears only when there is a real choice, and a review replaces the rail with its own answers", + actions.contains("if (showLaunchModeAction)") && + actions.contains("if (reviewExpanded)") && + actions.contains("onRetryHighFps") && + actions.contains("onResetProfile") ) assertFalse( - "the primary launch button must not remain inside the vertically scrolling launch-controls body", - launchControls.contains("text = playLabel") || - launchControls.contains("onClick = onPrimaryLaunch") - ) - assertTrue( - "game detail should keep host/render limits in the scrolling body above the pinned launch action", - launchControls.contains("LaunchProfilePrimaryNotice(") && - sheetContent.indexOf("LaunchControlsPanel(") < sheetContent.indexOf("NovaGameDetailLaunchFooter(") - ) - assertTrue( - "Heads up should explain evidence and the recommended next launch instead of showing one vague limited-by line", - launchControls.contains("text = summary.noticeDetail") && - launchControls.contains("text = summary.noticeRecommendation") - ) - assertTrue( - "Heads up should remain visible when only the new evidence or recommendation fields are available", - launchControls.contains("val hasNoticeContent = listOf(") && - launchControls.contains("summary.noticeDetail") && - launchControls.contains("summary.noticeRecommendation") && - launchControls.contains("if (!hasNoticeContent) return") && - launchControls.contains("text = notice.ifBlank { \"Launch profile adjusted\" }") - ) - assertTrue( - "near-target performance should use an explicit healthy status tone instead of warning styling", - launchControls.contains("summary.noticeTone == NovaLaunchProfileNoticeTone.HEALTHY") && - launchControls.contains("val badgeLabel = if (isHealthy) summary.noticeLabel else \"Heads up\"") && - launchControls.contains("colorResource(R.color.nova_success)") - ) - assertTrue( - "Heads up should stay compact until the player opens the DPAD-friendly detail control", - launchControls.contains("var noticeExpanded by remember(") && - launchControls.contains("text = if (noticeExpanded) \"Hide details\" else \"More details\"") && - launchControls.contains("stateDescription = if (noticeExpanded) \"Expanded\" else \"Collapsed\"") && - launchControls.contains("if (noticeExpanded && summary.noticeDetail.isNotBlank())") && - launchControls.contains("if (noticeExpanded && summary.noticeRecommendation.isNotBlank())") + "the primary launch button must not sit inside a scrolling body", + actions.contains("verticalScroll") ) } @@ -1172,27 +1112,40 @@ class NovaComposeSourceGuardTest { val detail = readNovaGameDetail() val launchControls = detail.section( "private fun LaunchControls(", - "@Composable\nprivate fun LaunchProfileSummaryInline(" + "@Composable\ninternal fun LaunchProfilePrimaryNotice(" ) assertTrue( - "Headless/Virtual should be directly selectable from the detail sheet before launching", - launchControls.contains("LaunchModeChoicePill(") && + "Headless/Virtual should be directly selectable from the destination, as rows carrying their standing", + launchControls.contains("NovaSteamChoiceRow(") && launchControls.contains("onClick = { onLaunchModeSelected(\"headless\") }") && - launchControls.contains("onClick = { onLaunchModeSelected(\"virtual_display\") }") + launchControls.contains("onClick = { onLaunchModeSelected(\"virtual_display\") }") && + !launchControls.contains("LaunchModeChoicePill(") ) assertTrue( "Launch Options should remain a secondary path after inline Headless/Virtual choices", detail.contains("private fun showLaunchOptions(") && detail.contains("onLaunchOptions = {") && - launchControls.contains("text = launchOptionsLabel") && - launchControls.indexOf("text = launchOptionsLabel") > launchControls.indexOf("LaunchModeChoicePill(") + launchControls.contains("label = launchOptionsLabel") && + launchControls.indexOf("label = headlessModeLabel") in + 0 until launchControls.indexOf("label = launchOptionsLabel") + ) + assertTrue( + "launch mode should answer where it runs and leave the profile alone: preference, summary and reset are Tune's", + !launchControls.contains("profilePreferenceLabel") && + !launchControls.contains("LaunchProfileSummaryInline(") && + !launchControls.contains("resetProfileLabel") + ) + assertTrue( + "Tune should hold the profile controls launch mode gave up, and stay the way into the preference picker", + detail.contains("onClick = onProfilePreference,") && + detail.contains("LaunchProfileSummaryActions(") && + detail.contains("R.string.nova_game_detail_group_actions") ) assertTrue( - "non-duplicative tuning should remain available separately from launch mode selection", - launchControls.contains("text = profilePreferenceLabel") && - launchControls.indexOf("text = profilePreferenceLabel") > launchControls.indexOf("LaunchModeChoicePill(") && - launchControls.split("LaunchProfileSummaryInline(").size == 2 + "launch mode should name itself once: the destination header already does, and the pills say which is recommended", + !launchControls.contains("text = launchModeTitle") && + !launchControls.contains("text = recommendedBadge") ) } @@ -1212,7 +1165,8 @@ class NovaComposeSourceGuardTest { "when MangoHUD is already enabled, the drawer should show only a passive status after launch controls", sheetContent.contains("if (mangoHudEnabled) {") && sheetContent.contains("MangoHudPassiveStatus(") && - sheetContent.indexOf("MangoHudPassiveStatus(") > sheetContent.indexOf("LaunchControlsPanel(") + sheetContent.indexOf("LaunchControls(") in + 0 until sheetContent.indexOf("MangoHudPassiveStatus(") ) } @@ -1221,7 +1175,7 @@ class NovaComposeSourceGuardTest { val source = readNovaGameDetail() val detailsPanel = source.section( "private fun GameDetailsPanel(", - "@Composable\nprivate fun LaunchControlsPanel(" + "@Composable\nprivate fun LaunchControls(" ) assertTrue( @@ -1239,52 +1193,32 @@ class NovaComposeSourceGuardTest { @Test fun gameDetailUsesHeroBackdropLogoTransformIconIdentityAndPosterFallback() { val source = readNovaGameDetail() - val sheetContent = source.section( - "fun NovaGameDetailContent(", - "@Composable\nprivate fun NovaGameDetailScrollableContent(" + val overview = source.section( + "internal fun NovaGameDetailOverview(", + "private fun NovaGameDetailTitle(" ) - val detailsPanel = source.section( - "private fun GameDetailsPanel(", - "@Composable\nprivate fun LaunchControlsPanel(" + val title = source.section( + "private fun NovaGameDetailTitle(", + "private fun NovaGameDetailStatusLine(" ) assertTrue( - "detail content should route revision-aware Hero, Logo, and Icon presentation into the identity panel", - sheetContent.contains("heroAvailable = heroAvailable") && - sheetContent.contains("heroPresentationKey = heroPresentationKey") && - sheetContent.contains("heroLoader = heroLoader") && - sheetContent.contains("logoPresentationKey = logoPresentationKey") && - sheetContent.contains("iconPresentationKey = iconPresentationKey") + "the hero should be the full-bleed backdrop rather than a 136dp panel thumbnail, reusing the library's own backdrop so hero-to-poster fallback and the theme scrims come with it", + overview.contains("NovaLibraryCinematicBackdrop(") && + overview.contains("strength = 1f") && + !overview.contains(".height(136.dp)") ) assertTrue( - "a real manifest Hero should become the compact detail backdrop", - detailsPanel.contains("if (heroAvailable)") && - detailsPanel.contains("NovaGameDetailHero(") && - detailsPanel.contains("key(heroPresentationKey)") && - detailsPanel.contains("heroLoader(this)") && - detailsPanel.contains(".height(136.dp)") + "curated logo artwork should become the title treatment at real size, still keyed by presentation revision", + title.contains("if (logoAvailable)") && + title.contains("key(logoPresentationKey)") && + title.contains("logoLoader(this)") && + title.contains("maxWidth = 200.dp, maxHeight = 64.dp") ) assertTrue( - "the manifest Logo should be revision-aware and use the saved normalized transform over Hero", - detailsPanel.contains("BoxWithConstraints(") && - detailsPanel.contains("key(logoPresentationKey)") && - detailsPanel.contains("logoLoader(this)") && - detailsPanel.contains("offset(x = logoOffsetX, y = logoOffsetY)") && - detailsPanel.contains("scaleX = artworkState.logoScale") && - detailsPanel.contains("scaleY = artworkState.logoScale") - ) - assertTrue( - "the manifest Icon should provide a compact revision-aware identity mark beside the title", - detailsPanel.contains("if (iconAvailable)") && - detailsPanel.contains("key(iconPresentationKey)") && - detailsPanel.contains("iconLoader(this)") && - detailsPanel.contains("text = game.name") - ) - assertTrue( - "Poster must remain the no-Hero fallback instead of disappearing from detail presentation", - detailsPanel.contains("NovaGameDetailPosterFallback(") && - detailsPanel.contains("key(PolarisApiClient.artworkPresentationKey(game, PolarisGame.ARTWORK_KIND_POSTER))") && - detailsPanel.contains("coverLoader(this)") + "a game with no curated logo falls back to its name, and the fallback is a title rather than a poster card", + title.contains("text = game.name") && + title.contains("nova-game-detail-title") ) } @@ -1295,14 +1229,22 @@ class NovaComposeSourceGuardTest { "fun NovaGameDetailContent(", "@Composable\nprivate fun NovaGameDetailScrollableContent(" ) - val artworkIndex = content.indexOf("NovaArtworkStudio(") - val stabilityIndex = content.indexOf("optimizationState.stability?.let") - val hintIndex = content.indexOf("NovaControllerHintBar(") - assertTrue("artwork preferences should follow functional and insight content", artworkIndex > stabilityIndex) - assertTrue("artwork preferences should remain above the controller hint footer", artworkIndex in 1 until hintIndex) + assertTrue( + "artwork curation should be its own destination, and a full-screen one: the studio lays itself out as a Row of weighted Columns and cannot fold into a side panel", + content.contains("NovaGameDetailDestination.ARTWORK -> NovaGameDetailFullScreen(") && + content.contains("NovaArtworkStudio(") + ) val panel = readSource("src/main/java/com/papi/nova/ui/NovaArtworkStudio.kt") - assertTrue("artwork preferences should start collapsed", panel.contains("var expanded by remember(initialQuery) { mutableStateOf(false) }")) + assertTrue( + "artwork preferences should start collapsed wherever the studio is one row among many", + panel.contains("initiallyExpanded: Boolean = false") && + panel.contains("var expanded by remember(initialQuery) { mutableStateOf(initiallyExpanded) }") + ) + assertTrue( + "the destination that is nothing but the studio should open it, not cost a tap and leave the window empty", + content.contains("NovaArtworkStudio(\n initiallyExpanded = true,") + ) assertTrue("artwork header should toggle expansion", panel.contains("clickable { expanded = !expanded }") && panel.contains("if (expanded)")) assertTrue("Studio should show persisted identity and composition beside the live draft", panel.contains("R.string.nova_artwork_current_match") && panel.contains("R.string.nova_artwork_current_composition") && panel.contains("R.string.nova_artwork_live_preview")) assertTrue("Studio should render Poster, Hero, Logo, and Icon composition layers", NovaArtworkKinds.ALL.all { kind -> panel.contains("kind = NovaArtworkKinds.${kind.uppercase()}") }) @@ -1751,11 +1693,11 @@ class NovaComposeSourceGuardTest { libraryScreen.contains("controllerHintBarLandscapeStartPadding") ) assertTrue( - "game detail sheet should keep the shared hint bar with explicit horizontal and bottom padding inside the scrollable sheet", - detailContent.contains("NovaControllerHintBar(") && - detailContent.contains("hints = novaGameDetailControllerHints()") && - detailContent.contains(".padding(start = 14.dp, end = 14.dp, top = 12.dp)") && - detailContent.contains(".padding(bottom = 16.dp)") + "the game detail window keeps the shared hint model; the Overview paints it borderless on the artwork while destinations keep the reusable bar", + detail.contains("List") && + detail.contains("novaGameDetailOverviewHints()") && + detail.contains("NovaControllerHintBar(") && + detail.contains("nova_controller_hint_back") ) assertTrue( "settings should keep the main rows weighted above the shared hint bar instead of letting rows consume and clip the bottom controls", @@ -2749,7 +2691,9 @@ class NovaComposeSourceGuardTest { private fun readNovaGameDetail(): String = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") + - readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") + + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt") + + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt") private fun readNovaSettingsScreen(): String = readSource("src/main/java/com/papi/nova/preferences/NovaSettingsScreen.kt") @@ -2761,7 +2705,7 @@ class NovaComposeSourceGuardTest { fun gameDetailLaunchOptionsUseActionableModeState() { val launchControls = readNovaGameDetail().section( "private fun LaunchControls(", - "@Composable\nprivate fun LaunchModeChoicePill(" + "@Composable\ninternal fun LaunchProfilePrimaryNotice(" ) assertTrue(launchControls.contains("uiState.showLaunchOptionsButton")) @@ -2772,7 +2716,9 @@ class NovaComposeSourceGuardTest { @Test fun gameDetailLaunchOptionsAvoidRawAppCompatAlertDialogButtons() { val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") + - readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") + + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt") + + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt") val launchOptions = detail.section( "private fun showLaunchOptions(", "private fun optionLabel(" @@ -2789,7 +2735,9 @@ class NovaComposeSourceGuardTest { @Test fun gameDetailProfilePreferenceAvoidsRawAppCompatAlertDialogButtons() { val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") + - readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") + + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt") + + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.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 3188ef34..afd3858a 100644 --- a/app/src/test/java/com/papi/nova/ui/NovaLaunchSourceGuardTest.kt +++ b/app/src/test/java/com/papi/nova/ui/NovaLaunchSourceGuardTest.kt @@ -10,14 +10,15 @@ class NovaLaunchSourceGuardTest { @Test fun gameDetailLaunchUsesSelectedMangoHudState() { - 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(", "setContentView(") + val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") + // launchConfirmed is shared by the primary action, the option picker and the + // expanded review, so the MangoHUD state is asserted where they all pass through. + val launchConfirmed = detail.section("fun launchConfirmed(", "fun resetProfile(") + val launchModeSelection = detail.section("fun selectLaunchMode(", "fun launchConfirmed(") assertTrue( - "primary Play should pass the selected MangoHUD state into the launch request", - primaryLaunch.contains("currentGame.copy(mangohud = mangoHudEnabled)") + "every launch path should pass the selected MangoHUD state into the launch request", + launchConfirmed.contains("currentGame.copy(mangohud = mangoHudEnabled)") ) assertTrue( "inline mode selection should keep the selected MangoHUD state in preview/preflight state", @@ -45,38 +46,40 @@ class NovaLaunchSourceGuardTest { @Test fun desktopSteamDecisionSheetUsesNovaGlassAndExplicitMirrorDesktopPlumbing() { val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") + - readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") + + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt") + + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.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") val nvHttp = readSource("src/main/java/com/papi/nova/nvstream/http/NvHTTP.kt") - val chrome = readSource("src/main/java/com/papi/nova/ui/NovaSheetChrome.kt") - val decisionSheet = detail.section( - "private fun showDesktopSteamLaunchDecision(", - "private fun modeLabel(" - ) - val launchOptions = detail.section( - "onLaunchOptionSelected = { option ->", - "onDismissLaunchOptions =" + val decisionRows = detail.section( + "internal fun NovaDesktopSteamLaunchDecisionRows(", + "internal fun NovaSteamChoiceRow(" ) assertTrue( - "desktop Steam active policy should open a Nova-themed Compose bottom sheet, not a legacy square AlertDialog", - decisionSheet.contains("BottomSheetDialog(this@NovaGameDetailActivity)") && - decisionSheet.contains("NovaDesktopSteamLaunchDecisionContent(") && - !decisionSheet.contains("AlertDialog.Builder") + "the desktop Steam choice belongs in the Launch mode destination, not a sheet or an alert raised over the artwork", + detail.contains("destination = NovaGameDetailDestination.LAUNCH_MODE") && + detail.contains("steamDecision = desktopSteamDecision") && + !detail.contains("BottomSheetDialog(") && + !detail.contains("AlertDialog.Builder") + ) + assertTrue( + "the decision should offer explicit Private Stream, Mirror Desktop, and close-Steam paths", + decisionRows.contains("nova_desktop_steam_private_stream") && + decisionRows.contains("nova_desktop_steam_mirror_desktop") && + decisionRows.contains("NovaSteamLaunchChoice.CLOSE_STEAM_THEN_PRIVATE") ) assertTrue( - "decision sheet should offer explicit Private Stream, Mirror Desktop, and Cancel actions", - decisionSheet.contains("nova_desktop_steam_private_stream") && - decisionSheet.contains("nova_desktop_steam_mirror_desktop") && - decisionSheet.contains("nova_desktop_steam_cancel") + "a blocked option stays visible and inert, because the reason it is blocked is the useful part", + decisionRows.contains("enabled = decision.privateStreamEnabled") && + decisionRows.contains("caption = decision.privateStreamUnavailableReason") ) assertTrue( "Launch Options must not bypass desktop-Steam safety for selected private headless launches", - launchOptions.contains("usesVirtualDisplay = option.usesVirtualDisplay") && - launchOptions.contains("showDesktopSteamLaunchDecision(") && - launchOptions.contains("onForcePrivateAfterSteamClose = { launchSelected(mirrorDesktop = false, forcePrivateAfterSteamClose = true) }") + detail.contains("usesVirtualDisplay = option.usesVirtualDisplay") && + detail.contains("steamDecision = desktopSteamDecision") ) assertTrue( "Mirror Desktop must be carried as an explicit launch override through the stream launch path", @@ -88,15 +91,16 @@ class NovaLaunchSourceGuardTest { streamConfiguration.contains("fun setMirrorDesktop(enable: Boolean)") && streamConfiguration.contains("fun setForcePrivateAfterSteamClose(enable: Boolean)") && nvHttp.contains("&mirrorDesktop=") && - nvHttp.contains("&launchMode=mirror_desktop") && - serverHelper.contains("forcePrivateAfterSteamClose = forcePrivateAfterSteamClose") + nvHttp.contains("&launchMode=mirror_desktop") ) } @Test fun launchFailureAndDesktopSteamActionsUseNovaThemedFlow() { val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") + - readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") + + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt") + + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.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") @@ -161,15 +165,22 @@ class NovaLaunchSourceGuardTest { @Test fun composeBottomSheetsUseThemeAwareGlassHostInsteadOfStaticOldThemeInset() { - val gameDetailSheet = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") + - readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") + val gameDetail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") + + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") + + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt") + + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt") val syncSheet = readSource("src/main/java/com/papi/nova/ui/NovaPolarisSyncSheet.kt") assertTrue( - "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(this@NovaGameDetailActivity)") && - !gameDetailSheet.contains("sheet.setBackgroundResource(sheetBackgroundRes())") + "the game detail window hosts no bottom sheet and no legacy alert at all, so there is no Material host left to restyle", + !gameDetail.contains("BottomSheetDialog(") && + !gameDetail.contains("AlertDialog.Builder") && + !gameDetail.contains("sheet.setBackgroundResource") + ) + assertTrue( + "a destination is a surface, not media: its glass must sit on an opaque ground or the artwork reads straight through it", + gameDetail.contains(".background(colors.window)\n .background(surfaces.panel)") || + gameDetail.contains(".background(colors.window)\n .background(surfaces.panel)") ) assertTrue( "Polaris sync sheet must use the same theme-aware host chrome instead of static nova_sheet_bg inset background", @@ -181,7 +192,9 @@ class NovaLaunchSourceGuardTest { @Test fun virtualLaunchPreflightUsesHostVirtualDisplayContractConstants() { val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") + - readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") + + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt") + + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt") val trampoline = readSource("src/main/java/com/papi/nova/ShortcutTrampoline.kt") val displayMode = readSource("src/main/java/com/papi/nova/api/PolarisStreamDisplayMode.kt") @@ -343,7 +356,9 @@ class NovaLaunchSourceGuardTest { @Test fun displayPlannerAndPostSessionReportStayControllerFirstAndLowNoise() { val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") + - readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") + + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt") + + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.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( @@ -489,7 +504,9 @@ class NovaLaunchSourceGuardTest { @Test fun virtualDisplayUnavailableCopyUsesHostVirtualDisplayLanguageAndReason() { val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") + - readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") + + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt") + + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.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")) @@ -625,11 +642,10 @@ class NovaLaunchSourceGuardTest { @Test fun gameDetailPreflightPreservesExplicitPolarisNonVirtualMode() { - val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") + - readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") + val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") val preflight = detail.section( "private fun syncLaunchPreflightSettings(", - "private fun showPreflightReview(" + "private fun modeLabel(" ) assertTrue( @@ -659,7 +675,9 @@ class NovaLaunchSourceGuardTest { @Test fun steamLaunchSelectionDoesNotDismissGameDetailOrStartStream() { val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") + - readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") + + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt") + + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt") val selection = detail.section( "onSteamLaunchModeSelected = { selected ->", "},\n onDismissSteamLaunchModeOptions" @@ -674,7 +692,9 @@ class NovaLaunchSourceGuardTest { fun steamLaunchModeUpdateConfirmsHostModeAndStaysInline() { val api = readSource("src/main/java/com/papi/nova/api/PolarisApiClient.kt") val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") + - readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") + + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt") + + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.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 78c82273..44cb4a3f 100644 --- a/app/src/test/java/com/papi/nova/ui/NovaThemeResourcesTest.kt +++ b/app/src/test/java/com/papi/nova/ui/NovaThemeResourcesTest.kt @@ -184,7 +184,9 @@ class NovaThemeResourcesTest { 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/NovaGameDetailActivity.kt").readText() + - File("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt").readText() + File("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt").readText() + + File("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt").readText() + + File("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.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() @@ -257,7 +259,9 @@ class NovaThemeResourcesTest { 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/NovaGameDetailActivity.kt").readText() + - File("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt").readText() + File("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt").readText() + + File("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt").readText() + + File("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.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")) @@ -390,7 +394,9 @@ class NovaThemeResourcesTest { 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/NovaGameDetailActivity.kt").readText() + - File("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt").readText() + File("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt").readText() + + File("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt").readText() + + File("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.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() @@ -453,14 +459,19 @@ class NovaThemeResourcesTest { @Test fun requiredNativeAlertsUseSharedOpacityAndBlurChrome() { val gameDetail = File("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt").readText() + - File("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt").readText() + File("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt").readText() + + File("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt").readText() + + File("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.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() val preflight = gameDetail .substringAfter("private fun showPreflightReview(") .substringBefore("private fun showDesktopSteamLaunchDecision(") - assertTrue("game-detail preflight should use shared literal-opacity alert chrome", preflight.contains("NovaSheetChrome.applyMenuOpacityToLegacyAlert")) + assertTrue( + "the game detail window raises no legacy alert at all: the preflight review expands the status line in place instead", + !preflight.contains("AlertDialog.Builder") && preflight.contains("reviewExpanded") + ) assertTrue("legacy sliders, including Menu & Drawer Opacity, should use shared literal-opacity alert chrome", legacySlider.contains("NovaSheetChrome.applyMenuOpacityToLegacyAlert(createdDialog)")) assertTrue("session termination/error alerts should use shared literal-opacity alert chrome", sessionDialog.contains("NovaSheetChrome.applyMenuOpacityToLegacyAlert(createdAlert)")) } @@ -547,7 +558,9 @@ class NovaThemeResourcesTest { 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/NovaGameDetailActivity.kt").readText() + - File("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt").readText() + File("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt").readText() + + File("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt").readText() + + File("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.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() diff --git a/shared/polaris/model/src/commonMain/kotlin/com/papi/nova/shared/polaris/model/PolarisGame.kt b/shared/polaris/model/src/commonMain/kotlin/com/papi/nova/shared/polaris/model/PolarisGame.kt index c6e6eb6c..9fdc2a0f 100644 --- a/shared/polaris/model/src/commonMain/kotlin/com/papi/nova/shared/polaris/model/PolarisGame.kt +++ b/shared/polaris/model/src/commonMain/kotlin/com/papi/nova/shared/polaris/model/PolarisGame.kt @@ -26,7 +26,20 @@ data class PolarisGame( @SerialName("launch_mode") val launchMode: LaunchModeContract? = null, @SerialName("steam_launch") val steamLaunch: SteamLaunchContract? = null, @SerialName("display_planner") val displayPlanner: DisplayPlannerContract? = null, - @SerialName("artwork") val artwork: ArtworkManifest? = null + @SerialName("artwork") val artwork: ArtworkManifest? = null, + /** + * How long the owning launcher says this has been played, or null when none can say. + * + * Null rather than zero: a game nobody has played and a game no launcher owns are + * different answers, and only one of them should read "Not started". + * + * Last in the list on purpose. Sixteen places build this positionally, so a field + * inserted beside lastLaunched where it reads best would silently shift every one of + * them. Serialisation is by name, so position costs nothing here. + */ + @SerialName("play_time") val playTime: PlayTime? = null, + /** Completion estimates, or null when the host's dataset has nothing for this game. */ + @SerialName("beat_time") val beatTime: BeatTime? = null ) { @Serializable data class ArtworkManifest( @@ -41,6 +54,34 @@ data class PolarisGame( fun asset(kind: String): ArtworkAsset? = assets.asset(kind) } + /** + * What the host's dataset says about finishing this game. + * + * Every figure is optional on its own: a catalogue that knows the main story but not + * the completionist run should say so rather than pad the gap with a zero. + */ + @Serializable + data class BeatTime( + @SerialName("main_seconds") val mainSeconds: Long = 0, + @SerialName("extras_seconds") val extrasSeconds: Long = 0, + @SerialName("completionist_seconds") val completionistSeconds: Long = 0, + @SerialName("matched_name") val matchedName: String = "", + @SerialName("url") val url: String = "", + @SerialName("cached_at") val cachedAt: Long = 0 + ) { + /** The bar's full width, falling back through what is actually known. */ + val longestSeconds: Long + get() = maxOf(completionistSeconds, extrasSeconds, mainSeconds) + } + + /** What a launcher says about time spent, normalised to seconds before it travels. */ + @Serializable + data class PlayTime( + @SerialName("seconds") val seconds: Long = 0, + @SerialName("source") val source: String = "", + @SerialName("read_at") val readAt: Long = 0 + ) + @Serializable data class ArtworkMatch( @SerialName("source") val source: String = "",