diff --git a/.gitignore b/.gitignore index 8e7acd132..8d26e7839 100644 --- a/.gitignore +++ b/.gitignore @@ -22,7 +22,7 @@ native/opennow-streamer/bin/*/* .serena/ *.xcuserstate xcuserdata/ - +.logs/ # Xcode build outputs DerivedData/ @@ -44,6 +44,8 @@ gfn_tokens.json # Test files test/ +!android/app/src/test/ +!android/app/src/test/** package-lock.json opennow-stable/package-lock.json @@ -56,3 +58,6 @@ release-notes.md result .deriveddata/ .deriveddata-device/ + +/android/app/.cxx +/android/app/release diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 000000000..025d1a5df --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,8 @@ +.gradle/ +.idea/ +build/ +local.properties +captures/ +*.iml +app/build/ +app/release/ \ No newline at end of file diff --git a/android/BUG_REPORT_API.md b/android/BUG_REPORT_API.md new file mode 100644 index 000000000..7ba7cf7ae --- /dev/null +++ b/android/BUG_REPORT_API.md @@ -0,0 +1,164 @@ +# Android bug-report API contract + +The Android client sends a pseudonymous, installation-scoped `reporterId` with every bug report. +Use that value as the lookup key for report throttles or blocks. It is a namespaced SHA-256 digest; +the raw GFN device ID, account ID, and email address are not sent. + +`reporterId` is useful for ordinary abuse control, but it is not a hardware-backed identity. Clearing +app data or reinstalling can create a new identifier, and a modified client can forge one. If stronger +enforcement is needed, combine it server-side with rate limits or authenticated account signals that +the service already has instead of asking the app to upload raw hardware identifiers. + +## Custom rejection shown by the app + +Return HTTP `403` with this JSON when the reporter is blocked: + +```json +{ + "ok": false, + "error": { + "code": "REPORTER_BANNED", + "message": "Bug reporting is disabled for this installation. Contact support if you believe this is a mistake.", + "retryable": false + } +} +``` + +The Android app displays `error.message` in its existing bug-report error panel. It normalizes +whitespace and limits the public message to 320 characters. It never displays an unstructured HTML +or proxy error body. + +Use HTTP `429` and `code: "REPORT_RATE_LIMITED"` for a temporary throttle. Set `retryable` to `true` +and write the retry instruction in `message`. + +## OpenAPI 3.1 schema + +```yaml +openapi: 3.1.0 +info: + title: OpenNOW Android bug reports + version: 1.0.0 +paths: + /releases/opennow/bug-reports: + post: + operationId: createOpenNowAndroidBugReport + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: + - title + - description + - versionName + - versionCode + - platform + - reporterId + - metadata + properties: + title: + type: string + minLength: 1 + description: + type: string + minLength: 50 + versionName: + type: string + versionCode: + type: string + platform: + type: string + const: android + reporterId: + type: string + pattern: '^br1_[0-9a-f]{64}$' + description: Pseudonymous installation key used for abuse prevention. + metadata: + type: string + contentMediaType: application/json + description: A JSON object serialized as a multipart text field. + files: + type: array + maxItems: 5 + items: + type: string + contentMediaType: application/octet-stream + maxLength: 10485760 + responses: + '201': + description: Report accepted. + content: + application/json: + schema: + $ref: '#/components/schemas/BugReportAccepted' + '400': + description: Invalid report. + content: + application/json: + schema: + $ref: '#/components/schemas/BugReportRejected' + '403': + description: This reporter is blocked. + content: + application/json: + schema: + $ref: '#/components/schemas/BugReportRejected' + '429': + description: This reporter is temporarily rate-limited. + content: + application/json: + schema: + $ref: '#/components/schemas/BugReportRejected' +components: + schemas: + BugReportAccepted: + type: object + required: [ok, reportId] + properties: + ok: + type: boolean + const: true + reportId: + type: string + BugReportRejected: + type: object + required: [ok, error] + properties: + ok: + type: boolean + const: false + error: + type: object + required: [code, message, retryable] + properties: + code: + type: string + maxLength: 80 + message: + type: string + minLength: 1 + maxLength: 320 + retryable: + type: boolean +``` + +## Server-side block record + +A minimal persistent record can use this shape: + +```json +{ + "reporterId": "br1_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "enabled": true, + "publicMessage": "Bug reporting is disabled for this installation.", + "internalReason": "Repeated reports without actionable evidence", + "createdAt": "2026-08-05T20:00:00Z", + "expiresAt": null +} +``` + +Before accepting files, validate `reporterId`, load the enabled non-expired block, and return the +structured `403` response using `publicMessage`. Keep `internalReason` server-only. Store the +`reporterId` beside accepted reports so an administrator can copy it from a report into the block +table. Check the block before persisting attachments to avoid unnecessary storage and processing. diff --git a/android/README.md b/android/README.md new file mode 100644 index 000000000..5219585e4 --- /dev/null +++ b/android/README.md @@ -0,0 +1,37 @@ +# OpenNOW Native Android + +This folder is a standalone Android Studio project for the native Kotlin / Jetpack Compose OpenNOW target. + +Open it from Android Studio with **File > Open > `OpenNOW/android`**. Android Studio will install/use the required Gradle, Android SDK, CMake, and NDK components from the project configuration. + +## Build Targets + +- `:app:assembleDebug` builds a debug APK. +- `:app:assembleRelease` builds the direct-distribution release APK with APK update support. +- `:app:bundleRelease` builds the Google Play Android App Bundle. This task removes `REQUEST_INSTALL_PACKAGES` and disables APK self-updates so Play installs use Google Play's update mechanism. + +Release and debug builds include `arm64-v8a`, `armeabi-v7a`, and `x86_64`. The `armeabi-v7a` slice supports 32-bit ARM phones and 32-bit Android TV firmware. OpenNOW recommends 720p/30 FPS/12 Mbps for 32-bit processes and memory-constrained TVs, and warns when a custom profile exceeds that recommendation without overriding the user's selection. + +## APK Update Manifest + +APK and debug builds check `https://api.printedwaste.com/releases/opennow/latest` and can download the returned APK. App Bundle builds installed from Google Play detect `com.android.vending` as the install source and do not check, download, or install APK updates. The manifest should look like this: + +```json +{ + "versionCode": 7, + "versionName": "0.5.2", + "apkUrl": "https://api.printedwaste.com/release-files/opennow/app-release.apk", + "artifactUrl": "https://api.printedwaste.com/release-files/opennow/app-release.apk", + "sha256": "optional lowercase apk checksum", + "releaseNotes": "Short notes shown in Settings\nSecond line" +} +``` + +`apkUrl`, `artifactUrl`, or `url` may point at the APK. `releaseNotes` may use real newlines or literal `\n` separators. The app compares `versionCode` against its installed build and asks Android's package installer to confirm the downloaded APK. + +## Runtime Notes + +- UI is native Compose. +- GFN auth, catalog, subscription, CloudMatch session creation/polling/claim/stop, signaling, and input packet behavior are implemented in Kotlin from the Electron project contracts. +- Streaming uses Android WebRTC plus hardware MediaCodec probing. The bundled `opennow_native` JNI library exposes native runtime diagnostics and keeps the NDK/CMake path wired for media-sensitive code. +- Queue ad metadata is preserved in `SessionInfo`; ad playback can use the included Media3 dependency when the server returns an ad media URL. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 000000000..96ba9f91d --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,197 @@ +import java.util.Properties + +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.plugin.compose") + id("org.jetbrains.kotlin.plugin.serialization") +} + +val localProperties = Properties().apply { + rootProject.file("local.properties").takeIf { it.exists() }?.inputStream()?.use { load(it) } +} +fun Sequence.firstNonBlankOrNull(): String? = + mapNotNull { value -> value?.trim()?.takeIf { it.isNotEmpty() } }.firstOrNull() + +fun gradlePropertyValue(vararg names: String): String? = + names.asSequence().map { name -> providers.gradleProperty(name).orNull }.firstNonBlankOrNull() + +fun localPropertyValue(vararg names: String): String? = + names.asSequence().map { name -> localProperties.getProperty(name) }.firstNonBlankOrNull() + +fun environmentValue(vararg names: String): String? = + names.asSequence().map { name -> providers.environmentVariable(name).orNull }.firstNonBlankOrNull() + +fun firstNonBlankValue(vararg values: String?): String = + values.asSequence().firstNonBlankOrNull().orEmpty() + +fun buildConfigString(value: String): String = + "\"" + value + .replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + "\"" + +val defaultPostHogProjectToken = "phc_pdob6BhBvbayfd7BA6zXBkty8o6EkxKYY7sF3ZwLymk3" +val defaultPostHogHost = "https://aa.printedwaste.com" + +val postHogProjectToken = firstNonBlankValue( + gradlePropertyValue("posthog.apiKey", "posthog.projectToken"), + environmentValue("POSTHOG_API_KEY", "POSTHOG_PROJECT_TOKEN"), + localPropertyValue("posthog.apiKey", "posthog.projectToken"), + defaultPostHogProjectToken, +) +val postHogHost = firstNonBlankValue( + gradlePropertyValue("posthog.host"), + environmentValue("POSTHOG_HOST"), + localPropertyValue("posthog.host"), + defaultPostHogHost, +) +val buildingPlayReleaseBundle = + providers.gradleProperty("distribution").orNull.equals("play-store", ignoreCase = true) || + gradle.startParameter.taskNames.any { taskName -> + taskName.substringAfterLast(":").equals("bundleRelease", ignoreCase = true) + } + +android { + namespace = "com.opencloudgaming.opennow" + compileSdk = 37 + + defaultConfig { + applicationId = "com.opencloudgaming.opennow" + minSdk = 23 + // Android 17 target changes are audited; LAN access is permission-gated at its feature boundary. + //noinspection EditedTargetSdkVersion + targetSdk = 37 + versionCode = 116 + versionName = "1.6.0" + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + + buildConfigField("String", "POSTHOG_PROJECT_TOKEN", buildConfigString(postHogProjectToken)) + buildConfigField("String", "POSTHOG_HOST", buildConfigString(postHogHost)) + buildConfigField("boolean", "APK_UPDATES_SUPPORTED", "true") + buildConfigField("boolean", "PLAY_STORE_RELEASE", "false") + buildConfigField("boolean", "LOCAL_APP_LAUNCHER_SUPPORTED", "true") + + ndk { + // Keep legacy Intel TV devices eligible; App Bundles deliver only the matching ABI. + abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64", "x86") + } + + } + + buildTypes { + debug { + isMinifyEnabled = false + } + release { + isMinifyEnabled = true + isShrinkResources = true + buildConfigField("boolean", "APK_UPDATES_SUPPORTED", (!buildingPlayReleaseBundle).toString()) + buildConfigField("boolean", "PLAY_STORE_RELEASE", buildingPlayReleaseBundle.toString()) + buildConfigField("boolean", "LOCAL_APP_LAUNCHER_SUPPORTED", (!buildingPlayReleaseBundle).toString()) + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + } + + sourceSets { + getByName("debug").manifest.srcFile("src/sideload/AndroidManifest.xml") + getByName("release").manifest.srcFile( + if (buildingPlayReleaseBundle) { + "src/playBundle/AndroidManifest.xml" + } else { + "src/sideload/AndroidManifest.xml" + }, + ) + } + + buildFeatures { + compose = true + buildConfig = true + } + + externalNativeBuild { + cmake { + path = file("src/main/cpp/CMakeLists.txt") + version = "3.22.1" + } + } + + packaging { + jniLibs { + useLegacyPackaging = false + } + resources { + excludes += setOf( + "META-INF/AL2.0", + "META-INF/LGPL2.1", + "META-INF/LICENSE*", + "META-INF/NOTICE*", + ) + } + } + + lint { + checkReleaseBuilds = false + } +} + +kotlin { + jvmToolchain(17) + compilerOptions { + // K2's FIR data-flow analysis is pathological on the streaming state machine. Kotlin's + // supported 1.9 language mode keeps the stable frontend until that class is fully decomposed. + languageVersion.set(org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9) + // This app is one large Kotlin module. Parallelize JVM code generation so cold builds + // do not leave the machine idling on a single backend thread. + freeCompilerArgs.add("-Xbackend-threads=0") + } +} + +dependencies { + implementation(platform("androidx.compose:compose-bom:2026.08.00")) + androidTestImplementation(platform("androidx.compose:compose-bom:2026.08.00")) + + implementation("androidx.activity:activity-compose:1.13.0") + implementation("androidx.browser:browser:1.10.0") + // Play App Update still requests Fragment 1.0.0 transitively through Play Services. + implementation("androidx.fragment:fragment:1.9.0") + implementation("androidx.compose.foundation:foundation") + implementation("androidx.compose.material:material-icons-extended") + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.core:core:1.19.0") + implementation("androidx.lifecycle:lifecycle-runtime-compose:2.11.0") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.11.0") + implementation("androidx.media3:media3-common:1.11.0") + implementation("androidx.media3:media3-exoplayer:1.11.0") + implementation("androidx.media3:media3-ui:1.11.0") + implementation("androidx.work:work-runtime:2.11.2") + + implementation("io.coil-kt.coil3:coil-compose:3.5.0") + implementation("io.coil-kt.coil3:coil-network-okhttp:3.5.0") + + implementation("com.squareup.okhttp3:logging-interceptor:5.5.0") + implementation("com.squareup.okhttp3:okhttp-dnsoverhttps:5.5.0") + implementation("com.squareup.okhttp3:okhttp:5.5.0") + implementation("com.google.android.play:app-update:2.1.0") + implementation("com.google.android.gms:play-services-code-scanner:16.1.0") + implementation("com.google.mlkit:language-id:17.0.6") + // Includes upstream Android AudioRecord restart and stopped-transceiver stats crash fixes. + implementation("io.github.webrtc-sdk:android:144.7559.14") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0") + implementation("com.posthog:posthog-android:3.60.7") + + debugImplementation("androidx.compose.ui:ui-tooling") + debugImplementation("androidx.compose.ui:ui-test-manifest") + + testImplementation("junit:junit:4.13.2") + androidTestImplementation("androidx.test.ext:junit:1.3.0") + androidTestImplementation("androidx.test:runner:1.7.0") + androidTestImplementation("androidx.test.espresso:espresso-core:3.7.0") + androidTestImplementation("androidx.compose.ui:ui-test-junit4") +} diff --git a/android/app/licenses/Inter-OFL-1.1.txt b/android/app/licenses/Inter-OFL-1.1.txt new file mode 100644 index 000000000..9b2ca37b3 --- /dev/null +++ b/android/app/licenses/Inter-OFL-1.1.txt @@ -0,0 +1,92 @@ +Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION AND CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 000000000..acb51b404 --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,8 @@ +-keep class org.webrtc.** { *; } +-keep class org.jni_zero.** { *; } +-keep class kotlinx.serialization.** { *; } +-keep class com.google.mlkit.common.internal.CommonComponentRegistrar { *; } +-keep class com.google.mlkit.common.sdkinternal.SharedPrefManager { *; } +-keepclassmembers class com.opencloudgaming.opennow.** { + @kotlinx.serialization.Serializable *; +} diff --git a/android/app/src/androidTest/java/com/opencloudgaming/opennow/LocalTvConnectorInstrumentedTest.kt b/android/app/src/androidTest/java/com/opencloudgaming/opennow/LocalTvConnectorInstrumentedTest.kt new file mode 100644 index 000000000..5197405f7 --- /dev/null +++ b/android/app/src/androidTest/java/com/opencloudgaming/opennow/LocalTvConnectorInstrumentedTest.kt @@ -0,0 +1,129 @@ +package com.opencloudgaming.opennow + +import android.net.Uri +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import java.net.DatagramSocket + +@RunWith(AndroidJUnit4::class) +class LocalTvConnectorInstrumentedTest { + @Test + fun localDiscoveryFindsTheTvWithoutBroadcastingItsPairingCode() = runBlocking { + val tv = LocalTvConnector() + val phone = LocalTvConnector() + try { + tv.startHosting() + val pairingState = awaitState(tv) { it.hosting && it.pairUri != null } + + phone.discoverTvs() + val discoveryState = awaitState(phone) { it.discoveryCompleted || it.error != null } + assertTrue(discoveryState.error.orEmpty(), discoveryState.discoveredTvs.isNotEmpty()) + val discoveredTv = discoveryState.discoveredTvs.first() + assertTrue(!discoveredTv.pairUri.contains("c=")) + + phone.pairDiscoveredTv(discoveredTv, pairingState.pairingCode!!) + val phoneState = awaitState(phone) { it.phoneConnected || it.error != null } + assertTrue(phoneState.error.orEmpty(), phoneState.phoneConnected) + } finally { + phone.close() + tv.close() + } + } + + @Test + fun encryptedLocalPairingTransfersLaunchAndSignIn() = runBlocking { + val tv = LocalTvConnector() + val phone = LocalTvConnector() + try { + tv.startHosting() + val pairingState = awaitState(tv) { it.hosting && it.pairUri != null } + assertTrue(pairingState.pairingCode.orEmpty().matches(Regex("[0-9]{4}"))) + val pairUri = pairingState.pairUri!! + + phone.pairPhone(Uri.parse(pairUri)) + val phoneState = awaitState(phone) { it.phoneConnected || it.error != null } + assertTrue(phoneState.error.orEmpty(), phoneState.phoneConnected) + val pairedState = awaitState(tv) { it.pairedDeviceName != null } + assertTrue(pairedState.trustRequestedByDevice) + + val launchResult = async { withTimeout(5_000L) { tv.launchRequests.first() } } + phone.sendLaunch("test-game-42", "Connector test game") + assertEquals("test-game-42", launchResult.await().gameId) + + phone.sendRemoteAction("open_stream_menu") + val untrustedState = awaitState(phone) { it.error?.contains("Trust this phone") == true } + assertTrue(untrustedState.error.orEmpty(), untrustedState.error?.contains("Trust this phone") == true) + + tv.setPairedDeviceTrusted(true) + val remoteResult = async { withTimeout(5_000L) { tv.remoteRequests.first() } } + phone.sendRemoteAction("open_stream_menu") + assertEquals("open_stream_menu", remoteResult.await().action) + + val transferred = AuthSession( + provider = LoginProvider( + idpId = "test", + code = "TEST", + displayName = "Test provider", + streamingServiceUrl = "https://example.invalid", + ), + tokens = AuthTokens( + accessToken = "instrumentation-access-token", + refreshToken = "instrumentation-refresh-token", + expiresAt = System.currentTimeMillis() + 60_000L, + ), + user = AuthUser( + userId = "instrumentation-user", + displayName = "Instrumentation user", + membershipTier = "FREE", + ), + ) + val signInResult = async { withTimeout(5_000L) { tv.signInRequests.first() } } + phone.sendSignIn(transferred) + assertEquals(transferred, signInResult.await()) + } finally { + phone.close() + tv.close() + } + } + + @Test + fun unavailableDiscoveryPortDoesNotStopDirectPairing() = runBlocking { + DatagramSocket(0).use { occupiedDiscoverySocket -> + val tv = LocalTvConnector(discoveryPort = occupiedDiscoverySocket.localPort) + val phone = LocalTvConnector() + try { + tv.startHosting() + val pairingState = awaitState(tv) { it.hosting && it.pairUri != null } + val degradedState = awaitState(tv) { it.message?.contains("discovery is unavailable") == true } + assertTrue(degradedState.hosting) + + phone.pairPhone(Uri.parse(pairingState.pairUri!!)) + val phoneState = awaitState(phone) { it.phoneConnected || it.error != null } + assertTrue(phoneState.error.orEmpty(), phoneState.phoneConnected) + } finally { + phone.close() + tv.close() + } + } + } + + private suspend fun awaitState( + connector: LocalTvConnector, + predicate: (LocalTvConnectorState) -> Boolean, + ): LocalTvConnectorState = withTimeout(8_000L) { + while (true) { + connector.state.value.takeIf(predicate)?.let { return@withTimeout it } + delay(25L) + } + @Suppress("UNREACHABLE_CODE") + connector.state.value + } +} diff --git a/android/app/src/androidTest/java/com/opencloudgaming/opennow/StreamingProfileInstrumentedTest.kt b/android/app/src/androidTest/java/com/opencloudgaming/opennow/StreamingProfileInstrumentedTest.kt new file mode 100644 index 000000000..3cdd3ed72 --- /dev/null +++ b/android/app/src/androidTest/java/com/opencloudgaming/opennow/StreamingProfileInstrumentedTest.kt @@ -0,0 +1,67 @@ +package com.opencloudgaming.opennow + +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class StreamingProfileInstrumentedTest { + @Test + fun commonResolutionAspectAndCodecMatrixResolvesToUsableHardwareProfile() { + val report = CodecProbe.report(ApplicationProvider.getApplicationContext()) + val h264 = report.capabilities.firstOrNull { it.codec == VideoCodec.H264 } + assertNotNull("Every supported Android target must provide H264 decoding", h264) + assertTrue("H264 must be usable by the WebRTC launch path", h264?.streamingDecoderUsableForLaunch() == true) + + val modes = listOf( + "1280x720" to "16:9", + "1366x768" to "16:9", + "1600x900" to "16:9", + "1920x1080" to "16:9", + "2560x1440" to "16:9", + "3840x2160" to "16:9", + "1280x800" to "16:10", + "1920x1200" to "16:10", + "2560x1600" to "16:10", + "1024x768" to "4:3", + "2340x1080" to "19.5:9", + "1680x720" to "21:9", + "2560x1080" to "21:9", + "3440x1440" to "21:9", + ) + for ((resolution, aspectRatio) in modes) { + for (codec in VideoCodec.entries) { + val requested = StreamSettings( + resolution = resolution, + aspectRatio = aspectRatio, + fps = 60, + codec = codec, + colorQuality = if (codec == VideoCodec.H264) ColorQuality.EightBit420 else ColorQuality.TenBit420, + ) + val adjusted = requested.adjustedForDevice(report) + val effectiveCapability = report.capabilities.first { it.codec == adjusted.codec } + + assertTrue("$resolution $codec did not resolve to a usable decoder", effectiveCapability.streamingDecoderUsableForLaunch()) + assertEquals( + "$resolution $codec resolved with inconsistent aspect metadata", + streamAspectRatioForResolution(adjusted.resolution), + adjusted.aspectRatio, + ) + assertEquals( + "$resolution $codec was unnecessarily reduced despite explicit decoder support", + resolution, + adjusted.resolution, + ) + println( + "STREAM_MATRIX requested=$resolution/$aspectRatio/$codec effective=${adjusted.resolution}/${adjusted.aspectRatio}/${adjusted.codec} " + + "fps=${adjusted.fps} decoder=${effectiveCapability.webRtcDecoderName ?: effectiveCapability.decoderName} " + + "max=${effectiveCapability.maxSupportedWidth}x${effectiveCapability.maxSupportedHeight}", + ) + } + } + } +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 000000000..bd6b8faf8 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,117 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/cpp/CMakeLists.txt b/android/app/src/main/cpp/CMakeLists.txt new file mode 100644 index 000000000..9fa05aa75 --- /dev/null +++ b/android/app/src/main/cpp/CMakeLists.txt @@ -0,0 +1,22 @@ +cmake_minimum_required(VERSION 3.22.1) + +project(opennow_native) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +add_library(opennow_native SHARED opennow_native.cpp) + +target_compile_options(opennow_native PRIVATE -fexceptions -frtti) + +find_library(log-lib log) +find_library(android-lib android) +find_library(mediandk-lib mediandk) + +target_link_libraries( + opennow_native + ${android-lib} + ${log-lib} + ${mediandk-lib} +) diff --git a/android/app/src/main/cpp/opennow_native.cpp b/android/app/src/main/cpp/opennow_native.cpp new file mode 100644 index 000000000..2b3041670 --- /dev/null +++ b/android/app/src/main/cpp/opennow_native.cpp @@ -0,0 +1,39 @@ +#include +#include +#include +#include +#include + +extern "C" JNIEXPORT jstring JNICALL +Java_com_opencloudgaming_opennow_NativeCodecProbe_nativeRuntimeSummary(JNIEnv *env, jobject) { + std::ostringstream out; + out << "{"; + out << "\"nativeLibrary\":\"opennow_native\","; + out << "\"mediaNdk\":true,"; + out << "\"rtpPacketSize\":1140,"; + out << "\"inputProtocolVersion\":3"; + out << "}"; + const std::string value = out.str(); + return env->NewStringUTF(value.c_str()); +} + +extern "C" JNIEXPORT jboolean JNICALL +Java_com_opencloudgaming_opennow_NativeCodecProbe_nativeDecoderAvailable(JNIEnv *env, jobject, jstring mimeType) { + if (mimeType == nullptr) { + return JNI_FALSE; + } + + const char *rawMimeType = env->GetStringUTFChars(mimeType, nullptr); + if (rawMimeType == nullptr) { + return JNI_FALSE; + } + + AMediaCodec *codec = AMediaCodec_createDecoderByType(rawMimeType); + env->ReleaseStringUTFChars(mimeType, rawMimeType); + if (codec == nullptr) { + return JNI_FALSE; + } + + AMediaCodec_delete(codec); + return JNI_TRUE; +} diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/AmbientBackground.kt b/android/app/src/main/java/com/opencloudgaming/opennow/AmbientBackground.kt new file mode 100644 index 000000000..5ddec800d --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/AmbientBackground.kt @@ -0,0 +1,131 @@ +package com.opencloudgaming.opennow + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.translate +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.sin + +/** + * A very slow, very quiet wash of colour behind the app when no wallpaper is set. + * + * Without a backdrop every page was a single flat fill, which reads as unfinished on a large phone + * and on TV. This fills that gap without competing with anything: three wide radial blooms in the + * accent, each under 7% alpha. It is deliberately not the Absolute Cinema treatment — that is an + * interaction effect with somewhere specific to point, and this is wallpaper. + * + * This used to move the blooms on a 150-second infinite transition. The movement was nearly + * imperceptible, but Compose still redrew three screen-sized radial shaders at every display + * vsync. On a 120 Hz phone that consumed GPU time continuously behind the Store and Settings and + * left less frame budget for scrolling, image upload, and route motion. A fixed phase preserves the + * same depth without keeping the whole app in a permanent animation. + * + * The three brushes are cached and redraw only when their size or theme colours change. + */ +@Composable +internal fun AmbientBackground(modifier: Modifier = Modifier) { + val accent = MaterialTheme.colorScheme.primary + val secondary = MaterialTheme.colorScheme.secondary + + Box( + modifier + .fillMaxSize() + // drawWithCache, not drawBehind: the gradient shaders depend only on the size and the + // accent, so they are built once per resize rather than on every parent invalidation. + .drawWithCache { + val radii = AMBIENT_BLOOMS.map { size.minDimension * it.radiusFactor } + val brushes = AMBIENT_BLOOMS.mapIndexed { index, bloom -> + val color = if (bloom.usesSecondary) secondary else accent + Brush.radialGradient( + colors = listOf(color.copy(alpha = bloom.alpha), Color.Transparent), + center = Offset.Zero, + radius = radii[index].coerceAtLeast(1f), + ) + } + onDrawBehind { + AMBIENT_BLOOMS.forEachIndexed { index, bloom -> + val radius = radii[index] + if (radius <= 0f) return@forEachIndexed + val center = ambientBloomCenter(bloom, AMBIENT_STATIC_PHASE, size) + translate(center.x, center.y) { + drawCircle(brush = brushes[index], radius = radius, center = Offset.Zero) + } + } + } + }, + ) +} + +/** + * One drifting bloom. + * + * [speed] is deliberately irrational relative to the others so the three never line back up into a + * visible loop — the pattern a viewer would notice is repetition, not motion. + */ +internal data class AmbientBloom( + val originX: Float, + val originY: Float, + val travelX: Float, + val travelY: Float, + val speed: Float, + val phaseOffset: Float, + val radiusFactor: Float, + val alpha: Float, + val usesSecondary: Boolean, +) + +internal fun ambientBloomCenter(bloom: AmbientBloom, phase: Float, size: Size): Offset { + val angle = (phase * bloom.speed + bloom.phaseOffset) * 2f * PI.toFloat() + return Offset( + x = size.width * (bloom.originX + bloom.travelX * sin(angle)), + y = size.height * (bloom.originY + bloom.travelY * cos(angle)), + ) +} + +/** A point in the old motion cycle where all three blooms are spread out. */ +private const val AMBIENT_STATIC_PHASE = 0.22f + +private val AMBIENT_BLOOMS = listOf( + AmbientBloom( + originX = 0.22f, + originY = 0.18f, + travelX = 0.10f, + travelY = 0.07f, + speed = 1f, + phaseOffset = 0f, + radiusFactor = 0.85f, + alpha = 0.065f, + usesSecondary = false, + ), + AmbientBloom( + originX = 0.82f, + originY = 0.34f, + travelX = 0.09f, + travelY = 0.10f, + speed = 0.61f, + phaseOffset = 0.37f, + radiusFactor = 0.70f, + alpha = 0.050f, + usesSecondary = true, + ), + AmbientBloom( + originX = 0.48f, + originY = 0.88f, + travelX = 0.12f, + travelY = 0.06f, + speed = 0.41f, + phaseOffset = 0.71f, + radiusFactor = 0.95f, + alpha = 0.045f, + usesSecondary = false, + ), +) diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/AndroidAppDataReset.kt b/android/app/src/main/java/com/opencloudgaming/opennow/AndroidAppDataReset.kt new file mode 100644 index 000000000..981be20b5 --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/AndroidAppDataReset.kt @@ -0,0 +1,88 @@ +package com.opencloudgaming.opennow + +import android.app.AlarmManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.Process +import android.os.SystemClock +import android.util.Log +import java.io.File +import kotlin.system.exitProcess + +private const val APP_DATA_RESET_LOG_TAG = "OpenNOW.AppDataReset" +private const val APP_DATA_RESET_RELAUNCH_DELAY_MS = 500L +private const val APP_DATA_RESET_RELAUNCH_REQUEST_CODE = 1007 + +internal fun wipeAppDataAndRelaunch(context: Context): Nothing { + val appContext = context.applicationContext + val relaunchIntent = buildAppRelaunchIntent(appContext) + val relaunchPendingIntent = PendingIntent.getActivity( + appContext, + APP_DATA_RESET_RELAUNCH_REQUEST_CODE, + relaunchIntent, + PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + val alarmManager = appContext.getSystemService(AlarmManager::class.java) + alarmManager?.set( + AlarmManager.ELAPSED_REALTIME, + SystemClock.elapsedRealtime() + APP_DATA_RESET_RELAUNCH_DELAY_MS, + relaunchPendingIntent, + ) + clearAppDataDirectories(appContext) + Process.killProcess(Process.myPid()) + exitProcess(0) +} + +private fun buildAppRelaunchIntent(context: Context): Intent = + (context.packageManager.getLaunchIntentForPackage(context.packageName) ?: Intent(context, MainActivity::class.java)).apply { + action = Intent.ACTION_MAIN + addCategory(Intent.CATEGORY_LAUNCHER) + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + } + +/** + * [Context.getDataDir] is API 24. On API 23 the same directory is the parent of `filesDir`, which + * keeps the destructive reset path from throwing NoSuchMethodError instead of clearing data. + */ +private val Context.appDataDirCompat: File + get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + dataDir + } else { + filesDir.parentFile ?: filesDir + } + +private fun clearAppDataDirectories(context: Context) { + val dataDir = context.appDataDirCompat + val children = dataDir.listFiles().orEmpty() + if (children.isEmpty()) { + deleteKnownAppDataDirectories(context) + return + } + children.forEach { child -> + if (child.name == "lib") return@forEach + deleteAppDataPath(child) + } +} + +private fun deleteKnownAppDataDirectories(context: Context) { + listOf( + context.filesDir, + context.cacheDir, + context.codeCacheDir, + context.noBackupFilesDir, + File(context.appDataDirCompat, "shared_prefs"), + File(context.appDataDirCompat, "databases"), + ).forEach(::deleteAppDataPath) +} + +private fun deleteAppDataPath(path: File?) { + if (path == null || !path.exists()) return + val deleted = runCatching { path.deleteRecursively() } + .onFailure { error -> Log.w(APP_DATA_RESET_LOG_TAG, "Failed to delete ${path.name}", error) } + .getOrDefault(false) + if (!deleted && path.exists()) { + Log.w(APP_DATA_RESET_LOG_TAG, "Failed to delete ${path.name}") + } +} diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/AndroidAppLocale.kt b/android/app/src/main/java/com/opencloudgaming/opennow/AndroidAppLocale.kt new file mode 100644 index 000000000..0aae7d420 --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/AndroidAppLocale.kt @@ -0,0 +1,153 @@ +package com.opencloudgaming.opennow + +import android.app.Activity +import android.app.LocaleManager +import android.content.Context +import android.content.ContextWrapper +import android.content.res.Configuration +import android.content.res.Resources +import android.os.Build +import android.os.LocaleList +import java.util.Locale + +internal const val ANDROID_APP_LANGUAGE_SYSTEM = "" +internal const val ANDROID_APP_LANGUAGE_ENGLISH = "en" +internal const val ANDROID_APP_LANGUAGE_ARABIC = "ar" +internal const val ANDROID_APP_LANGUAGE_GERMAN = "de" +internal const val ANDROID_APP_LANGUAGE_SPANISH = "es" +internal const val ANDROID_APP_LANGUAGE_FRENCH = "fr" +internal const val ANDROID_APP_LANGUAGE_JAPANESE = "ja" +internal const val ANDROID_APP_LANGUAGE_KOREAN = "ko" +internal const val ANDROID_APP_LANGUAGE_DUTCH = "nl" +internal const val ANDROID_APP_LANGUAGE_POLISH = "pl" +internal const val ANDROID_APP_LANGUAGE_PORTUGUESE = "pt" +internal const val ANDROID_APP_LANGUAGE_ROMANIAN = "ro" +internal const val ANDROID_APP_LANGUAGE_RUSSIAN = "ru" +internal const val ANDROID_APP_LANGUAGE_TURKISH = "tr" +internal const val ANDROID_APP_LANGUAGE_SIMPLIFIED_CHINESE = "zh-Hans" + +internal val ANDROID_APP_LANGUAGE_TAGS = setOf( + ANDROID_APP_LANGUAGE_ENGLISH, + ANDROID_APP_LANGUAGE_ARABIC, + ANDROID_APP_LANGUAGE_GERMAN, + ANDROID_APP_LANGUAGE_SPANISH, + ANDROID_APP_LANGUAGE_FRENCH, + ANDROID_APP_LANGUAGE_JAPANESE, + ANDROID_APP_LANGUAGE_KOREAN, + ANDROID_APP_LANGUAGE_DUTCH, + ANDROID_APP_LANGUAGE_POLISH, + ANDROID_APP_LANGUAGE_PORTUGUESE, + ANDROID_APP_LANGUAGE_ROMANIAN, + ANDROID_APP_LANGUAGE_RUSSIAN, + ANDROID_APP_LANGUAGE_TURKISH, + ANDROID_APP_LANGUAGE_SIMPLIFIED_CHINESE, +) + +internal data class AndroidAppLocaleState( + val selectedLanguageTag: String, + val effectiveLanguageTag: String, + val deviceLanguageTag: String = effectiveLanguageTag, +) { + val bugReportsAllowed: Boolean + get() = androidAppLocaleIsEnglish(selectedLanguageTag) || androidAppLocaleIsEnglish(deviceLanguageTag) + + val bugReportLanguageTag: String? + get() = when { + androidAppLocaleIsEnglish(selectedLanguageTag) -> selectedLanguageTag + androidAppLocaleIsEnglish(deviceLanguageTag) -> deviceLanguageTag + else -> null + } +} + +/** + * [Configuration.getLocales] landed in API 24, but this module ships to API 23. Reading the + * deprecated single-locale field below that level keeps locale detection — which runs on the + * startup path — from throwing NoSuchMethodError on the oldest supported devices. + */ +private val Configuration.primaryLocale: Locale? + get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + locales.get(0) + } else { + @Suppress("DEPRECATION") + locale + } + +internal fun currentAndroidAppLocale(context: Context): AndroidAppLocaleState { + val selected = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + context.getSystemService(LocaleManager::class.java) + ?.applicationLocales + ?.get(0) + ?.toLanguageTag() + .orEmpty() + } else { + context.getSharedPreferences(APP_LOCALE_PREFERENCES, Context.MODE_PRIVATE) + .getString(APP_LOCALE_LANGUAGE_TAG, null) + .orEmpty() + } + val effective = selected.ifBlank { + context.resources.configuration.primaryLocale?.toLanguageTag().orEmpty() + } + val device = (if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + context.getSystemService(LocaleManager::class.java) + ?.systemLocales + ?.get(0) + ?.toLanguageTag() + .orEmpty() + } else { + Resources.getSystem().configuration.primaryLocale?.toLanguageTag().orEmpty() + }).ifBlank { + Resources.getSystem().configuration.primaryLocale?.toLanguageTag().orEmpty() + } + return AndroidAppLocaleState( + selectedLanguageTag = selected, + effectiveLanguageTag = effective, + deviceLanguageTag = device, + ) +} + +internal fun androidAppLocaleIsEnglish(languageTag: String): Boolean = + Locale.forLanguageTag(languageTag.replace('_', '-')).language.equals("en", ignoreCase = true) + +internal fun androidAppLanguageSelectionIsSupported(languageTag: String): Boolean { + val normalized = languageTag.trim().replace('_', '-') + return normalized.isBlank() || normalized in ANDROID_APP_LANGUAGE_TAGS +} + +internal fun setAndroidAppLanguage(context: Context, languageTag: String) { + val normalized = languageTag.trim().replace('_', '-') + require(androidAppLanguageSelectionIsSupported(normalized)) { + "Unsupported OpenNOW Android app language: $normalized" + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + context.getSystemService(LocaleManager::class.java)?.applicationLocales = + LocaleList.forLanguageTags(normalized) + return + } + context.getSharedPreferences(APP_LOCALE_PREFERENCES, Context.MODE_PRIVATE) + .edit() + .putString(APP_LOCALE_LANGUAGE_TAG, normalized) + .apply() + context.findActivity()?.recreate() +} + +/** Applies the in-app language choice on Android 12 and older. Android 13+ owns this context. */ +internal fun localizedAndroidContext(base: Context): Context { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) return base + val languageTag = base.getSharedPreferences(APP_LOCALE_PREFERENCES, Context.MODE_PRIVATE) + .getString(APP_LOCALE_LANGUAGE_TAG, null) + .orEmpty() + if (languageTag.isBlank()) return base + val configuration = Configuration(base.resources.configuration).apply { + setLocale(Locale.forLanguageTag(languageTag)) + } + return base.createConfigurationContext(configuration) +} + +private tailrec fun Context.findActivity(): Activity? = when (this) { + is Activity -> this + is ContextWrapper -> baseContext.findActivity() + else -> null +} + +private const val APP_LOCALE_PREFERENCES = "opennow_app_locale" +private const val APP_LOCALE_LANGUAGE_TAG = "language_tag" diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/AndroidAuthRefresh.kt b/android/app/src/main/java/com/opencloudgaming/opennow/AndroidAuthRefresh.kt new file mode 100644 index 000000000..ac72502c9 --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/AndroidAuthRefresh.kt @@ -0,0 +1,113 @@ +package com.opencloudgaming.opennow + +import android.content.Context +import android.util.Log +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +import androidx.work.PeriodicWorkRequest +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import kotlinx.coroutines.CancellationException +import java.util.concurrent.TimeUnit + +private const val AUTH_REFRESH_WORK_NAME = "opennow-auth-token-refresh" +private const val AUTH_REFRESH_LOG_TAG = "OpenNOWAuthRefresh" +private const val AUTH_REFRESH_INTERVAL_MINUTES = 15L +private const val AUTH_REFRESH_FLEX_MINUTES = 5L +private const val AUTH_REFRESH_BACKOFF_MINUTES = 5L + +internal fun AuthTokens.needsBackgroundRefresh(nowMs: Long = System.currentTimeMillis()): Boolean = + expiresAt - nowMs < TOKEN_REFRESH_WINDOW_MS || + clientToken.isNullOrBlank() || + clientTokenExpiresAt == null || + clientTokenExpiresAt - nowMs < CLIENT_TOKEN_REFRESH_WINDOW_MS + +internal fun authenticationRefreshClientIds( + savedClientId: String?, + browserClientId: String, + deviceClientId: String, +): List = + listOfNotNull( + savedClientId?.takeIf(String::isNotBlank), + browserClientId, + deviceClientId, + ).distinct() + +/** + * A best-effort refresh must never crash an unrelated authenticated action. The existing session + * can still produce the normal provider/API error, while coroutine cancellation must keep its + * structured-concurrency semantics. + */ +internal suspend fun refreshedSessionOrFallback( + fallback: AuthSession, + refresh: suspend () -> AuthSession?, + onFailure: (Throwable) -> Unit = {}, +): AuthSession = + try { + refresh() ?: fallback + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + onFailure(error) + fallback + } + +internal object AndroidAuthRefreshScheduler { + fun schedule(context: Context) { + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + val request = PeriodicWorkRequest.Builder( + AndroidAuthRefreshWorker::class.java, + AUTH_REFRESH_INTERVAL_MINUTES, + TimeUnit.MINUTES, + AUTH_REFRESH_FLEX_MINUTES, + TimeUnit.MINUTES, + ) + .setConstraints(constraints) + .setBackoffCriteria( + BackoffPolicy.EXPONENTIAL, + AUTH_REFRESH_BACKOFF_MINUTES, + TimeUnit.MINUTES, + ) + .build() + WorkManager.getInstance(context.applicationContext).enqueueUniquePeriodicWork( + AUTH_REFRESH_WORK_NAME, + ExistingPeriodicWorkPolicy.UPDATE, + request, + ) + } +} + +internal class AndroidAuthRefreshWorker( + appContext: Context, + workerParams: WorkerParameters, +) : CoroutineWorker(appContext, workerParams) { + override suspend fun doWork(): Result { + val application = applicationContext as OpenNowApplication + val authStore = application.authStore + val activeSession = authStore.reload().let { state -> + state.sessions.firstOrNull { it.user.userId == state.activeUserId } + ?: state.sessions.firstOrNull() + } ?: return Result.success() + if (!activeSession.tokens.needsBackgroundRefresh()) return Result.success() + + return runCatching { + val refreshed = application.authRepository.restore( + throwOnRefreshFailure = true, + removeExpiredSessionOnFailure = false, + ) + if (refreshed?.tokens?.needsBackgroundRefresh() == true) { + Result.retry() + } else { + Result.success() + } + }.getOrElse { error -> + Log.w(AUTH_REFRESH_LOG_TAG, "Background token refresh failed; retrying", error) + Result.retry() + } + } +} diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/AndroidDeveloperOptions.kt b/android/app/src/main/java/com/opencloudgaming/opennow/AndroidDeveloperOptions.kt new file mode 100644 index 000000000..b5f80ecdd --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/AndroidDeveloperOptions.kt @@ -0,0 +1,158 @@ +package com.opencloudgaming.opennow + +/** + * Developer options: unlocking them, and the state each action resets. + * + * The Compose layer owns presentation only. Everything that decides *when* the section appears and + * *what* an action writes back to [AppSettings] lives here so it can be unit tested without a + * device, the same split `AndroidSetupFlow.kt` uses for first-run setup. + * + * Nothing here unlocks paid entitlements, bypasses the provider, or changes what OpenNOW reports + * about the user. Every action either resets local state the user already controls elsewhere or + * shows information already available in the diagnostics export. + */ + +/** Taps on the build row needed to reveal developer options. Matches the Android platform gesture. */ +internal const val DEVELOPER_OPTIONS_TAP_COUNT = 10 + +/** + * Taps are only counted down out loud once the user is clearly not just double-tapping the row. + * Android shows nothing for the first few, then counts the rest in — copying that avoids telling + * every user who taps a version number twice that a hidden menu exists. + */ +internal const val DEVELOPER_OPTIONS_TAP_COUNTDOWN_FROM = 7 + +internal sealed interface DeveloperOptionsTapResult { + /** Not far enough along to say anything. */ + data object Silent : DeveloperOptionsTapResult + + /** [remaining] more taps to go, and the user should be told. */ + data class Countdown(val remaining: Int) : DeveloperOptionsTapResult + + /** This tap crossed the threshold. */ + data object Unlocked : DeveloperOptionsTapResult + + /** Already unlocked before this tap. */ + data object AlreadyUnlocked : DeveloperOptionsTapResult +} + +/** + * Resolves one tap on the build row. + * + * [tapCount] is the running total *including* this tap. + */ +internal fun developerOptionsTapResult( + tapCount: Int, + alreadyUnlocked: Boolean, +): DeveloperOptionsTapResult { + if (alreadyUnlocked) return DeveloperOptionsTapResult.AlreadyUnlocked + if (tapCount >= DEVELOPER_OPTIONS_TAP_COUNT) return DeveloperOptionsTapResult.Unlocked + val remaining = DEVELOPER_OPTIONS_TAP_COUNT - tapCount + return if (tapCount >= DEVELOPER_OPTIONS_TAP_COUNTDOWN_FROM) { + DeveloperOptionsTapResult.Countdown(remaining) + } else { + DeveloperOptionsTapResult.Silent + } +} + +internal fun AppSettings.unlockingDeveloperOptions(): AppSettings = + if (developerOptionsUnlocked) this else copy(developerOptionsUnlocked = true) + +internal fun AppSettings.lockingDeveloperOptions(): AppSettings = + if (!developerOptionsUnlocked) this else copy(developerOptionsUnlocked = false) + +// --------------------------------------------------------------------------- +// Flow resets +// --------------------------------------------------------------------------- + +/** Shows the stream guide again on the next launch. */ +internal fun AppSettings.resettingStreamGuide(): AppSettings = + copy(androidStreamGuideDismissed = false) + +/** Shows the "connect a controller" prompt again. */ +internal fun AppSettings.resettingControllerPrompt(): AppSettings = + copy(androidPhysicalControllerPromptDismissed = false) + +/** + * Puts the analytics consent question back. + * + * Deliberately also opts out until it is answered again: re-asking while still counted as + * consenting would make the prompt cosmetic. + */ +internal fun AppSettings.resettingAnalyticsConsent(): AppSettings = + copy(analyticsConsentAsked = false, analyticsOptOut = true) + +/** + * Replays the one-time migrations that run on upgrade. + * + * These versions gate the presentation and TV-layout defaults applied once per install, so zeroing + * them is how a developer re-tests an upgrade path without wiping the whole profile. + */ +internal fun AppSettings.resettingProfileMigrations(): AppSettings = + copy(streamPresentationProfileVersion = 0, tvLayoutProfileVersion = 0) + +// --------------------------------------------------------------------------- +// Catalogue resets +// --------------------------------------------------------------------------- + +/** Store and Library back to their default ordering with every filter cleared. */ +internal fun AppSettings.resettingCatalogBrowsing(): AppSettings = + copy( + catalogSortId = AppSettings().catalogSortId, + catalogFilterIds = emptyList(), + librarySortId = AppSettings().librarySortId, + libraryFilterIds = emptyList(), + ) + +internal fun AppSettings.clearingFavorites(): AppSettings = copy(favoriteGameIds = emptyList()) + +/** Drops every remembered "always launch this game from this store" choice. */ +internal fun AppSettings.clearingStorePreferences(): AppSettings = copy(defaultGameVariantIds = emptyMap()) + +internal fun AppSettings.clearingLocalAppShelf(): AppSettings = copy(localAppPackageNames = emptyList()) + +// --------------------------------------------------------------------------- +// Interface and input resets +// --------------------------------------------------------------------------- + +/** Every appearance choice back to the shipped defaults, leaving account and stream alone. */ +internal fun AppSettings.resettingInterface(): AppSettings { + val defaults = AppSettings() + return copy( + uiAccent = defaults.uiAccent, + dynamicColor = defaults.dynamicColor, + expressiveUi = defaults.expressiveUi, + absoluteCinemaEffects = defaults.absoluteCinemaEffects, + absoluteCinemaEverywhere = defaults.absoluteCinemaEverywhere, + liveSelectedOutlines = defaults.liveSelectedOutlines, + controllerBackgroundAnimations = defaults.controllerBackgroundAnimations, + nerdCatalogBackground = defaults.nerdCatalogBackground, + ambientBackgroundEnabled = defaults.ambientBackgroundEnabled, + catalogBackgroundPreset = defaults.catalogBackgroundPreset, + nerdCatalogBackgroundUri = null, + compactGameCards = defaults.compactGameCards, + showCardTitles = defaults.showCardTitles, + showFavoriteIconOnGameCards = defaults.showFavoriteIconOnGameCards, + posterSizeScale = defaults.posterSizeScale, + launchPage = defaults.launchPage, + tvSafeAreaPaddingDp = defaults.tvSafeAreaPaddingDp, + ) +} + +/** Touch overlay geometry back to the shipped layout, keeping the rest of the input settings. */ +internal fun AppSettings.resettingTouchLayout(): AppSettings = + copy(androidTouch = androidTouch.withResetOffsets()) + +/** + * Every state a first-run install starts from, short of signing out. + * + * Used by the single "replay first launch" action so a developer does not have to press eight + * separate resets to reproduce what a new user sees. + */ +internal fun AppSettings.replayingFirstLaunch(): AppSettings = + restartingSetupFlow() + .resettingStreamGuide() + .resettingControllerPrompt() + .resettingAnalyticsConsent() + .resettingProfileMigrations() + .resettingCatalogBrowsing() diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/AndroidDisplayResolution.kt b/android/app/src/main/java/com/opencloudgaming/opennow/AndroidDisplayResolution.kt new file mode 100644 index 000000000..39759cdf3 --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/AndroidDisplayResolution.kt @@ -0,0 +1,20 @@ +package com.opencloudgaming.opennow + +import android.content.Context +import android.hardware.display.DisplayManager +import android.view.Display + +/** + * Returns the real display-mode geometry in the landscape orientation used for streaming. + * This is intentionally separate from the requested logical stream resolution. + */ +internal fun Context.physicalStreamDisplayResolution(): Pair? { + val display = getSystemService(DisplayManager::class.java) + ?.getDisplay(Display.DEFAULT_DISPLAY) + ?: return null + val mode = display.mode + val width = mode.physicalWidth + val height = mode.physicalHeight + if (width <= 0 || height <= 0) return null + return maxOf(width, height) to minOf(width, height) +} diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/AndroidLocalNetworkAccess.kt b/android/app/src/main/java/com/opencloudgaming/opennow/AndroidLocalNetworkAccess.kt new file mode 100644 index 000000000..9e7a4a35b --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/AndroidLocalNetworkAccess.kt @@ -0,0 +1,17 @@ +package com.opencloudgaming.opennow + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import androidx.core.content.ContextCompat + +internal const val ANDROID_LOCAL_NETWORK_PERMISSION_API = 37 + +internal fun androidLocalNetworkPermissionRequired(sdkInt: Int = Build.VERSION.SDK_INT): Boolean = + sdkInt >= ANDROID_LOCAL_NETWORK_PERMISSION_API + +internal fun Context.hasAndroidLocalNetworkAccess(): Boolean = + !androidLocalNetworkPermissionRequired() || + ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_LOCAL_NETWORK) == + PackageManager.PERMISSION_GRANTED diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/AndroidNerdAudio.kt b/android/app/src/main/java/com/opencloudgaming/opennow/AndroidNerdAudio.kt new file mode 100644 index 000000000..ca505a8cf --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/AndroidNerdAudio.kt @@ -0,0 +1,264 @@ +package com.opencloudgaming.opennow + +import android.content.Context +import android.media.AudioAttributes +import android.media.AudioManager +import android.media.MediaPlayer +import android.media.ToneGenerator +import android.os.Handler +import android.os.HandlerThread +import android.os.Looper +import android.os.SystemClock + +internal class AndroidNerdAudioController(context: Context) { + private val appContext = context.applicationContext + private val mainHandler = Handler(Looper.getMainLooper()) + private val audioThread = HandlerThread("opennow-cue-audio").apply { start() } + private val audioHandler = Handler(audioThread.looper) + private var cuePlayer: MediaPlayer? = null + private var cuePurpose: MusicCuePurpose? = null + private var cuePlayingChanged: ((Boolean) -> Unit)? = null + private var cuePaused = false + private var cueRemainingDurationMs = 0L + private var cueStopsAtUptimeMs = 0L + private var toneGenerator: ToneGenerator? = null + private var lastToneAtMs = 0L + @Volatile private var released = false + private val stopCueRunnable = Runnable { + stopMusicCueInternal(cuePlayingChanged) + } + + fun startIntro(enabled: Boolean, onPlayingChanged: (Boolean) -> Unit) { + postAudio { + startMusicCueInternal( + purpose = MusicCuePurpose.Intro, + enabled = enabled, + maxDurationMs = INTRO_MUSIC_MAX_DURATION_MS, + onPlayingChanged = onPlayingChanged, + ) + } + } + + fun startQueueReadyReminder(enabled: Boolean, onPlayingChanged: (Boolean) -> Unit = {}) { + postAudio { + startMusicCueInternal( + purpose = MusicCuePurpose.QueueReady, + enabled = enabled, + maxDurationMs = QUEUE_READY_CUE_DURATION_MS, + onPlayingChanged = onPlayingChanged, + ) + } + } + + private fun startMusicCueInternal( + purpose: MusicCuePurpose, + enabled: Boolean, + maxDurationMs: Long, + onPlayingChanged: (Boolean) -> Unit, + ) { + stopMusicCueInternal(cuePlayingChanged) + if (!enabled || released) return + + val descriptor = runCatching { + appContext.resources.openRawResourceFd(musicCueResource(purpose)) + }.getOrNull() ?: return + val player = MediaPlayer() + runCatching { + descriptor.use { + player.setAudioAttributes( + AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_GAME) + .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) + .build(), + ) + player.setDataSource(it.fileDescriptor, it.startOffset, it.length) + } + player.setVolume(MUSIC_CUE_VOLUME, MUSIC_CUE_VOLUME) + player.isLooping = false + player.setOnPreparedListener { prepared -> + if (cuePlayer !== prepared || released) { + runCatching { prepared.release() } + return@setOnPreparedListener + } + runCatching { + prepared.start() + cuePaused = false + cueRemainingDurationMs = maxDurationMs + scheduleCueStop(maxDurationMs) + }.onSuccess { + notifyPlaying(onPlayingChanged, true) + }.onFailure { + finishMusicCue(prepared, onPlayingChanged) + } + } + player.setOnCompletionListener { completed -> + finishMusicCue(completed, onPlayingChanged) + } + player.setOnErrorListener { failed, _, _ -> + finishMusicCue(failed, onPlayingChanged) + true + } + cuePlayer = player + cuePurpose = purpose + cuePlayingChanged = onPlayingChanged + cuePaused = false + cueRemainingDurationMs = maxDurationMs + player.prepareAsync() + }.onFailure { + if (cuePlayer === player) clearMusicCueState() + runCatching { player.release() } + notifyPlaying(onPlayingChanged, false) + } + } + + fun stopIntro(onPlayingChanged: (Boolean) -> Unit = {}) { + postAudio { + if (cuePurpose == MusicCuePurpose.Intro) { + stopMusicCueInternal(onPlayingChanged) + } + } + } + + fun stopQueueReadyReminder(onPlayingChanged: (Boolean) -> Unit = {}) { + postAudio { + if (cuePurpose == MusicCuePurpose.QueueReady) { + stopMusicCueInternal(onPlayingChanged) + } + } + } + + fun stopAll(onPlayingChanged: (Boolean) -> Unit = {}) { + postAudio { stopMusicCueInternal(onPlayingChanged) } + } + + fun pauseAll(onPlayingChanged: (Boolean) -> Unit = {}) { + postAudio { + val player = cuePlayer ?: return@postAudio + if (cuePaused) return@postAudio + val playing = runCatching { player.isPlaying }.getOrElse { + stopMusicCueInternal(onPlayingChanged) + return@postAudio + } + if (!playing) return@postAudio + cueRemainingDurationMs = (cueStopsAtUptimeMs - SystemClock.uptimeMillis()).coerceAtLeast(0L) + audioHandler.removeCallbacks(stopCueRunnable) + runCatching { player.pause() } + .onSuccess { + cuePaused = true + notifyPlaying(onPlayingChanged, false) + } + .onFailure { stopMusicCueInternal(onPlayingChanged) } + } + } + + fun resumeAll(onPlayingChanged: (Boolean) -> Unit = {}) { + postAudio { + val player = cuePlayer ?: return@postAudio + if (!cuePaused) return@postAudio + if (cueRemainingDurationMs <= 0L) { + stopMusicCueInternal(onPlayingChanged) + return@postAudio + } + runCatching { player.start() } + .onSuccess { + cuePaused = false + scheduleCueStop(cueRemainingDurationMs) + notifyPlaying(onPlayingChanged, true) + } + .onFailure { stopMusicCueInternal(onPlayingChanged) } + } + } + + private fun scheduleCueStop(delayMs: Long) { + cueRemainingDurationMs = delayMs.coerceAtLeast(0L) + cueStopsAtUptimeMs = SystemClock.uptimeMillis() + cueRemainingDurationMs + audioHandler.removeCallbacks(stopCueRunnable) + audioHandler.postDelayed(stopCueRunnable, cueRemainingDurationMs) + } + + private fun finishMusicCue(player: MediaPlayer, onPlayingChanged: (Boolean) -> Unit) { + if (cuePlayer !== player) { + runCatching { player.release() } + return + } + clearMusicCueState() + runCatching { player.release() } + notifyPlaying(onPlayingChanged, false) + } + + private fun stopMusicCueInternal(onPlayingChanged: ((Boolean) -> Unit)? = null) { + val player = cuePlayer ?: return + val callback = onPlayingChanged ?: cuePlayingChanged + clearMusicCueState() + runCatching { player.stop() } + runCatching { player.release() } + callback?.let { notifyPlaying(it, false) } + } + + private fun clearMusicCueState() { + cuePlayer = null + cuePurpose = null + cuePlayingChanged = null + cuePaused = false + cueRemainingDurationMs = 0L + cueStopsAtUptimeMs = 0L + audioHandler.removeCallbacks(stopCueRunnable) + } + + fun playButtonTone(enabled: Boolean) { + if (!enabled) return + postAudio { + val now = SystemClock.uptimeMillis() + if (now - lastToneAtMs < MIN_BUTTON_TONE_INTERVAL_MS) return@postAudio + lastToneAtMs = now + val generator = toneGenerator ?: runCatching { + ToneGenerator(AudioManager.STREAM_MUSIC, BUTTON_TONE_VOLUME) + }.getOrNull()?.also { + toneGenerator = it + } ?: return@postAudio + runCatching { + generator.startTone(ToneGenerator.TONE_PROP_ACK, BUTTON_TONE_DURATION_MS) + } + } + } + + fun release() { + if (released) return + released = true + audioHandler.post { + stopMusicCueInternal(cuePlayingChanged) + toneGenerator?.release() + toneGenerator = null + audioThread.quitSafely() + } + } + + private fun postAudio(command: () -> Unit) { + if (released) return + runCatching { audioHandler.post(command) } + } + + private fun notifyPlaying(callback: (Boolean) -> Unit, playing: Boolean) { + mainHandler.post { callback(playing) } + } + + private enum class MusicCuePurpose { + Intro, + QueueReady, + } + + private fun musicCueResource(purpose: MusicCuePurpose): Int = + when (purpose) { + MusicCuePurpose.Intro -> R.raw.nerd_stream_intro + MusicCuePurpose.QueueReady -> R.raw.nerd_queue_ready + } + + private companion object { + private const val MUSIC_CUE_VOLUME = 0.20f + private const val INTRO_MUSIC_MAX_DURATION_MS = 150_000L + private const val QUEUE_READY_CUE_DURATION_MS = 7_000L + private const val BUTTON_TONE_VOLUME = 34 + private const val BUTTON_TONE_DURATION_MS = 48 + private const val MIN_BUTTON_TONE_INTERVAL_MS = 55L + } +} diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/AndroidQueueAds.kt b/android/app/src/main/java/com/opencloudgaming/opennow/AndroidQueueAds.kt new file mode 100644 index 000000000..2a6239afd --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/AndroidQueueAds.kt @@ -0,0 +1,80 @@ +package com.opencloudgaming.opennow + +internal fun isSessionAdsRequired(adState: SessionAdState?): Boolean = + adState?.sessionAdsRequired ?: (adState?.isAdsRequired == true) + +internal fun sessionAdItems(adState: SessionAdState?): List = + adState?.sessionAds?.takeIf { it.isNotEmpty() } ?: adState?.ads.orEmpty() + +internal fun shouldWaitForQueueAdPlayback(adState: SessionAdState?): Boolean = + isSessionAdsRequired(adState) && sessionAdItems(adState).isNotEmpty() + +internal fun mergeQueueAdState( + previous: SessionAdState?, + next: SessionAdState?, + preserveMissingAdState: Boolean = true, +): SessionAdState? { + if (next == null) return if (preserveMissingAdState) previous else null + val shouldRestorePreviousAds = + preserveMissingAdState && + isSessionAdsRequired(next) && + next.serverSentEmptyAds && + sessionAdItems(next).isEmpty() && + sessionAdItems(previous).isNotEmpty() + + return if (shouldRestorePreviousAds) { + next.copy( + sessionAds = sessionAdItems(previous), + ads = previous?.ads?.takeIf { it.isNotEmpty() } ?: sessionAdItems(previous), + ) + } else { + next + } +} + +internal fun mergeQueueSessionState( + previous: SessionInfo, + next: SessionInfo, + preserveMissingAdState: Boolean = true, +): SessionInfo { + val merged = next.copy(assignedZone = next.assignedZone ?: previous.assignedZone) + if (merged.isReadyForStream()) return merged + return merged.copy( + adState = mergeQueueAdState(previous.adState, next.adState, preserveMissingAdState), + mediaConnectionInfo = next.mediaConnectionInfo ?: previous.mediaConnectionInfo, + ) +} + +internal fun removeSessionAdItem(adState: SessionAdState?, adId: String): SessionAdState? { + if (adState == null) return null + return adState.copy( + sessionAds = adState.sessionAds.filterNot { it.adId == adId }, + ads = adState.ads.filterNot { it.adId == adId }, + serverSentEmptyAds = false, + ) +} + +internal fun removeSessionAdItem(session: SessionInfo, adId: String): SessionInfo = + session.copy(adState = removeSessionAdItem(session.adState, adId)) + +internal fun mergeQueueAdReportResult( + previous: SessionInfo, + updated: SessionInfo, + adId: String, + terminalAction: Boolean, +): SessionInfo { + val sanitizedUpdate = if ( + terminalAction && sessionAdItems(updated.adState).any { it.adId == adId } + ) { + removeSessionAdItem(updated, adId) + } else { + updated + } + return mergeQueueSessionState(previous, sanitizedUpdate) +} + +internal fun nextSessionAdId(adState: SessionAdState?, completedAdId: String): String? { + val ads = sessionAdItems(adState) + val completedIndex = ads.indexOfFirst { it.adId == completedAdId } + return ads.getOrNull(completedIndex + 1)?.adId +} diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/AndroidQueueStatusNotifier.kt b/android/app/src/main/java/com/opencloudgaming/opennow/AndroidQueueStatusNotifier.kt new file mode 100644 index 000000000..871d18df4 --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/AndroidQueueStatusNotifier.kt @@ -0,0 +1,285 @@ +package com.opencloudgaming.opennow + +import android.Manifest +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.content.pm.ServiceInfo +import android.os.Build +import android.os.IBinder +import android.util.Log +import androidx.core.app.ServiceCompat +import java.util.concurrent.atomic.AtomicLong + +internal const val QUEUE_CHANNEL_ID = "opennow_queue_status" +internal const val QUEUE_NOTIFICATION_ID = 4210 +private const val QUEUE_ALERT_CHANNEL_ID = "opennow_queue_ready" +private const val QUEUE_ALERT_NOTIFICATION_ID = 4212 + +private const val QUEUE_SERVICE_ACTION_UPDATE = "com.opencloudgaming.opennow.queue.UPDATE" +private const val QUEUE_SERVICE_ACTION_STOP = "com.opencloudgaming.opennow.queue.STOP" +private const val QUEUE_SERVICE_EXTRA_TITLE = "title" +private const val QUEUE_SERVICE_EXTRA_TEXT = "text" +private const val QUEUE_SERVICE_TAG = "OpenNOWQueueService" +private val QUEUE_NOTIFICATION_SMALL_ICON = R.drawable.ic_tab_stream + +class AndroidQueueStatusNotifier(context: Context) { + private val appContext = context.applicationContext + private val commandVersion = AtomicLong() + private val queueReadyTracker = QueueReadyNotificationTracker() + @Volatile private var serviceStartRequested = false + private var queueReadyAlertSent = false + private var activeTitle: String? = null + private var activeText: String? = null + private var cancellationApplied = true + + fun update(state: OpenNowUiState) { + val queueFinished = queueReadyTracker.update(state) + if (isActivelyQueued(state) && queueReadyAlertSent) { + queueReadyAlertSent = false + AndroidServiceCommandDispatcher.dispatch("queue-ready-alert-reset") { + appContext.getSystemService(NotificationManager::class.java).cancel(QUEUE_ALERT_NOTIFICATION_ID) + } + } + // Send a one-shot high-priority heads-up alert only after an observed queue finishes. + if (!queueReadyAlertSent && queueFinished) { + queueReadyAlertSent = true + val readyTitle = state.streamGame?.title ?: "OpenNOW" + AndroidServiceCommandDispatcher.dispatch("queue-ready-alert") { + if (canPostNotifications()) { + ensureQueueAlertChannel(appContext) + appContext.getSystemService(NotificationManager::class.java).notify( + QUEUE_ALERT_NOTIFICATION_ID, + buildQueueReadyNotification(appContext, readyTitle), + ) + } + } + } + if (!shouldShowQueueLaunchStatus(state)) { + val preserveReadyAlert = queueReadyAlertSent && + (state.streamStatus == "connecting" || state.streamStatus == "streaming") + cancel(preserveReadyAlert = preserveReadyAlert) + return + } + cancellationApplied = false + + val title = state.streamGame?.title ?: "OpenNOW" + val text = localizedQueueLaunchStatusText(appContext, state) + if (serviceStartRequested && activeTitle == title && activeText == text) return + serviceStartRequested = true + activeTitle = title + activeText = text + val version = commandVersion.incrementAndGet() + AndroidServiceCommandDispatcher.dispatch("queue-update") { + runCatching { + ensureQueueNotificationChannel(appContext) + val intent = Intent(appContext, AndroidQueueStatusService::class.java).apply { + action = QUEUE_SERVICE_ACTION_UPDATE + putExtra(QUEUE_SERVICE_EXTRA_TITLE, title) + putExtra(QUEUE_SERVICE_EXTRA_TEXT, text) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + appContext.startForegroundService(intent) + } else { + appContext.startService(intent) + } + }.onFailure { error -> + if (commandVersion.get() == version) serviceStartRequested = false + Log.w(QUEUE_SERVICE_TAG, "Unable to start queue foreground service", error) + if (canPostNotifications()) { + runCatching { + appContext.getSystemService(NotificationManager::class.java).notify( + QUEUE_NOTIFICATION_ID, + buildQueueNotification(appContext, title, text), + ) + }.onFailure { notifyError -> + Log.w(QUEUE_SERVICE_TAG, "Unable to post fallback queue notification", notifyError) + } + } + } + } + } + + fun cancel() = cancel(preserveReadyAlert = false) + + private fun cancel(preserveReadyAlert: Boolean) { + if (!serviceStartRequested && cancellationApplied && (preserveReadyAlert || !queueReadyAlertSent)) return + commandVersion.incrementAndGet() + val startWasRequested = serviceStartRequested + serviceStartRequested = false + if (!preserveReadyAlert) queueReadyAlertSent = false + activeTitle = null + activeText = null + cancellationApplied = true + AndroidServiceCommandDispatcher.dispatch("queue-stop") { + val intent = Intent(appContext, AndroidQueueStatusService::class.java).apply { + action = QUEUE_SERVICE_ACTION_STOP + } + if (startWasRequested) { + appContext.startService(intent) + } else { + appContext.stopService(intent) + } + appContext.getSystemService(NotificationManager::class.java).apply { + cancel(QUEUE_NOTIFICATION_ID) + if (!preserveReadyAlert) cancel(QUEUE_ALERT_NOTIFICATION_ID) + } + } + } + + private fun canPostNotifications(): Boolean { + return Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || + appContext.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED + } + +} + +class AndroidQueueStatusService : Service() { + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + runCatching { + when (intent?.action) { + QUEUE_SERVICE_ACTION_STOP -> { + startQueueForeground("OpenNOW", localizedAndroidContext(this).getString(R.string.queue_status)) + ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) + stopSelf(startId) + } + QUEUE_SERVICE_ACTION_UPDATE, null -> { + val title = intent?.getStringExtra(QUEUE_SERVICE_EXTRA_TITLE) ?: "OpenNOW" + val text = intent?.getStringExtra(QUEUE_SERVICE_EXTRA_TEXT) + ?: localizedAndroidContext(this).getString(R.string.queue_status) + startQueueForeground(title, text) + } + else -> { + startQueueForeground("OpenNOW", localizedAndroidContext(this).getString(R.string.queue_status)) + ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) + stopSelf(startId) + } + } + }.onFailure { error -> + Log.e(QUEUE_SERVICE_TAG, "Queue foreground service failed to start", error) + stopSelf(startId) + } + return START_NOT_STICKY + } + + override fun onTimeout(startId: Int, fgsType: Int) { + Log.w(QUEUE_SERVICE_TAG, "Queue foreground service timed out startId=$startId type=$fgsType; stopping") + ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) + stopSelf() + } + + override fun onBind(intent: Intent?): IBinder? = null + + private fun startQueueForeground(title: String, text: String) { + ensureQueueNotificationChannel(this) + val notification = buildQueueNotification(this, title, text) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startForeground(QUEUE_NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) + } else { + startForeground(QUEUE_NOTIFICATION_ID, notification) + } + } +} + +private fun ensureQueueNotificationChannel(context: Context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + val notificationManager = context.applicationContext.getSystemService(NotificationManager::class.java) + val channel = NotificationChannel( + QUEUE_CHANNEL_ID, + "Queue status", + NotificationManager.IMPORTANCE_LOW, + ).apply { + description = "Shows OpenNOW queue and session startup progress." + lockscreenVisibility = Notification.VISIBILITY_PUBLIC + setShowBadge(false) + } + notificationManager.createNotificationChannel(channel) +} + +private fun buildQueueNotification(context: Context, title: String, text: String): Notification { + val appContext = context.applicationContext + val openIntent = Intent(appContext, MainActivity::class.java).apply { + action = Intent.ACTION_MAIN + addCategory(Intent.CATEGORY_LAUNCHER) + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP + } + val pendingIntent = PendingIntent.getActivity( + appContext, + 0, + openIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Notification.Builder(appContext, QUEUE_CHANNEL_ID) + } else { + @Suppress("DEPRECATION") + Notification.Builder(appContext) + } + return builder + .setSmallIcon(QUEUE_NOTIFICATION_SMALL_ICON) + .setContentTitle(title) + .setContentText(text) + .setSubText("OpenNOW") + .setCategory(Notification.CATEGORY_PROGRESS) + .setProgress(0, 0, true) + .setVisibility(Notification.VISIBILITY_PUBLIC) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setShowWhen(false) + .setContentIntent(pendingIntent) + .build() +} + +private fun ensureQueueAlertChannel(context: Context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + val notificationManager = context.applicationContext.getSystemService(NotificationManager::class.java) + val channel = NotificationChannel( + QUEUE_ALERT_CHANNEL_ID, + "Queue ready alert", + NotificationManager.IMPORTANCE_HIGH, + ).apply { + description = "Alerts when a GFN queue finishes and the game is about to launch." + lockscreenVisibility = Notification.VISIBILITY_PUBLIC + setShowBadge(true) + enableVibration(true) + } + notificationManager.createNotificationChannel(channel) +} + +private fun buildQueueReadyNotification(context: Context, gameTitle: String): Notification { + val appContext = context.applicationContext + val openIntent = Intent(appContext, MainActivity::class.java).apply { + action = Intent.ACTION_MAIN + addCategory(Intent.CATEGORY_LAUNCHER) + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP + } + val pendingIntent = PendingIntent.getActivity( + appContext, + 2, + openIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Notification.Builder(appContext, QUEUE_ALERT_CHANNEL_ID) + } else { + @Suppress("DEPRECATION") + Notification.Builder(appContext) + } + return builder + .setSmallIcon(QUEUE_NOTIFICATION_SMALL_ICON) + .setContentTitle(localizedAndroidContext(context).getString(R.string.queue_ready_title, gameTitle)) + .setContentText(localizedAndroidContext(context).getString(R.string.queue_ready_body)) + .setSubText("OpenNOW") + .setCategory(Notification.CATEGORY_ALARM) + .setVisibility(Notification.VISIBILITY_PUBLIC) + .setOngoing(false) + .setAutoCancel(true) + .setShowWhen(true) + .setContentIntent(pendingIntent) + .build() +} diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/AndroidRecommendedProfile.kt b/android/app/src/main/java/com/opencloudgaming/opennow/AndroidRecommendedProfile.kt new file mode 100644 index 000000000..64093f5a1 --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/AndroidRecommendedProfile.kt @@ -0,0 +1,200 @@ +package com.opencloudgaming.opennow + +import android.app.ActivityManager +import android.content.Context +import android.os.Build +import kotlin.math.abs + +internal data class AndroidDeviceRecommendation( + val stream: StreamSettings, + val displayWidth: Int, + val displayHeight: Int, + val processorCount: Int, + val totalMemoryMiB: Long?, + val androidTvProfile: Boolean, + val lowPowerProfile: Boolean, +) { + fun debugSummary(): String = + "display=${displayWidth}x$displayHeight processors=$processorCount memoryMiB=${totalMemoryMiB ?: "unknown"} " + + "tv=$androidTvProfile lowPower=$lowPowerProfile recommended=${stream.resolution}@${stream.fps} " + + "codec=${stream.codec} bitrate=${stream.maxBitrateMbps}" +} + +internal fun recommendedAndroidStreamProfile( + context: Context, + report: RuntimeCodecReport?, +): AndroidDeviceRecommendation { + val metrics = context.resources.displayMetrics + val displayWidth = maxOf(metrics.widthPixels, metrics.heightPixels).coerceAtLeast(1) + val displayHeight = minOf(metrics.widthPixels, metrics.heightPixels).coerceAtLeast(1) + val processorCount = Runtime.getRuntime().availableProcessors().coerceAtLeast(1) + val totalMemoryMiB = runCatching { + val info = ActivityManager.MemoryInfo() + (context.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager)?.getMemoryInfo(info) + info.totalMem.takeIf { it > 0L }?.div(1024L * 1024L) + }.getOrNull() + val tvProfile = report?.androidTvProfile ?: isAndroidTvProfile(context) + val shieldTv = isNvidiaShieldTvDevice( + androidTvProfile = tvProfile, + manufacturer = Build.MANUFACTURER, + model = Build.MODEL, + ) + return recommendedAndroidStreamProfile( + displayWidth = displayWidth, + displayHeight = displayHeight, + processorCount = processorCount, + totalMemoryMiB = totalMemoryMiB, + androidTvProfile = tvProfile, + nvidiaShieldTv = shieldTv, + report = report, + ) +} + +internal fun recommendedAndroidStreamProfile( + displayWidth: Int, + displayHeight: Int, + processorCount: Int, + totalMemoryMiB: Long?, + androidTvProfile: Boolean, + nvidiaShieldTv: Boolean = false, + report: RuntimeCodecReport?, +): AndroidDeviceRecommendation { + val safeDisplayWidth = displayWidth.coerceAtLeast(1) + val safeDisplayHeight = displayHeight.coerceAtLeast(1) + val safeProcessorCount = processorCount.coerceAtLeast(1) + val noVerifiedHardwareDecoder = report != null && report.capabilities.none { capability -> + capability.streamingDecoderUsableForLaunch() && capability.streamingHardwareDecoderAvailable() + } + val lowPower = report?.lowPowerGpuProfile == true || + totalMemoryMiB?.let { it < 3_000L } == true || + safeProcessorCount <= 4 || + noVerifiedHardwareDecoder + + val maxHeight = when { + report?.constrainedRuntimeProfile == true -> 720 + lowPower -> 720 + nvidiaShieldTv -> 2160 + androidTvProfile -> 1080 + totalMemoryMiB?.let { it >= 6_000L } == true && safeProcessorCount >= 8 -> 1440 + else -> 1080 + } + val deviceAspect = safeDisplayWidth.toDouble() / safeDisplayHeight.toDouble() + val aspectRatio = streamAspectRatioOptions().minByOrNull { option -> + val parts = option.split(':') + val optionAspect = parts.getOrNull(0)?.toDoubleOrNull() + ?.div(parts.getOrNull(1)?.toDoubleOrNull() ?: 1.0) + ?: (16.0 / 9.0) + abs(deviceAspect - optionAspect) + } ?: "16:9" + val choices = streamResolutionChoicesForAspect(aspectRatio) + val displaySizedChoices = choices + .filter { it.width <= safeDisplayWidth && it.height <= safeDisplayHeight && it.height <= maxHeight } + .ifEmpty { choices.filter { it.height <= maxHeight } } + .sortedByDescending { it.width * it.height } + val fallbackChoice = streamResolutionChoicesForAspect("16:9").first() + val selectedWithCodec = displaySizedChoices.firstNotNullOfOrNull { choice -> + recommendedCodecForResolution(choice, lowPower, report)?.let { codec -> choice to codec } + } ?: (displaySizedChoices.firstOrNull() ?: fallbackChoice) to VideoCodec.H264 + val (selected, selectedCodec) = selectedWithCodec + + val fps = if ( + report?.constrainedRuntimeProfile == true || + noVerifiedHardwareDecoder || + (lowPower && safeProcessorCount <= 4) + ) { + 30 + } else { + 60 + } + val bitrate = when { + lowPower -> if (fps <= 30) 12 else 18 + selected.height >= 2160 -> 75 + selected.height >= 1440 -> 45 + androidTvProfile -> 30 + else -> 35 + } + val stream = StreamSettings( + resolution = selected.value, + aspectRatio = selected.aspectRatio, + fps = fps, + maxBitrateMbps = bitrate, + codec = selectedCodec, + colorQuality = ColorQuality.EightBit420, + ).adjustedForDevice(report) + + return AndroidDeviceRecommendation( + stream = stream, + displayWidth = safeDisplayWidth, + displayHeight = safeDisplayHeight, + processorCount = safeProcessorCount, + totalMemoryMiB = totalMemoryMiB, + androidTvProfile = androidTvProfile, + lowPowerProfile = lowPower, + ) +} + +private fun recommendedCodecForResolution( + resolution: StreamResolutionChoice, + lowPower: Boolean, + report: RuntimeCodecReport?, +): VideoCodec? { + if (report == null) return VideoCodec.H264 + val codecOrder = if (!lowPower && resolution.height >= 1440) { + listOf(VideoCodec.H265, VideoCodec.H264) + } else { + listOf(VideoCodec.H264, VideoCodec.H265) + } + return codecOrder.firstOrNull { codec -> + report.capabilities + .firstOrNull { it.codec == codec } + ?.supportsRecommendedResolution(resolution) == true + } +} + +private fun CodecCapability.supportsRecommendedResolution(resolution: StreamResolutionChoice): Boolean { + if (!streamingDecoderUsableForLaunch() || !streamingHardwareDecoderAvailable()) return false + val maxWidth = maxSupportedWidth + val maxHeight = maxSupportedHeight + return maxWidth == null || maxHeight == null || + (resolution.width <= maxWidth && resolution.height <= maxHeight) +} + +internal fun StreamSettings.performanceOverridesComparedTo( + recommended: StreamSettings?, + report: RuntimeCodecReport?, +): List { + recommended ?: return emptyList() + val selectedResolution = normalizeStreamResolutionForAspect(resolution, aspectRatio) + val recommendedResolution = normalizeStreamResolutionForAspect(recommended.resolution, recommended.aspectRatio) + val selectedPixels = parseResolutionPixelsOrNull(selectedResolution) + val recommendedPixels = parseResolutionPixelsOrNull(recommendedResolution) + val selectedPixelCount = selectedPixels?.let { (width, height) -> width.toLong() * height } + val recommendedPixelCount = recommendedPixels?.let { (width, height) -> width.toLong() * height } + val codecCapability = report?.capabilities?.firstOrNull { it.codec == codec } + + return buildList { + if ( + selectedPixelCount != null && recommendedPixelCount != null && + selectedPixelCount > recommendedPixelCount + ) { + add("$selectedResolution resolution (recommended $recommendedResolution)") + } + if (fps > recommended.fps) add("$fps FPS (recommended ${recommended.fps})") + if (maxBitrateMbps > recommended.maxBitrateMbps) { + add("$maxBitrateMbps Mbps bitrate (recommended ${recommended.maxBitrateMbps})") + } + if (hdrEnabled && !recommended.hdrEnabled) add("HDR") + if (usesTenBitStreamProfile() && !recommended.usesTenBitStreamProfile()) add("10-bit color") + if (streamSharpeningEnabled && !recommended.streamSharpeningEnabled) add("stream sharpening") + if ( + codec != recommended.codec && + codecCapability != null && + (!codecCapability.streamingDecoderUsableForLaunch() || !codecCapability.streamingHardwareDecoderAvailable()) + ) { + add("${codec.name} without a verified real-time hardware decoder") + } + }.distinct() +} + +internal fun StreamSettings.recommendationSummary(): String = + "$resolution@$fps ${codec.name}, $maxBitrateMbps Mbps" diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/AndroidRuntimeDiagnostics.kt b/android/app/src/main/java/com/opencloudgaming/opennow/AndroidRuntimeDiagnostics.kt new file mode 100644 index 000000000..00ba70cd7 --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/AndroidRuntimeDiagnostics.kt @@ -0,0 +1,350 @@ +package com.opencloudgaming.opennow + +import android.app.ActivityManager +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.net.wifi.WifiInfo +import android.net.wifi.WifiManager +import android.os.BatteryManager +import android.os.Build +import android.os.PowerManager +import kotlin.math.roundToInt + +internal data class AndroidDeviceDiagnosticsSnapshot( + val manufacturer: String, + val brand: String, + val model: String, + val deviceCodename: String, + val product: String, + val hardware: String, + val board: String, + val androidRelease: String, + val androidCodename: String, + val androidSdk: Int, + val targetSdk: Int, + val securityPatch: String, + val supportedAbis: List, + val is64BitRuntime: Boolean, + val processorCount: Int, + val totalMemoryMiB: Long?, + val lowRamDevice: Boolean?, + val displayWidthPixels: Int, + val displayHeightPixels: Int, + val densityDpi: Int, + val smallestScreenWidthDp: Int, + val formFactor: String, + val emulator: Boolean, +) { + fun debugSummary(): String = buildString { + appendLine( + "device.identity manufacturer=$manufacturer brand=$brand model=$model " + + "codename=$deviceCodename product=$product formFactor=$formFactor emulator=$emulator", + ) + appendLine( + "android.os release=$androidRelease codename=$androidCodename sdk=$androidSdk " + + "targetSdk=$targetSdk securityPatch=$securityPatch", + ) + appendLine( + "device.hardware hardware=$hardware board=$board abis=${supportedAbis.joinToString("|").ifBlank { "unknown" }} " + + "runtimeBits=${if (is64BitRuntime) 64 else 32} processors=$processorCount " + + "memoryMiB=${totalMemoryMiB ?: "unknown"} lowRam=${lowRamDevice ?: "unknown"}", + ) + append( + "device.display pixels=${displayWidthPixels}x$displayHeightPixels densityDpi=$densityDpi " + + "smallestWidthDp=$smallestScreenWidthDp", + ) + } +} + +internal object AndroidDeviceDiagnostics { + fun snapshot(context: Context): AndroidDeviceDiagnosticsSnapshot { + val appContext = context.applicationContext + val resources = appContext.resources + val configuration = resources.configuration + val metrics = resources.displayMetrics + val activityManager = appContext.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager + val totalMemoryMiB = activityManager?.let { manager -> + runCatching { + val info = ActivityManager.MemoryInfo() + manager.getMemoryInfo(info) + info.totalMem.takeIf { it > 0L }?.div(DEVICE_BYTES_PER_MEBIBYTE) + }.getOrNull() + } + val smallestWidthDp = configuration.smallestScreenWidthDp.coerceAtLeast(0) + val tv = isAndroidTvProfile(appContext) + return AndroidDeviceDiagnosticsSnapshot( + manufacturer = diagnosticBuildValue(Build.MANUFACTURER), + brand = diagnosticBuildValue(Build.BRAND), + model = diagnosticBuildValue(Build.MODEL), + deviceCodename = diagnosticBuildValue(Build.DEVICE), + product = diagnosticBuildValue(Build.PRODUCT), + hardware = diagnosticBuildValue(Build.HARDWARE), + board = diagnosticBuildValue(Build.BOARD), + androidRelease = diagnosticBuildValue(Build.VERSION.RELEASE), + androidCodename = diagnosticBuildValue(Build.VERSION.CODENAME), + androidSdk = Build.VERSION.SDK_INT, + targetSdk = appContext.applicationInfo.targetSdkVersion, + securityPatch = diagnosticBuildValue(Build.VERSION.SECURITY_PATCH), + supportedAbis = Build.SUPPORTED_ABIS.map(::diagnosticBuildValue), + is64BitRuntime = android.os.Process.is64Bit(), + processorCount = Runtime.getRuntime().availableProcessors().coerceAtLeast(1), + totalMemoryMiB = totalMemoryMiB, + lowRamDevice = activityManager?.isLowRamDevice, + displayWidthPixels = metrics.widthPixels.coerceAtLeast(0), + displayHeightPixels = metrics.heightPixels.coerceAtLeast(0), + densityDpi = metrics.densityDpi.coerceAtLeast(0), + smallestScreenWidthDp = smallestWidthDp, + formFactor = androidDeviceFormFactor(tv, smallestWidthDp), + emulator = isProbablyAndroidEmulator(), + ) + } +} + +internal fun androidDeviceFormFactor(androidTv: Boolean, smallestScreenWidthDp: Int): String = when { + androidTv -> "tv" + smallestScreenWidthDp >= 600 -> "tablet" + else -> "phone" +} + +private fun diagnosticBuildValue(value: String?): String = + value + ?.trim() + ?.takeIf { it.isNotEmpty() } + ?.replace(Regex("\\s+"), "_") + ?.take(MAX_DEVICE_DIAGNOSTIC_VALUE_CHARS) + ?: "unknown" + +private fun isProbablyAndroidEmulator(): Boolean { + val fingerprint = Build.FINGERPRINT.lowercase() + val model = Build.MODEL.lowercase() + val manufacturer = Build.MANUFACTURER.lowercase() + val brand = Build.BRAND.lowercase() + val device = Build.DEVICE.lowercase() + val product = Build.PRODUCT.lowercase() + return fingerprint.startsWith("generic") || + fingerprint.startsWith("unknown") || + model.contains("google_sdk") || + model.contains("emulator") || + model.contains("android sdk built for") || + manufacturer.contains("genymotion") || + (brand.startsWith("generic") && device.startsWith("generic")) || + product.contains("sdk") || + product.contains("emulator") || + product.contains("simulator") +} + +internal data class AndroidRuntimeDiagnosticsSnapshot( + val batteryPercent: Int? = null, + val batteryCharging: Boolean = false, + val batteryTemperatureC: Float? = null, + val thermalStatus: AndroidThermalStatus = AndroidThermalStatus.Unknown, + val networkKind: AndroidNetworkKind = AndroidNetworkKind.Unknown, + val networkSignalBars: Int? = null, + val cellularGeneration: String? = null, + val networkDownstreamKbps: Int? = null, + val wifiFrequencyMhz: Int? = null, + val wifiBand: AndroidWifiBand = AndroidWifiBand.Unknown, +) { + fun debugSummary(): String { + val temperature = batteryTemperatureC?.let { "%.1f".format(java.util.Locale.US, it) } ?: "unknown" + return "battery=${batteryPercent?.toString() ?: "unknown"} charging=$batteryCharging batteryTempC=$temperature thermal=${thermalStatus.logValue} network=${networkKind.logValue} generation=${cellularGeneration ?: "unknown"} bars=${networkSignalBars?.toString() ?: "unknown"} downKbps=${networkDownstreamKbps ?: 0} wifiMhz=${wifiFrequencyMhz ?: 0} wifiBand=${wifiBand.logValue}" + } +} + +enum class AndroidNetworkKind(val label: String, val logValue: String) { + Wifi("WiFi", "wifi"), + Cellular("Cell", "cellular"), + Ethernet("LAN", "ethernet"), + Other("Net", "other"), + None("Off", "none"), + Unknown("Net", "unknown"), +} + +enum class AndroidWifiBand(val label: String, val logValue: String) { + TwoPointFourGhz("2.4 GHz", "2.4ghz"), + FiveGhz("5 GHz", "5ghz"), + SixGhz("6 GHz", "6ghz"), + Unknown("Wi-Fi", "unknown"), +} + +internal fun androidWifiBandForFrequency(frequencyMhz: Int?): AndroidWifiBand = when (frequencyMhz) { + in 2_400..2_500 -> AndroidWifiBand.TwoPointFourGhz + in 4_900..5_900 -> AndroidWifiBand.FiveGhz + in 5_925..7_125 -> AndroidWifiBand.SixGhz + else -> AndroidWifiBand.Unknown +} + +internal enum class AndroidThermalStatus(val logValue: String) { + Unknown("unknown"), + None("none"), + Light("light"), + Moderate("moderate"), + Severe("severe"), + Critical("critical"), + Emergency("emergency"), + Shutdown("shutdown"), +} + +internal object AndroidRuntimeDiagnostics { + fun snapshot(context: Context): AndroidRuntimeDiagnosticsSnapshot { + val appContext = context.applicationContext + val battery = readBattery(appContext) + val network = readNetwork(appContext) + return AndroidRuntimeDiagnosticsSnapshot( + batteryPercent = battery.percent, + batteryCharging = battery.charging, + batteryTemperatureC = battery.temperatureC, + thermalStatus = readThermalStatus(appContext), + networkKind = network.kind, + networkSignalBars = network.signalBars, + cellularGeneration = network.cellularGeneration, + networkDownstreamKbps = network.downstreamKbps, + wifiFrequencyMhz = network.wifiFrequencyMhz, + wifiBand = androidWifiBandForFrequency(network.wifiFrequencyMhz), + ) + } + + fun networkSnapshot(context: Context): AndroidRuntimeDiagnosticsSnapshot { + val network = readNetwork(context.applicationContext) + return AndroidRuntimeDiagnosticsSnapshot( + networkKind = network.kind, + networkSignalBars = network.signalBars, + cellularGeneration = network.cellularGeneration, + networkDownstreamKbps = network.downstreamKbps, + wifiFrequencyMhz = network.wifiFrequencyMhz, + wifiBand = androidWifiBandForFrequency(network.wifiFrequencyMhz), + ) + } + + private fun readBattery(context: Context): BatteryDiagnostics { + val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED), Context.RECEIVER_NOT_EXPORTED) + } else { + @Suppress("DEPRECATION") + context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED)) + } + val level = intent?.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) ?: -1 + val scale = intent?.getIntExtra(BatteryManager.EXTRA_SCALE, -1) ?: -1 + val percent = if (level >= 0 && scale > 0) { + ((level / scale.toFloat()) * 100f).roundToInt().coerceIn(0, 100) + } else { + null + } + val batteryStatus = intent?.getIntExtra(BatteryManager.EXTRA_STATUS, BatteryManager.BATTERY_STATUS_UNKNOWN) + ?: BatteryManager.BATTERY_STATUS_UNKNOWN + val plugged = intent?.getIntExtra(BatteryManager.EXTRA_PLUGGED, 0) ?: 0 + val temperatureTenths = intent?.getIntExtra(BatteryManager.EXTRA_TEMPERATURE, Int.MIN_VALUE) ?: Int.MIN_VALUE + return BatteryDiagnostics( + percent = percent, + charging = batteryStatus == BatteryManager.BATTERY_STATUS_CHARGING || + batteryStatus == BatteryManager.BATTERY_STATUS_FULL || + plugged != 0, + temperatureC = temperatureTenths.takeIf { it != Int.MIN_VALUE }?.let { it / 10f }, + ) + } + + private fun readThermalStatus(context: Context): AndroidThermalStatus { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return AndroidThermalStatus.Unknown + val powerManager = context.getSystemService(Context.POWER_SERVICE) as? PowerManager ?: return AndroidThermalStatus.Unknown + return when (powerManager.currentThermalStatus) { + PowerManager.THERMAL_STATUS_NONE -> AndroidThermalStatus.None + PowerManager.THERMAL_STATUS_LIGHT -> AndroidThermalStatus.Light + PowerManager.THERMAL_STATUS_MODERATE -> AndroidThermalStatus.Moderate + PowerManager.THERMAL_STATUS_SEVERE -> AndroidThermalStatus.Severe + PowerManager.THERMAL_STATUS_CRITICAL -> AndroidThermalStatus.Critical + PowerManager.THERMAL_STATUS_EMERGENCY -> AndroidThermalStatus.Emergency + PowerManager.THERMAL_STATUS_SHUTDOWN -> AndroidThermalStatus.Shutdown + else -> AndroidThermalStatus.Unknown + } + } + + private fun readNetwork(context: Context): NetworkDiagnostics { + val connectivity = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager + val capabilities = connectivity?.getNetworkCapabilities(connectivity.activeNetwork) + val kind = when { + capabilities == null -> AndroidNetworkKind.None + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> AndroidNetworkKind.Wifi + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> AndroidNetworkKind.Cellular + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> AndroidNetworkKind.Ethernet + else -> AndroidNetworkKind.Other + } + return NetworkDiagnostics( + kind = kind, + signalBars = if (kind == AndroidNetworkKind.Cellular) { + CellularNetworkStatus.signalBars(context) ?: networkBars(capabilities) + } else { + networkBars(capabilities) + }, + cellularGeneration = if (kind == AndroidNetworkKind.Cellular) CellularNetworkStatus.displayLabel(context) else null, + downstreamKbps = capabilities?.linkDownstreamBandwidthKbps?.takeIf { it > 0 }, + wifiFrequencyMhz = if (kind == AndroidNetworkKind.Wifi) { + wifiFrequencyMhz(context, capabilities) + } else { + null + }, + ) + } + + @Suppress("DEPRECATION") + private fun wifiFrequencyMhz(context: Context, capabilities: NetworkCapabilities?): Int? { + val networkInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + capabilities?.transportInfo as? WifiInfo + } else { + null + } + // WIFI_SERVICE must be resolved from the application context: on API < 24 holding it from an + // Activity context leaks that Activity, and this module still ships to API 23. + val wifiInfo = networkInfo + ?: (context.applicationContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager)?.connectionInfo + return wifiInfo?.frequency?.takeIf { it > 0 } + } + + private fun networkBars(capabilities: NetworkCapabilities?): Int? { + if (capabilities == null) return 0 + val signalBars = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + networkBarsFromSignal(capabilities.signalStrength) + } else { + null + } + return signalBars ?: networkBarsFromBandwidth(capabilities.linkDownstreamBandwidthKbps) + } + + private fun networkBarsFromSignal(signalStrength: Int): Int? { + if (signalStrength == Int.MIN_VALUE) return null + return when { + signalStrength in 0..4 -> signalStrength + signalStrength >= -55 -> 4 + signalStrength >= -67 -> 3 + signalStrength >= -80 -> 2 + else -> 1 + } + } + + private fun networkBarsFromBandwidth(downstreamKbps: Int): Int? = when { + downstreamKbps >= 25_000 -> 4 + downstreamKbps >= 10_000 -> 3 + downstreamKbps >= 3_000 -> 2 + downstreamKbps > 0 -> 1 + else -> null + } + + private data class BatteryDiagnostics( + val percent: Int?, + val charging: Boolean, + val temperatureC: Float?, + ) + + private data class NetworkDiagnostics( + val kind: AndroidNetworkKind, + val signalBars: Int?, + val cellularGeneration: String?, + val downstreamKbps: Int?, + val wifiFrequencyMhz: Int?, + ) +} + +private const val DEVICE_BYTES_PER_MEBIBYTE = 1024L * 1024L +private const val MAX_DEVICE_DIAGNOSTIC_VALUE_CHARS = 120 diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/AndroidServiceCommandDispatcher.kt b/android/app/src/main/java/com/opencloudgaming/opennow/AndroidServiceCommandDispatcher.kt new file mode 100644 index 000000000..adeb721ef --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/AndroidServiceCommandDispatcher.kt @@ -0,0 +1,26 @@ +package com.opencloudgaming.opennow + +import android.util.Log +import java.util.concurrent.Executors + +/** Keeps service and notification Binder calls ordered and off the UI thread. */ +internal object AndroidServiceCommandDispatcher { + private const val TAG = "OpenNOWServiceCommands" + private val executor = Executors.newSingleThreadExecutor { command -> + Thread(command, "opennow-service-commands").apply { + priority = Thread.NORM_PRIORITY + } + } + + fun dispatch(label: String, command: () -> Unit) { + runCatching { + executor.execute { + runCatching(command).onFailure { error -> + Log.w(TAG, "Service command failed: $label", error) + } + } + }.onFailure { error -> + Log.w(TAG, "Unable to queue service command: $label", error) + } + } +} diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/AndroidSetupFlow.kt b/android/app/src/main/java/com/opencloudgaming/opennow/AndroidSetupFlow.kt new file mode 100644 index 000000000..a00c4cb7a --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/AndroidSetupFlow.kt @@ -0,0 +1,169 @@ +package com.opencloudgaming.opennow + +/** + * First-run setup: the ordering, gating, and settings writes behind the intro screens. + * + * The Compose layer in `OpenNowSetupScreens.kt` owns presentation only. Everything that decides + * *whether* the flow runs, *which* step comes next, and *what* finishing writes to [AppSettings] + * lives here so it can be unit tested without a device. + */ + +/** + * Bump when a step is added that existing installs should be shown. Installs whose + * [AppSettings.setupFlowCompletedVersion] is lower run the flow again. + */ +internal const val SETUP_FLOW_VERSION = 2 + +internal enum class SetupStep { + /** What OpenNOW is, and what the next few screens will ask. */ + Welcome, + + /** Catalog backdrop and accent, previewed live. */ + Appearance, + + /** Quality preset for this specific device. */ + Streaming, + + /** Touch-mouse behavior and the small status line shown over a stream. */ + Play, + + /** Bug reporter, session reports, and diagnostics sharing. */ + Feedback, + + /** Recap of the choices, and where to change them later. */ + Ready, +} + +internal enum class SetupStreamingChoice { + /** Whatever `recommendedAndroidStreamProfile` measured for this device. */ + Recommended, + + /** The highest profile the membership allows, whatever the device measured. */ + Best, + + /** 720p30 at 12 Mbps — mobile data, hotel Wi-Fi, capped connections. */ + DataSaver, + + /** Resolution, frame rate, and bitrate set by hand, on this screen. */ + Custom, +} + +internal enum class SetupTouchMouseChoice { + /** A tap moves the cursor to that point and clicks it in one gesture. */ + Direct, + + /** The screen behaves like a laptop trackpad: swipe to move, then tap to click. */ + Trackpad, + + /** Finger input does not drive the host cursor. */ + Off, +} + +internal fun setupSteps(): List = SetupStep.entries.toList() + +internal fun shouldShowSetupFlow(settings: AppSettings): Boolean = + settings.setupFlowCompletedVersion < SETUP_FLOW_VERSION + +internal fun setupStepIndex(step: SetupStep): Int = setupSteps().indexOf(step) + +internal fun setupStepAfter(step: SetupStep): SetupStep? = + setupSteps().getOrNull(setupStepIndex(step) + 1) + +internal fun setupStepBefore(step: SetupStep): SetupStep? = + setupSteps().getOrNull(setupStepIndex(step) - 1) + +internal fun isFinalSetupStep(step: SetupStep): Boolean = setupStepAfter(step) == null + +/** + * Whether reaching [furthestStep] counts as having answered the diagnostics question. + * + * Analytics consent has its own dialog outside setup. Setup only claims to have asked once the + * user has moved *past* [SetupStep.Feedback] — seeing the switch and leaving it alone is an + * answer, but skipping out before it is not, and those users still get the dialog. + */ +internal fun setupFlowRecordedAnalyticsConsent(furthestStep: SetupStep): Boolean = + setupStepIndex(furthestStep) > setupStepIndex(SetupStep.Feedback) + +/** Marks setup as done. [furthestStep] is the deepest step the user actually reached. */ +internal fun AppSettings.completingSetupFlow(furthestStep: SetupStep): AppSettings = + copy( + setupFlowCompletedVersion = SETUP_FLOW_VERSION, + analyticsConsentAsked = analyticsConsentAsked || setupFlowRecordedAnalyticsConsent(furthestStep), + ) + +/** Sends the user back through setup from Settings without touching any of their choices. */ +internal fun AppSettings.restartingSetupFlow(): AppSettings = copy(setupFlowCompletedVersion = 0) + +internal fun setupStreamingChoiceFor(settings: AppSettings): SetupStreamingChoice = + when (settings.streamPreset) { + StreamPreset.Recommended -> SetupStreamingChoice.Recommended + StreamPreset.High -> SetupStreamingChoice.Best + StreamPreset.LowDataSaver -> SetupStreamingChoice.DataSaver + StreamPreset.Custom, + StreamPreset.Medium, + -> SetupStreamingChoice.Custom + } + +/** + * The preset a streaming choice writes. + * + * Every choice now writes one, including [SetupStreamingChoice.Custom]. Custom used to leave the + * settings untouched because the user had to go and find Settings > Stream; the step edits the + * profile in place instead, so selecting it has to put the app into the custom preset for those + * edits to survive. + */ +internal fun setupStreamingPresetFor(choice: SetupStreamingChoice): StreamPreset = when (choice) { + SetupStreamingChoice.Recommended -> StreamPreset.Recommended + SetupStreamingChoice.Best -> StreamPreset.High + SetupStreamingChoice.DataSaver -> StreamPreset.LowDataSaver + SetupStreamingChoice.Custom -> StreamPreset.Custom +} + +/** + * Whether the step should expose the resolution/FPS/bitrate controls under the choices. + * + * Only for [SetupStreamingChoice.Custom]: showing live controls beside a preset would let the user + * edit values the next preset write silently discards. + */ +internal fun setupStreamingCustomControlsVisible(choice: SetupStreamingChoice): Boolean = + choice == SetupStreamingChoice.Custom + +internal fun setupTouchMouseChoiceFor(settings: AppSettings): SetupTouchMouseChoice = when { + !settings.androidTouch.mousePad -> SetupTouchMouseChoice.Off + settings.androidTouch.mouseDirectClick -> SetupTouchMouseChoice.Direct + else -> SetupTouchMouseChoice.Trackpad +} + +/** Writes both persisted switches together so setup cannot leave an impossible half-selected mode. */ +internal fun AppSettings.withSetupTouchMouseChoice(choice: SetupTouchMouseChoice): AppSettings = + copy( + androidTouch = androidTouch.copy( + mousePad = choice != SetupTouchMouseChoice.Off, + mouseDirectClick = choice == SetupTouchMouseChoice.Direct, + ), + ) + +internal enum class AppBackgroundChoice { + Default, + Nothing, + Wallpaper, +} + +internal fun appBackgroundChoiceFor(settings: AppSettings): AppBackgroundChoice = when { + settings.nerdCatalogBackground -> AppBackgroundChoice.Wallpaper + settings.ambientBackgroundEnabled -> AppBackgroundChoice.Default + else -> AppBackgroundChoice.Nothing +} + +internal fun AppSettings.withAppBackgroundChoice(choice: AppBackgroundChoice): AppSettings = + when (choice) { + AppBackgroundChoice.Default -> copy( + nerdCatalogBackground = false, + ambientBackgroundEnabled = true, + ) + AppBackgroundChoice.Nothing -> copy( + nerdCatalogBackground = false, + ambientBackgroundEnabled = false, + ) + AppBackgroundChoice.Wallpaper -> copy(nerdCatalogBackground = true) + } diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/AndroidStreamKeepAliveNotifier.kt b/android/app/src/main/java/com/opencloudgaming/opennow/AndroidStreamKeepAliveNotifier.kt new file mode 100644 index 000000000..9ab253a16 --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/AndroidStreamKeepAliveNotifier.kt @@ -0,0 +1,273 @@ +package com.opencloudgaming.opennow + +import android.Manifest +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.content.pm.ServiceInfo +import android.os.Build +import android.os.IBinder +import android.os.PowerManager +import android.util.Log +import androidx.core.app.ServiceCompat +import java.util.concurrent.atomic.AtomicLong + +private const val STREAM_CHANNEL_ID = "opennow_active_stream" +private const val STREAM_NOTIFICATION_ID = 4211 +private const val STREAM_SERVICE_ACTION_START = "com.opencloudgaming.opennow.stream.START" +private const val STREAM_SERVICE_ACTION_STOP = "com.opencloudgaming.opennow.stream.STOP" +private const val STREAM_SERVICE_EXTRA_TITLE = "title" +private const val STREAM_SERVICE_EXTRA_MICROPHONE_CAPTURE = "microphone_capture" +private const val STREAM_SERVICE_TAG = "OpenNOWStreamService" + +internal fun shouldKeepAndroidStreamAlive(state: OpenNowUiState): Boolean = + state.page == AppPage.Stream && + state.streamStatus != "idle" && + state.streamSession?.isReadyForStream() == true + +internal fun androidStreamForegroundServiceType( + microphoneCaptureActive: Boolean, + sdkInt: Int, +): Int = + ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK or + if (microphoneCaptureActive && sdkInt >= Build.VERSION_CODES.R) { + ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE + } else { + 0 + } + +internal fun shouldPrepareAndroidStreamMicrophone( + state: OpenNowUiState, + permissionGranted: Boolean, +): Boolean = + shouldKeepAndroidStreamAlive(state) && + (state.activeStreamSettings ?: state.settings.stream).microphoneMode != MicrophoneMode.Disabled && + permissionGranted + +class AndroidStreamKeepAliveNotifier(context: Context) { + private val appContext = context.applicationContext + private val commandVersion = AtomicLong() + @Volatile private var serviceStartRequested = false + private var activeTitle: String? = null + private var activeMicrophoneCapture = false + private var cancellationApplied = false + + fun update(state: OpenNowUiState) { + if (!shouldKeepAndroidStreamAlive(state)) { + cancel() + return + } + cancellationApplied = false + + val title = state.streamGame?.title ?: "OpenNOW" + val microphoneCaptureActive = shouldPrepareAndroidStreamMicrophone( + state = state, + permissionGranted = appContext.checkSelfPermission(Manifest.permission.RECORD_AUDIO) == + PackageManager.PERMISSION_GRANTED, + ) + requestStart(title, microphoneCaptureActive) + } + + /** + * Marks the stream as microphone-capable before WebRTC opens AudioRecord. The flag remains set + * while the user mutes the track because WebRTC can keep the capture device open across mute + * and transport reconnects. + */ + fun setMicrophoneCaptureActive(active: Boolean) { + if (activeMicrophoneCapture == active) return + val title = activeTitle + if (!serviceStartRequested || title == null) { + activeMicrophoneCapture = active + return + } + requestStart(title, active) + } + + private fun requestStart( + title: String, + microphoneCaptureActive: Boolean, + ) { + if ( + serviceStartRequested && + activeTitle == title && + activeMicrophoneCapture == microphoneCaptureActive + ) return + serviceStartRequested = true + activeTitle = title + activeMicrophoneCapture = microphoneCaptureActive + val version = commandVersion.incrementAndGet() + AndroidServiceCommandDispatcher.dispatch("stream-start") { + runCatching { + ensureStreamNotificationChannel(appContext) + val intent = Intent(appContext, AndroidStreamKeepAliveService::class.java).apply { + action = STREAM_SERVICE_ACTION_START + putExtra(STREAM_SERVICE_EXTRA_TITLE, title) + putExtra(STREAM_SERVICE_EXTRA_MICROPHONE_CAPTURE, microphoneCaptureActive) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + appContext.startForegroundService(intent) + } else { + appContext.startService(intent) + } + }.onFailure { error -> + if (commandVersion.get() == version) serviceStartRequested = false + Log.w(STREAM_SERVICE_TAG, "Unable to start stream foreground service", error) + } + } + } + + fun cancel() { + if (!serviceStartRequested && cancellationApplied) return + commandVersion.incrementAndGet() + val startWasRequested = serviceStartRequested + serviceStartRequested = false + activeTitle = null + activeMicrophoneCapture = false + cancellationApplied = true + AndroidServiceCommandDispatcher.dispatch("stream-stop") { + val intent = Intent(appContext, AndroidStreamKeepAliveService::class.java).apply { + action = STREAM_SERVICE_ACTION_STOP + } + if (startWasRequested) { + appContext.startService(intent) + } else { + appContext.stopService(intent) + } + appContext.getSystemService(NotificationManager::class.java).cancel(STREAM_NOTIFICATION_ID) + } + } +} + +class AndroidStreamKeepAliveService : Service() { + private var streamWakeLock: PowerManager.WakeLock? = null + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + runCatching { + when (intent?.action) { + STREAM_SERVICE_ACTION_STOP -> { + releaseStreamWakeLock() + ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) + stopSelf(startId) + } + STREAM_SERVICE_ACTION_START, null -> { + startStreamForeground( + title = intent?.getStringExtra(STREAM_SERVICE_EXTRA_TITLE) ?: "OpenNOW", + microphoneCaptureActive = intent?.getBooleanExtra( + STREAM_SERVICE_EXTRA_MICROPHONE_CAPTURE, + false, + ) == true, + ) + } + else -> { + releaseStreamWakeLock() + ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) + stopSelf(startId) + } + } + }.onFailure { error -> + Log.e(STREAM_SERVICE_TAG, "Stream foreground service failed", error) + stopSelf(startId) + } + return START_NOT_STICKY + } + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onTaskRemoved(rootIntent: Intent?) { + // Leaving or swiping away the Android task is not the in-app End action. Tear down only + // this local keep-alive service so the allocated provider session remains resumable. + Log.i(STREAM_SERVICE_TAG, "App task removed; preserving cloud session until explicit End") + releaseStreamWakeLock() + ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) + stopSelf() + super.onTaskRemoved(rootIntent) + } + + override fun onDestroy() { + releaseStreamWakeLock() + super.onDestroy() + } + + private fun startStreamForeground(title: String, microphoneCaptureActive: Boolean) { + ensureStreamNotificationChannel(this) + acquireStreamWakeLock() + val notification = buildStreamNotification(this, title) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startForeground( + STREAM_NOTIFICATION_ID, + notification, + androidStreamForegroundServiceType(microphoneCaptureActive, Build.VERSION.SDK_INT), + ) + } else { + startForeground(STREAM_NOTIFICATION_ID, notification) + } + } + + private fun acquireStreamWakeLock() { + if (streamWakeLock?.isHeld == true) return + streamWakeLock = getSystemService(PowerManager::class.java) + .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "OpenNOW:ActiveStream") + .apply { + setReferenceCounted(false) + acquire() + } + } + + private fun releaseStreamWakeLock() { + streamWakeLock?.takeIf { it.isHeld }?.release() + streamWakeLock = null + } +} + +private fun ensureStreamNotificationChannel(context: Context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + val notificationManager = context.applicationContext.getSystemService(NotificationManager::class.java) + val channel = NotificationChannel( + STREAM_CHANNEL_ID, + "Active stream", + NotificationManager.IMPORTANCE_LOW, + ).apply { + description = "Keeps an active OpenNOW stream connected while the screen is off." + lockscreenVisibility = Notification.VISIBILITY_PUBLIC + setShowBadge(false) + } + notificationManager.createNotificationChannel(channel) +} + +private fun buildStreamNotification(context: Context, title: String): Notification { + val appContext = context.applicationContext + val openIntent = Intent(appContext, MainActivity::class.java).apply { + action = Intent.ACTION_MAIN + addCategory(Intent.CATEGORY_LAUNCHER) + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP + } + val pendingIntent = PendingIntent.getActivity( + appContext, + 1, + openIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Notification.Builder(appContext, STREAM_CHANNEL_ID) + } else { + @Suppress("DEPRECATION") + Notification.Builder(appContext) + } + return builder + .setSmallIcon(R.drawable.ic_tab_stream) + .setContentTitle(title) + .setContentText(localizedAndroidContext(context).getString(R.string.stream_background_notification)) + .setSubText("OpenNOW") + .setCategory(Notification.CATEGORY_TRANSPORT) + .setVisibility(Notification.VISIBILITY_PUBLIC) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setShowWhen(false) + .setContentIntent(pendingIntent) + .build() +} diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/AndroidStreamOrientation.kt b/android/app/src/main/java/com/opencloudgaming/opennow/AndroidStreamOrientation.kt new file mode 100644 index 000000000..b02ada9a5 --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/AndroidStreamOrientation.kt @@ -0,0 +1,18 @@ +package com.opencloudgaming.opennow + +internal const val PHONE_STREAM_LANDSCAPE_MAX_SMALLEST_WIDTH_DP = 600 + +internal fun shouldLockPhoneStreamLandscape( + state: OpenNowUiState, + smallestScreenWidthDp: Int, +): Boolean = + state.page == AppPage.Stream && + state.streamStatus in phoneStreamLandscapeStatuses && + state.streamSession?.isReadyForStream() == true && + !(state.androidTvProfile || state.codecReport?.androidTvProfile == true) && + isPhoneSizedAndroidDevice(smallestScreenWidthDp) + +private val phoneStreamLandscapeStatuses = setOf("connecting", "streaming") + +private fun isPhoneSizedAndroidDevice(smallestScreenWidthDp: Int): Boolean = + smallestScreenWidthDp in 1 until PHONE_STREAM_LANDSCAPE_MAX_SMALLEST_WIDTH_DP diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/AppUpdate.kt b/android/app/src/main/java/com/opencloudgaming/opennow/AppUpdate.kt new file mode 100644 index 000000000..b251e8cc3 --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/AppUpdate.kt @@ -0,0 +1,773 @@ +package com.opencloudgaming.opennow + +import android.content.ActivityNotFoundException +import android.content.ClipData +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.provider.Settings +import androidx.core.content.FileProvider +import com.google.android.play.core.appupdate.AppUpdateInfo +import com.google.android.play.core.appupdate.AppUpdateManagerFactory +import com.google.android.play.core.install.model.UpdateAvailability +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.longOrNull +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.OkHttpClient +import okhttp3.Request +import java.io.File +import java.security.MessageDigest +import java.util.Locale +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +private const val APK_MIME_TYPE = "application/vnd.android.package-archive" +internal const val ANDROID_UPDATE_SOURCE_URL = "https://api.printedwaste.com/releases/opennow/latest" +internal const val GOOGLE_PLAY_STORE_PACKAGE = "com.android.vending" +internal const val GOOGLE_PLAY_STORE_LISTING_URL = "https://play.google.com/store/apps/details?id=${BuildConfig.APPLICATION_ID}" +private const val UPDATE_FILE_PROVIDER_AUTHORITY_SUFFIX = ".updates" +private val UPDATE_USER_AGENT = "OpenNOW-AndroidUpdater/${BuildConfig.VERSION_NAME}" +private val KNOWN_PACKAGE_INSTALLER_GRANT_TARGETS = setOf( + "com.android.packageinstaller", + "com.google.android.packageinstaller", + "com.android.vending", +) + +enum class AndroidUpdateStatus { + Idle, + Checking, + Available, + NotAvailable, + Downloading, + Downloaded, + Error, +} + +data class AndroidAppInstallSource( + val installerPackageNames: Set = emptySet(), + val apkUpdatesSupportedByBuild: Boolean = BuildConfig.APK_UPDATES_SUPPORTED, + val playStoreReleaseBuild: Boolean = BuildConfig.PLAY_STORE_RELEASE, +) { + val isGooglePlay: Boolean + get() = installerPackageNames.any(::isGooglePlayInstallerPackage) + + val usesGooglePlayUpdates: Boolean + get() = playStoreReleaseBuild || isGooglePlay + + val distributionKind: String + get() = if (isGooglePlay) "play-store" else "apk" + + val buildDistributionKind: String + get() = if (playStoreReleaseBuild) "play-release" else "apk" + + val allowsApkUpdates: Boolean + get() = apkUpdatesSupportedByBuild && !usesGooglePlayUpdates + + val displayName: String + get() = when { + isGooglePlay -> "Google Play" + installerPackageNames.isEmpty() -> "Sideloaded" + else -> installerPackageNames.sorted().joinToString(", ") + } +} + +internal fun AndroidUpdateState.debugHeaderLine(debugBuild: Boolean = BuildConfig.DEBUG): String { + val variant = if (debugBuild) "debug" else "release" + return listOf( + "app.version=$currentVersionName", + "build=$currentVersionCode", + "variant=$variant", + "distribution=${installSource.distributionKind}", + "installSource=${installSource.displayName}", + "buildDistribution=${installSource.buildDistributionKind}", + "apkUpdatesAllowed=$apkUpdatesAllowed", + ).joinToString(" ") +} + +data class AndroidUpdateProgress( + val percent: Int?, + val transferredBytes: Long, + val totalBytes: Long?, +) + +data class AndroidUpdateState( + val status: AndroidUpdateStatus = AndroidUpdateStatus.Idle, + val currentVersionName: String = BuildConfig.VERSION_NAME, + val currentVersionCode: Long = BuildConfig.VERSION_CODE.toLong(), + val sourceUrl: String = ANDROID_UPDATE_SOURCE_URL, + val installSource: AndroidAppInstallSource = AndroidAppInstallSource(), + val availableVersionName: String? = null, + val availableVersionCode: Long? = null, + val releaseNotes: String? = null, + val downloadedFileName: String? = null, + val progress: AndroidUpdateProgress? = null, + val message: String = "Ready to check for updates.", + val lastCheckedAt: Long? = null, +) { + val apkUpdatesAllowed: Boolean + get() = installSource.allowsApkUpdates + + val updateChecksSupported: Boolean + get() = apkUpdatesAllowed || installSource.usesGooglePlayUpdates + + val canCheck: Boolean + get() = updateChecksSupported && status != AndroidUpdateStatus.Checking && status != AndroidUpdateStatus.Downloading + + val canDownload: Boolean + get() = apkUpdatesAllowed && status == AndroidUpdateStatus.Available + + val canInstall: Boolean + get() = apkUpdatesAllowed && status == AndroidUpdateStatus.Downloaded + + val canOpenPlayStore: Boolean + get() = installSource.usesGooglePlayUpdates && status == AndroidUpdateStatus.Available +} + +internal fun AndroidUpdateState.shouldRunAutomaticCheck(): Boolean { + if (!updateChecksSupported) return false + return when (status) { + AndroidUpdateStatus.Checking, + AndroidUpdateStatus.Available, + AndroidUpdateStatus.Downloading, + AndroidUpdateStatus.Downloaded -> false + else -> true + } +} + +internal fun androidUpdateNoticeKey(update: AndroidUpdateState): String? = + if (!update.updateChecksSupported) { + null + } else when (update.status) { + AndroidUpdateStatus.Available, + AndroidUpdateStatus.Downloading, + AndroidUpdateStatus.Downloaded, + -> listOfNotNull( + update.availableVersionCode?.let { "code:$it" }, + update.availableVersionName?.takeIf { it.isNotBlank() }?.let { "name:$it" }, + ).takeIf { it.isNotEmpty() }?.joinToString("|") + ?: update.sourceUrl.takeIf { it.isNotBlank() }?.let { "source:$it" } + else -> null + } + +internal fun androidUpdateUnavailableMessage(installSource: AndroidAppInstallSource): String = + when { + installSource.usesGooglePlayUpdates -> "Ready to check Google Play for updates." + !installSource.apkUpdatesSupportedByBuild -> "APK self-updates are disabled in this Play release." + else -> "Ready to check for sideload APK updates." + } + +internal fun AndroidUpdateState.visibleNoticeKey(dismissedKey: String?): String? = + androidUpdateNoticeKey(this)?.takeUnless { it == dismissedKey } + +internal data class AndroidUpdateCandidate( + val sourceUrl: String, + val apkUrl: String, + val versionName: String?, + val versionCode: Long?, + val sha256: String?, + val releaseNotes: String?, + val fileName: String?, +) { + val displayVersion: String + get() = versionName ?: versionCode?.toString() ?: "APK update" +} + +class AndroidAppUpdater( + private val context: Context, + private val http: OkHttpClient, +) { + private val appContext = context.applicationContext + private val installSource = detectAndroidAppInstallSource(appContext) + private val playAppUpdateManager by lazy { AppUpdateManagerFactory.create(appContext) } + private val _state = MutableStateFlow( + AndroidUpdateState( + installSource = installSource, + message = androidUpdateUnavailableMessage(installSource), + ), + ) + val state: StateFlow = _state + + private var latestCandidate: AndroidUpdateCandidate? = null + private var downloadedApk: File? = null + + suspend fun checkForUpdate(sourceUrl: String = ANDROID_UPDATE_SOURCE_URL) { + if (_state.value.installSource.usesGooglePlayUpdates) { + checkForPlayStoreUpdate() + return + } + if (!_state.value.apkUpdatesAllowed) { + publishApkUpdatesUnavailable() + return + } + val normalizedSourceUrl = runCatching { normalizeAndroidUpdateSourceUrl(sourceUrl) }.getOrElse { error -> + publishError(sourceUrl, error.message ?: "Update source URL is invalid.") + return + } + withContext(Dispatchers.IO) { + publish( + status = AndroidUpdateStatus.Checking, + sourceUrl = normalizedSourceUrl, + message = "Checking update source...", + progress = null, + clearCandidate = true, + ) + try { + val candidate = fetchCandidate(normalizedSourceUrl) + ensureActive() + latestCandidate = candidate + downloadedApk = null + val checkedAt = System.currentTimeMillis() + if (candidate.versionCode != null && candidate.versionCode <= BuildConfig.VERSION_CODE.toLong()) { + publish( + status = AndroidUpdateStatus.NotAvailable, + sourceUrl = normalizedSourceUrl, + message = "OpenNOW Android is up to date.", + availableVersionName = candidate.versionName, + availableVersionCode = candidate.versionCode, + releaseNotes = candidate.releaseNotes, + lastCheckedAt = checkedAt, + ) + } else { + val compareHint = if (candidate.versionCode == null) { + " Version could not be compared, so only download this source if you trust it." + } else { + "" + } + publish( + status = AndroidUpdateStatus.Available, + sourceUrl = normalizedSourceUrl, + message = "OpenNOW ${candidate.displayVersion} is available to download.$compareHint", + availableVersionName = candidate.versionName, + availableVersionCode = candidate.versionCode, + releaseNotes = candidate.releaseNotes, + lastCheckedAt = checkedAt, + ) + } + } catch (error: Throwable) { + if (error is CancellationException) throw error + ensureActive() + publishError(normalizedSourceUrl, error.message ?: "Update check failed.") + } + } + } + + fun markCheckDeferredForStreaming() { + if (!_state.value.updateChecksSupported) { + publishApkUpdatesUnavailable() + return + } + val current = _state.value + when (current.status) { + AndroidUpdateStatus.Available, + AndroidUpdateStatus.Downloading, + AndroidUpdateStatus.Downloaded -> return + else -> Unit + } + _state.value = current.copy( + status = if (current.status == AndroidUpdateStatus.Checking) AndroidUpdateStatus.Idle else current.status, + message = "Update checks pause while streaming.", + progress = null, + ) + } + + fun openPlayStoreListing() { + if (!_state.value.installSource.usesGooglePlayUpdates) return + val marketIntent = Intent( + Intent.ACTION_VIEW, + Uri.parse("market://details?id=${BuildConfig.APPLICATION_ID}"), + ).apply { + setPackage(GOOGLE_PLAY_STORE_PACKAGE) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + val webIntent = Intent(Intent.ACTION_VIEW, Uri.parse(GOOGLE_PLAY_STORE_LISTING_URL)) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + try { + appContext.startActivity(marketIntent) + } catch (_: ActivityNotFoundException) { + runCatching { appContext.startActivity(webIntent) } + .onFailure { error -> + publishError( + GOOGLE_PLAY_STORE_LISTING_URL, + error.message ?: "Google Play could not be opened.", + ) + } + } catch (error: SecurityException) { + runCatching { appContext.startActivity(webIntent) } + .onFailure { + publishError( + GOOGLE_PLAY_STORE_LISTING_URL, + error.message ?: "Android blocked access to Google Play.", + ) + } + } + } + + suspend fun downloadUpdate(sourceUrl: String = ANDROID_UPDATE_SOURCE_URL) { + if (!_state.value.apkUpdatesAllowed) { + publishApkUpdatesUnavailable() + return + } + val normalizedSourceUrl = runCatching { normalizeAndroidUpdateSourceUrl(sourceUrl) }.getOrElse { error -> + publishError(sourceUrl, error.message ?: "Update source URL is invalid.") + return + } + withContext(Dispatchers.IO) { + val candidate = latestCandidate + ?.takeIf { it.sourceUrl == normalizedSourceUrl } + ?: runCatching { fetchCandidate(normalizedSourceUrl) }.getOrElse { error -> + publishError(normalizedSourceUrl, error.message ?: "Update check failed.") + return@withContext + } + latestCandidate = candidate + publish( + status = AndroidUpdateStatus.Downloading, + sourceUrl = normalizedSourceUrl, + message = "Downloading OpenNOW ${candidate.displayVersion}...", + availableVersionName = candidate.versionName, + availableVersionCode = candidate.versionCode, + releaseNotes = candidate.releaseNotes, + progress = AndroidUpdateProgress(percent = 0, transferredBytes = 0, totalBytes = null), + ) + + runCatching { + downloadCandidate(candidate) + }.onSuccess { apk -> + downloadedApk = apk + publish( + status = AndroidUpdateStatus.Downloaded, + sourceUrl = normalizedSourceUrl, + message = "Downloaded ${apk.name}. Android will ask you to confirm the install.", + availableVersionName = candidate.versionName, + availableVersionCode = candidate.versionCode, + releaseNotes = candidate.releaseNotes, + downloadedFileName = apk.name, + progress = null, + ) + }.onFailure { error -> + publishError(normalizedSourceUrl, error.message ?: "Update download failed.") + } + } + } + + @Suppress("DEPRECATION") + fun installDownloadedUpdate() { + if (!_state.value.apkUpdatesAllowed) { + publishApkUpdatesUnavailable() + return + } + val apk = downloadedApk?.takeIf { it.exists() && it.isFile } ?: run { + publishError(_state.value.sourceUrl, "Downloaded APK is no longer available.") + return + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !appContext.packageManager.canRequestPackageInstalls()) { + val settingsIntent = Intent( + Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, + Uri.parse("package:${BuildConfig.APPLICATION_ID}"), + ).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + runCatching { appContext.startActivity(settingsIntent) } + _state.value = _state.value.copy( + status = AndroidUpdateStatus.Downloaded, + message = "Allow OpenNOW to install unknown apps, then tap Install again.", + ) + return + } + + val uri = FileProvider.getUriForFile(appContext, updateFileProviderAuthority(), apk) + val installIntent = Intent(Intent.ACTION_INSTALL_PACKAGE) + .setDataAndType(uri, APK_MIME_TYPE) + .putExtra(Intent.EXTRA_NOT_UNKNOWN_SOURCE, true) + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK) + installIntent.clipData = ClipData.newRawUri("OpenNOW update", uri) + try { + grantInstallUriPermissions(uri, installIntent) + appContext.startActivity(installIntent) + _state.value = _state.value.copy( + status = AndroidUpdateStatus.Downloaded, + message = "Android package installer opened.", + ) + } catch (error: ActivityNotFoundException) { + publishError(_state.value.sourceUrl, error.message ?: "No package installer is available.") + } catch (error: SecurityException) { + publishError(_state.value.sourceUrl, error.message ?: "Android blocked package install access.") + } + } + + private suspend fun checkForPlayStoreUpdate() { + publish( + status = AndroidUpdateStatus.Checking, + sourceUrl = GOOGLE_PLAY_STORE_LISTING_URL, + message = "Checking Google Play...", + progress = null, + clearCandidate = true, + ) + try { + val updateInfo = awaitPlayStoreUpdateInfo() + val currentBuild = BuildConfig.VERSION_CODE.toLong() + val availableBuild = playStoreAvailableVersionCode( + currentVersionCode = currentBuild, + updateAvailability = updateInfo.updateAvailability(), + availableVersionCode = updateInfo.availableVersionCode(), + ) + val checkedAt = System.currentTimeMillis() + if (availableBuild != null) { + publish( + status = AndroidUpdateStatus.Available, + sourceUrl = GOOGLE_PLAY_STORE_LISTING_URL, + message = "Installed build $currentBuild is older than Google Play build $availableBuild.", + availableVersionCode = availableBuild, + lastCheckedAt = checkedAt, + ) + } else { + publish( + status = AndroidUpdateStatus.NotAvailable, + sourceUrl = GOOGLE_PLAY_STORE_LISTING_URL, + message = "OpenNOW is up to date on Google Play (build $currentBuild).", + availableVersionCode = currentBuild, + lastCheckedAt = checkedAt, + ) + } + } catch (error: Throwable) { + if (error is CancellationException) throw error + publishError( + GOOGLE_PLAY_STORE_LISTING_URL, + error.message ?: "Google Play update check failed.", + ) + } + } + + private suspend fun awaitPlayStoreUpdateInfo(): AppUpdateInfo = + suspendCancellableCoroutine { continuation -> + playAppUpdateManager.appUpdateInfo + .addOnSuccessListener { updateInfo -> + if (continuation.isActive) continuation.resume(updateInfo) + } + .addOnFailureListener { error -> + if (continuation.isActive) continuation.resumeWithException(error) + } + } + + private fun fetchCandidate(sourceUrl: String): AndroidUpdateCandidate { + val request = Request.Builder() + .url(sourceUrl) + .header("User-Agent", UPDATE_USER_AGENT) + .build() + http.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + error("Update source returned HTTP ${response.code}.") + } + val contentType = response.header("Content-Type").orEmpty() + if (looksLikeApk(sourceUrl, contentType)) { + return directApkCandidate(sourceUrl, response.header("X-OpenNOW-Version-Name"), response.header("X-OpenNOW-Version-Code")?.toLongOrNull(), response.header("X-OpenNOW-SHA256")) + } + val body = response.body?.string()?.takeIf { it.isNotBlank() } ?: error("Update source returned an empty manifest.") + return parseAndroidUpdateCandidate(sourceUrl, body) + ?: error("Update manifest must provide versionCode/versionName and an apkUrl.") + } + } + + private fun downloadCandidate(candidate: AndroidUpdateCandidate): File { + val request = Request.Builder() + .url(candidate.apkUrl) + .header("User-Agent", UPDATE_USER_AGENT) + .build() + http.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + error("APK download returned HTTP ${response.code}.") + } + val body = response.body ?: error("APK download response was empty.") + val totalBytes = body.contentLength().takeIf { it > 0 } + val updatesDir = androidUpdateStorageDir(appContext).apply { + mkdirs() + listFiles()?.forEach { it.delete() } + } + val tmp = File(updatesDir, "opennow-update.tmp") + val outputName = candidate.safeFileName() + val outputFile = File(updatesDir, outputName) + var transferred = 0L + var lastPercent: Int? = null + var lastProgressAt = 0L + body.byteStream().use { input -> + tmp.outputStream().use { output -> + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + while (true) { + val read = input.read(buffer) + if (read == -1) break + output.write(buffer, 0, read) + transferred += read + val percent = totalBytes?.let { ((transferred * 100) / it).toInt().coerceIn(0, 100) } + val now = System.currentTimeMillis() + if (percent != lastPercent || now - lastProgressAt > 300L) { + lastPercent = percent + lastProgressAt = now + _state.value = _state.value.copy( + progress = AndroidUpdateProgress(percent = percent, transferredBytes = transferred, totalBytes = totalBytes), + ) + } + } + } + } + candidate.sha256?.takeIf { it.isNotBlank() }?.let { expected -> + val actual = tmp.sha256() + if (!actual.equals(expected.cleanHex(), ignoreCase = true)) { + tmp.delete() + error("Downloaded APK failed SHA-256 verification.") + } + } + if (outputFile.exists()) outputFile.delete() + if (!tmp.renameTo(outputFile)) { + tmp.copyTo(outputFile, overwrite = true) + tmp.delete() + } + return outputFile + } + } + + private fun publish( + status: AndroidUpdateStatus, + sourceUrl: String, + message: String, + availableVersionName: String? = null, + availableVersionCode: Long? = null, + releaseNotes: String? = null, + downloadedFileName: String? = null, + progress: AndroidUpdateProgress? = null, + lastCheckedAt: Long? = _state.value.lastCheckedAt, + clearCandidate: Boolean = false, + ) { + if (clearCandidate) { + latestCandidate = null + downloadedApk = null + } + _state.value = AndroidUpdateState( + status = status, + sourceUrl = sourceUrl, + installSource = _state.value.installSource, + availableVersionName = availableVersionName, + availableVersionCode = availableVersionCode, + releaseNotes = releaseNotes, + downloadedFileName = downloadedFileName, + progress = progress, + message = message, + lastCheckedAt = lastCheckedAt, + ) + } + + private fun publishApkUpdatesUnavailable() { + latestCandidate = null + downloadedApk = null + val current = _state.value + _state.value = current.copy( + status = AndroidUpdateStatus.Idle, + availableVersionName = null, + availableVersionCode = null, + releaseNotes = null, + downloadedFileName = null, + progress = null, + message = androidUpdateUnavailableMessage(current.installSource), + ) + } + + private fun publishError(sourceUrl: String, message: String) { + _state.value = _state.value.copy( + status = AndroidUpdateStatus.Error, + sourceUrl = sourceUrl, + message = message, + progress = null, + ) + } + + private fun updateFileProviderAuthority(): String = + "${BuildConfig.APPLICATION_ID}$UPDATE_FILE_PROVIDER_AUTHORITY_SUFFIX" + + private fun grantInstallUriPermissions(uri: Uri, installIntent: Intent) { + val packageManager = appContext.packageManager + val grantTargets = KNOWN_PACKAGE_INSTALLER_GRANT_TARGETS.toMutableSet() + packageManager.resolveActivity(installIntent, 0)?.activityInfo?.packageName?.let(grantTargets::add) + packageManager.queryIntentActivities(installIntent, 0) + .mapNotNullTo(grantTargets) { it.activityInfo?.packageName } + grantTargets.forEach { packageName -> + runCatching { + appContext.grantUriPermission(packageName, uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + } + } +} + +internal fun playStoreAvailableVersionCode( + currentVersionCode: Long, + updateAvailability: Int, + availableVersionCode: Int, +): Long? { + val updateAvailable = updateAvailability == UpdateAvailability.UPDATE_AVAILABLE || + updateAvailability == UpdateAvailability.DEVELOPER_TRIGGERED_UPDATE_IN_PROGRESS + return availableVersionCode.toLong().takeIf { updateAvailable && it > currentVersionCode } +} + +internal fun androidUpdateStorageDir(context: Context): File = + File(context.applicationContext.filesDir, "updates") + +@Suppress("DEPRECATION") +internal fun detectAndroidAppInstallSource(context: Context): AndroidAppInstallSource { + val appContext = context.applicationContext + val packageManager = appContext.packageManager + val packageNames = linkedSetOf() + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val sourceInfo = packageManager.getInstallSourceInfo(appContext.packageName) + packageNames.addIfNotBlank(sourceInfo.initiatingPackageName) + packageNames.addIfNotBlank(sourceInfo.installingPackageName) + packageNames.addIfNotBlank(sourceInfo.originatingPackageName) + } else { + packageNames.addIfNotBlank(packageManager.getInstallerPackageName(appContext.packageName)) + } + } catch (_: PackageManager.NameNotFoundException) { + // PackageManager should know this app, but treat lookup failure like a sideload/unknown source. + } + return AndroidAppInstallSource(packageNames) +} + +internal fun isGooglePlayInstallerPackage(packageName: String?): Boolean = + packageName?.trim()?.equals(GOOGLE_PLAY_STORE_PACKAGE, ignoreCase = true) == true + +private fun MutableSet.addIfNotBlank(value: String?) { + value?.trim()?.takeIf { it.isNotBlank() }?.let(::add) +} + +internal fun normalizeAndroidUpdateSourceUrl(raw: String): String { + val trimmed = raw.trim() + require(trimmed.isNotBlank()) { "Add an update source URL first." } + val withScheme = if (Regex("^[a-z][a-z0-9+.-]*://", RegexOption.IGNORE_CASE).containsMatchIn(trimmed)) { + trimmed + } else { + "https://$trimmed" + } + val url = withScheme.toHttpUrlOrNull() ?: error("Update source URL is invalid.") + if (url.scheme != "https" && !url.isLoopbackHttp()) { + error("Use HTTPS for update sources. HTTP is only allowed for localhost.") + } + return url.toString() +} + +internal fun parseAndroidUpdateCandidate(sourceUrl: String, body: String): AndroidUpdateCandidate? { + val root = runCatching { OpenNowJson.parseToJsonElement(body).jsonObject }.getOrNull() ?: return null + parseGithubReleaseCandidate(sourceUrl, root)?.let { return it } + + val manifest = root.obj("android") ?: root.obj("androidUpdate") ?: root + val rawApkUrl = manifest.string("apkUrl", "apk_url", "artifactUrl", "artifact_url", "downloadUrl", "download_url", "url") ?: return null + val apkUrl = resolveUpdateUrl(sourceUrl, rawApkUrl) ?: return null + return AndroidUpdateCandidate( + sourceUrl = sourceUrl, + apkUrl = apkUrl, + versionName = manifest.string("versionName", "version_name", "name"), + versionCode = manifest.long("versionCode", "version_code", "androidVersionCode"), + sha256 = manifest.string("sha256", "sha256sum", "checksumSha256")?.cleanHex(), + releaseNotes = normalizeReleaseNotes(manifest.string("releaseNotes", "release_notes", "notes", "body")), + fileName = manifest.string("fileName", "file_name"), + ) +} + +private fun parseGithubReleaseCandidate(sourceUrl: String, root: JsonObject): AndroidUpdateCandidate? { + val assets = root["assets"] as? JsonArray ?: return null + val apkAsset = assets.mapNotNull { it as? JsonObject } + .firstOrNull { asset -> + val name = asset.string("name").orEmpty() + val contentType = asset.string("content_type").orEmpty() + name.endsWith(".apk", ignoreCase = true) || contentType.equals(APK_MIME_TYPE, ignoreCase = true) + } ?: return null + val apkUrl = apkAsset.string("browser_download_url", "downloadUrl", "url")?.let { resolveUpdateUrl(sourceUrl, it) } ?: return null + val versionCode = root.long("versionCode", "version_code", "androidVersionCode") + return AndroidUpdateCandidate( + sourceUrl = sourceUrl, + apkUrl = apkUrl, + versionName = root.string("tag_name", "name")?.removePrefix("v"), + versionCode = versionCode, + sha256 = apkAsset.string("sha256", "digest")?.removePrefix("sha256:")?.cleanHex(), + releaseNotes = normalizeReleaseNotes(root.string("body")), + fileName = apkAsset.string("name"), + ) +} + +private fun directApkCandidate(sourceUrl: String, versionName: String?, versionCode: Long?, sha256: String?): AndroidUpdateCandidate = + AndroidUpdateCandidate( + sourceUrl = sourceUrl, + apkUrl = sourceUrl, + versionName = versionName, + versionCode = versionCode, + sha256 = sha256?.cleanHex(), + releaseNotes = null, + fileName = sourceUrl.toHttpUrlOrNull()?.pathSegments?.lastOrNull(), + ) + +private fun resolveUpdateUrl(sourceUrl: String, value: String): String? { + val trimmed = value.trim() + val url = trimmed.toHttpUrlOrNull() ?: sourceUrl.toHttpUrlOrNull()?.resolve(trimmed) + return url?.takeIf { it.scheme == "https" || it.isLoopbackHttp() }?.toString() +} + +private fun looksLikeApk(url: String, contentType: String): Boolean = + url.substringBefore("?").endsWith(".apk", ignoreCase = true) || + contentType.substringBefore(";").trim().equals(APK_MIME_TYPE, ignoreCase = true) + +private fun HttpUrl.isLoopbackHttp(): Boolean = + scheme == "http" && host.lowercase(Locale.US) in setOf("localhost", "127.0.0.1", "::1") + +private fun AndroidUpdateCandidate.safeFileName(): String { + val raw = fileName + ?: apkUrl.toHttpUrlOrNull()?.pathSegments?.lastOrNull() + ?: "OpenNOW-${versionName ?: versionCode ?: "update"}.apk" + val normalized = raw.substringBefore("?") + .replace(Regex("[^A-Za-z0-9._-]"), "_") + .takeIf { it.endsWith(".apk", ignoreCase = true) } + ?: "OpenNOW-${versionName ?: versionCode ?: "update"}.apk" + return normalized +} + +private fun File.sha256(): String { + val digest = MessageDigest.getInstance("SHA-256") + inputStream().use { input -> + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + while (true) { + val read = input.read(buffer) + if (read == -1) break + digest.update(buffer, 0, read) + } + } + return digest.digest().joinToString("") { "%02x".format(it) } +} + +private fun String.cleanHex(): String = + trim().lowercase(Locale.US).removePrefix("sha256:").filter { it in '0'..'9' || it in 'a'..'f' } + +private fun normalizeReleaseNotes(value: String?): String? = + value + ?.replace("\\r\\n", "\n") + ?.replace("\\n", "\n") + ?.replace("\r\n", "\n") + ?.replace('\r', '\n') + ?.takeIf { it.isNotBlank() } + +private fun JsonObject.string(vararg keys: String): String? = + keys.firstNotNullOfOrNull { key -> + this[key]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() } + } + +private fun JsonObject.long(vararg keys: String): Long? = + keys.firstNotNullOfOrNull { key -> + this[key]?.jsonPrimitive?.longOrNull + } + +private fun JsonObject.obj(key: String): JsonObject? = this[key] as? JsonObject diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/BugReportLanguage.kt b/android/app/src/main/java/com/opencloudgaming/opennow/BugReportLanguage.kt new file mode 100644 index 000000000..8a8812e86 --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/BugReportLanguage.kt @@ -0,0 +1,156 @@ +package com.opencloudgaming.opennow + +import com.google.android.gms.tasks.Task +import com.google.mlkit.nl.languageid.LanguageIdentification +import com.google.mlkit.nl.languageid.LanguageIdentificationOptions +import com.google.mlkit.nl.languageid.LanguageIdentifier +import kotlinx.coroutines.suspendCancellableCoroutine +import java.util.Locale +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +internal const val ANDROID_BUG_REPORT_MIN_MEANINGFUL_CHARS = 50 +internal const val ANDROID_BUG_REPORT_MIN_WORDS = 8 +internal const val ANDROID_BUG_REPORT_MIN_UNIQUE_WORDS = 6 +internal const val ANDROID_BUG_REPORT_MIN_ENGLISH_CONFIDENCE = 0.50f + +internal data class AndroidBugReportLanguageCandidate( + val languageTag: String, + val confidence: Float, +) + +internal data class AndroidBugReportLanguageCheck( + val languageTag: String, + val confidence: Float, +) + +internal fun androidBugReportMeaningfulCharacterCount(description: String): Int = + description.count(Char::isLetterOrDigit) + +internal fun androidBugReportDescriptionError(description: String): String? { + val meaningfulCharacters = androidBugReportMeaningfulCharacterCount(description) + if (meaningfulCharacters < ANDROID_BUG_REPORT_MIN_MEANINGFUL_CHARS) { + return "Describe what happened using at least $ANDROID_BUG_REPORT_MIN_MEANINGFUL_CHARS letters or numbers" + } + val words = BUG_REPORT_WORD.findAll(description) + .map { match -> match.value.lowercase(Locale.ROOT) } + .toList() + if (words.size < ANDROID_BUG_REPORT_MIN_WORDS || words.toSet().size < ANDROID_BUG_REPORT_MIN_UNIQUE_WORDS) { + return "Use complete English sentences that explain the steps, result, and expected behavior" + } + if (description.contains(BUG_REPORT_REPEATED_CHARACTER) || words.areRepeatedPadding()) { + return "Remove repeated or random text and describe the real problem in English" + } + return null +} + +internal fun androidBugReportTitleError(title: String): String? { + if (title.isBlank()) return "Enter a short issue title" + if (title.any { it.isLetter() && it.code > ASCII_END }) { + return "Write the issue title in English" + } + if (title.contains(BUG_REPORT_REPEATED_CHARACTER)) { + return "Remove repeated or random text from the issue title" + } + return null +} + +internal fun androidBugReportLanguageError( + candidates: List, +): String? { + val strongest = candidates.maxByOrNull(AndroidBugReportLanguageCandidate::confidence) + val language = strongest?.languageTag + ?.substringBefore('-') + ?.substringBefore('_') + ?.lowercase(Locale.ROOT) + return if ( + language == "en" && + strongest.confidence >= ANDROID_BUG_REPORT_MIN_ENGLISH_CONFIDENCE + ) { + null + } else { + "Write the title and description in clear English; non-English or unrecognizable text cannot be sent" + } +} + +internal suspend fun identifyAndroidBugReportLanguage( + title: String, + description: String, +): AndroidBugReportLanguageCheck { + var identifier: LanguageIdentifier? = null + return try { + val activeIdentifier = LanguageIdentification.getClient( + LanguageIdentificationOptions.Builder() + .setConfidenceThreshold(MIN_LANGUAGE_CANDIDATE_CONFIDENCE) + .build(), + ) + identifier = activeIdentifier + val candidates = activeIdentifier.identifyPossibleLanguages("${title.trim()}\n${description.trim()}") + .awaitResult() + .map { candidate -> + AndroidBugReportLanguageCandidate( + languageTag = candidate.languageTag, + confidence = candidate.confidence, + ) + } + androidBugReportLanguageError(candidates)?.let { message -> + throw IllegalArgumentException(message) + } + val strongest = candidates.maxByOrNull(AndroidBugReportLanguageCandidate::confidence) + ?: throw IllegalArgumentException("OpenNOW could not verify that this report is written in English") + AndroidBugReportLanguageCheck( + languageTag = strongest.languageTag, + confidence = strongest.confidence, + ) + } catch (error: NullPointerException) { + // Some minified Play builds have failed inside ML Kit before the request is built. The + // report has already passed the English app-locale, meaningful-content, and anti-padding + // gates, so keep reporting available instead of surfacing an obfuscated platform NPE. + androidBugReportLanguageCheckAfterMlKitNullFailure(title, description) + } finally { + // A broken ML Kit client can also throw while releasing native resources. Never let cleanup + // replace a successful language result or block the bug report itself. + runCatching { identifier?.close() } + } +} + +internal fun androidBugReportLanguageCheckAfterMlKitNullFailure( + title: String, + description: String, +): AndroidBugReportLanguageCheck { + androidBugReportTitleError(title)?.let { message -> throw IllegalArgumentException(message) } + androidBugReportDescriptionError(description)?.let { message -> throw IllegalArgumentException(message) } + return AndroidBugReportLanguageCheck( + languageTag = "en", + confidence = ANDROID_BUG_REPORT_MIN_ENGLISH_CONFIDENCE, + ) +} + +private suspend fun Task.awaitResult(): T = suspendCancellableCoroutine { continuation -> + addOnSuccessListener { result -> + if (continuation.isActive) continuation.resume(result) + } + addOnFailureListener { error -> + if (continuation.isActive) continuation.resumeWithException(error) + } + addOnCanceledListener { + continuation.cancel() + } +} + +private fun List.areRepeatedPadding(): Boolean { + if (isEmpty()) return false + val mostFrequentWord = groupingBy { word -> word }.eachCount().maxOf(Map.Entry::value) + if (mostFrequentWord > size / 2) return true + for (period in 1..size / 2) { + if (size % period == 0 && indices.all { index -> this[index] == this[index % period] }) { + return true + } + } + return false +} + +private const val ASCII_END = 0x7f +private const val MIN_LANGUAGE_CANDIDATE_CONFIDENCE = 0.01f +private val BUG_REPORT_WORD = Regex("[\\p{L}\\p{N}]+(?:['’-][\\p{L}\\p{N}]+)*") +private val BUG_REPORT_REPEATED_CHARACTER = Regex("([^\\s])\\1{5,}", RegexOption.IGNORE_CASE) diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/BugReportPreflight.kt b/android/app/src/main/java/com/opencloudgaming/opennow/BugReportPreflight.kt new file mode 100644 index 000000000..9b2ff89ed --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/BugReportPreflight.kt @@ -0,0 +1,594 @@ +package com.opencloudgaming.opennow + +import java.util.Locale +import kotlin.math.roundToInt + +internal enum class BugReportPreflightTone { + Healthy, + Notice, + Warning, +} + +internal enum class BugReportPreflightArea { + Experimental, + Connection, + VideoDevice, + Input, +} + +internal data class BugReportPreflightCard( + val area: BugReportPreflightArea, + val label: String, + val title: String, + val summary: String, + val facts: List, + val recommendations: List, + val tone: BugReportPreflightTone, +) + +internal data class BugReportKnownIssueBlock( + val key: String, + val title: String, + val action: String, +) + +internal data class BugReportPreflightDeck( + val cards: List, +) { + init { + require(cards.isNotEmpty()) + } +} + +internal data class BugReportPreflightEvidence( + val requestedSettings: StreamSettings, + val recommendedSettings: StreamSettings? = null, + val nativeLowLatencyDecoderEnabled: Boolean = false, + val runtimeStats: StreamRuntimeStats = StreamRuntimeStats(), + val runtimeDiagnostics: AndroidRuntimeDiagnosticsSnapshot = AndroidRuntimeDiagnosticsSnapshot(), + val sessionReport: SessionReport? = null, + val deliveredResolution: String? = null, + val deliveredCodec: String? = null, + val codecReport: RuntimeCodecReport? = null, + val androidTvProfile: Boolean = false, + val serverZone: String? = null, + val manuallySelectedServer: Boolean = false, + val inputDiagnostics: String = "", +) + +internal fun buildBugReportPreflightDeck( + evidence: BugReportPreflightEvidence, +): BugReportPreflightDeck { + val report = evidence.sessionReport + val networkKind = report?.networkKind + ?.takeUnless { it == AndroidNetworkKind.Unknown } + ?: evidence.runtimeDiagnostics.networkKind + val wifiBand = report?.wifiBand + ?.takeUnless { it == AndroidWifiBand.Unknown } + ?: evidence.runtimeDiagnostics.wifiBand + val pingMs = report?.averagePingMs ?: evidence.runtimeStats.pingMs + val packetLossPct = report?.packetLossPct ?: evidence.runtimeStats.packetLossPct + val averageJitterMs = report?.averageJitterMs ?: evidence.runtimeStats.jitterMs + val averageFps = report?.averageFps ?: evidence.runtimeStats.fps?.toDouble() + val averageDecodeMs = report?.averageDecodeMs ?: evidence.runtimeStats.decodeMs + val averageReceivedFps = evidence.runtimeStats.receivedFps?.toDouble() + val averageDecodedFps = evidence.runtimeStats.decodedFps?.toDouble() + val averageBitrateKbps = report?.averageBitrateKbps ?: evidence.runtimeStats.bitrateKbps + val downstreamKbps = report?.estimatedLinkDownstreamKbps + ?: evidence.runtimeDiagnostics.networkDownstreamKbps + val recommendations = report?.recommendations ?: buildSessionRecommendations( + averagePingMs = pingMs, + packetLossPct = packetLossPct, + averageJitterMs = averageJitterMs, + averageFps = averageFps, + averageDecodeMs = averageDecodeMs, + targetFps = evidence.requestedSettings.fps, + targetBitrateMbps = evidence.requestedSettings.maxBitrateMbps, + averageBitrateKbps = averageBitrateKbps, + networkKind = networkKind, + wifiBand = wifiBand, + estimatedLinkDownstreamKbps = downstreamKbps, + lowestNetworkBars = evidence.runtimeDiagnostics.networkSignalBars, + averageReceivedFps = averageReceivedFps, + averageDecodedFps = averageDecodedFps, + decoderOverloadDetected = isDecoderOverloadSample( + evidence.runtimeStats, + evidence.requestedSettings.fps, + ), + ) + val deviceRecommendationTitles = setOf("Reduce device decode load", "Decoder could not keep up") + val networkRecommendations = recommendations.filterNot { it.title in deviceRecommendationTitles } + val deviceRecommendations = recommendations.filter { it.title in deviceRecommendationTitles } + + return BugReportPreflightDeck( + cards = buildList { + if (evidence.nativeLowLatencyDecoderEnabled) { + add(buildExperimentalNativeStreamerPreflightCard()) + } + add( + buildConnectionPreflightCard( + networkKind = networkKind, + wifiBand = wifiBand, + signalBars = evidence.runtimeDiagnostics.networkSignalBars, + downstreamKbps = downstreamKbps, + pingMs = pingMs, + packetLossPct = packetLossPct, + jitterMs = averageJitterMs, + serverZone = evidence.serverZone, + manuallySelectedServer = evidence.manuallySelectedServer, + recommendations = networkRecommendations, + ), + ) + add( + buildVideoDevicePreflightCard( + evidence = evidence, + averageFps = averageFps, + averageBitrateKbps = averageBitrateKbps, + recommendations = deviceRecommendations, + ), + ) + add(buildInputPreflightCard(evidence.inputDiagnostics)) + }, + ) +} + +private fun buildExperimentalNativeStreamerPreflightCard(): BugReportPreflightCard = + BugReportPreflightCard( + area = BugReportPreflightArea.Experimental, + label = "EXPERIMENTAL FEATURE DETECTED", + title = "Native streamer is enabled", + summary = + "Native streamer changes the hardware decoder with experimental vendor settings. " + + "Turn it off and reproduce first, or explicitly acknowledge sending anyway.", + facts = listOf( + "Native streamer (Experimental): On", + "Unsupported report configuration", + ), + recommendations = listOf( + SessionReportFinding( + title = "Turn it off and reproduce the issue again", + detail = + "Disable Native streamer (Experimental), restart the stream, and reproduce the problem before sending a bug report.", + kind = SessionReportFindingKind.Warning, + ), + ), + tone = BugReportPreflightTone.Warning, + ) + +private fun buildConnectionPreflightCard( + networkKind: AndroidNetworkKind, + wifiBand: AndroidWifiBand, + signalBars: Int?, + downstreamKbps: Int?, + pingMs: Int?, + packetLossPct: Double?, + jitterMs: Double?, + serverZone: String?, + manuallySelectedServer: Boolean, + recommendations: List, +): BugReportPreflightCard { + val facts = buildList { + add( + when (networkKind) { + AndroidNetworkKind.Wifi -> wifiBand.label + else -> networkKind.label + }, + ) + signalBars?.let { add("Signal ${it.coerceIn(0, 4)}/4") } + pingMs?.takeIf { it >= 0 }?.let { add("$it ms latency") } + packetLossPct?.takeIf { it >= 0.0 }?.let { + add("${"%.2f".format(Locale.US, it)}% loss") + } + jitterMs?.takeIf { it >= 0.0 }?.let { + add("${"%.1f".format(Locale.US, it)} ms jitter") + } + downstreamKbps?.takeIf { it > 0 }?.let { add("~${formatPreflightMbps(it)} Mbps link") } + serverZone?.trim()?.takeIf { it.isNotEmpty() }?.let { add("Server $it") } + if (manuallySelectedServer) add("Manual server selection") + } + val warningRecommendations = buildList { + if (manuallySelectedServer) { + add( + SessionReportFinding( + title = MANUAL_SERVER_SELECTION_ACTION_TITLE, + detail = + "Switch Region to Auto, start a new cloud session, and reproduce the issue. " + + "Reports captured while a server is manually selected may not be investigated because the selected route can cause the reported symptoms.", + kind = SessionReportFindingKind.Warning, + ), + ) + } + addAll(recommendations.filter { it.kind == SessionReportFindingKind.Warning }) + }.distinctBy { it.title } + val hasMeasurements = facts.size > 1 || networkKind !in setOf(AndroidNetworkKind.Unknown, AndroidNetworkKind.Other) + val tone = when { + warningRecommendations.isNotEmpty() || networkKind == AndroidNetworkKind.None -> BugReportPreflightTone.Warning + hasMeasurements -> BugReportPreflightTone.Healthy + else -> BugReportPreflightTone.Notice + } + val title = when (tone) { + BugReportPreflightTone.Warning -> if (manuallySelectedServer) { + "A manually selected server may explain the issue" + } else { + "The connection may explain the issue" + } + BugReportPreflightTone.Healthy -> "The connection looks healthy" + BugReportPreflightTone.Notice -> "Connection evidence is limited" + } + val summary = when { + manuallySelectedServer -> + "Reports from manually selected servers may not be investigated. Reproduce on Auto so server routing is ruled out first." + warningRecommendations.isNotEmpty() -> + "These suggestions are based on this session's measured network, not generic Wi-Fi advice." + networkKind == AndroidNetworkKind.None -> + "Android did not detect an active connection when this check ran." + hasMeasurements -> + "Nothing in the measured connection crossed the current warning thresholds." + else -> + "Android did not expose enough live network data to make a specific recommendation." + } + return BugReportPreflightCard( + area = BugReportPreflightArea.Connection, + label = "CONNECTION CHECK", + title = title, + summary = summary, + facts = facts, + recommendations = warningRecommendations, + tone = tone, + ) +} + +private fun buildVideoDevicePreflightCard( + evidence: BugReportPreflightEvidence, + averageFps: Double?, + averageBitrateKbps: Int?, + recommendations: List, +): BugReportPreflightCard { + val report = evidence.sessionReport + val requestedResolution = report?.requestedResolution ?: streamResolutionLabelForPreflight(evidence.requestedSettings) + val deliveredResolution = report?.deliveredResolution + ?: evidence.deliveredResolution + ?: evidence.runtimeStats.resolution + val deliveredCodec = report?.deliveredCodec + ?: evidence.deliveredCodec + ?: evidence.runtimeStats.codec + ?: evidence.requestedSettings.codec.name + val deliveredCodecType = deliveredCodec.toVideoCodecOrNull() + val decoderCapability = deliveredCodecType?.let { codec -> + evidence.codecReport?.capabilities?.firstOrNull { it.codec == codec } + } + val hardwareDecoder = decoderCapability?.streamingHardwareDecoderAvailable() + val thermalStatus = evidence.runtimeDiagnostics.thermalStatus + val resolutionChanged = deliveredResolution != null && + parseResolutionPixelsOrNull(deliveredResolution) != parseResolutionPixelsOrNull(requestedResolution) + val codecChanged = deliveredCodecType != null && deliveredCodecType != evidence.requestedSettings.codec + val thermalWarning = thermalStatus in setOf( + AndroidThermalStatus.Moderate, + AndroidThermalStatus.Severe, + AndroidThermalStatus.Critical, + AndroidThermalStatus.Emergency, + AndroidThermalStatus.Shutdown, + ) + val recommendationOverrides = evidence.requestedSettings.performanceOverridesComparedTo( + recommended = evidence.recommendedSettings, + report = evidence.codecReport, + ) + val videoRecommendations = buildList { + addAll(recommendations.filter { it.kind == SessionReportFindingKind.Warning }) + if (recommendationOverrides.isNotEmpty()) { + val recommended = requireNotNull(evidence.recommendedSettings) + add( + SessionReportFinding( + title = DEVICE_RECOMMENDATION_ACTION_TITLE, + detail = + "Switch to Recommended (${recommended.recommendationSummary()}), restart the stream, and reproduce the lag before reporting it. " + + "This session used ${recommendationOverrides.joinToString()}.", + kind = SessionReportFindingKind.Warning, + ), + ) + } + if (thermalWarning) { + add( + SessionReportFinding( + title = "Let the device cool down", + detail = "Android reports ${thermalStatus.logValue} thermal pressure. Heat can reduce decode speed and frame delivery, so retry after the device cools or improve ventilation.", + kind = SessionReportFindingKind.Warning, + ), + ) + } + if (hardwareDecoder == false) { + val hardwareFallback = evidence.codecReport?.capabilities + ?.firstOrNull { it.streamingHardwareDecoderAvailable() } + ?.codec + add( + SessionReportFinding( + title = "Use a hardware-decoded codec", + detail = hardwareFallback?.let { + "$deliveredCodec is not using a hardware decoder on this device. Try ${it.name} for lower decode load." + } ?: "$deliveredCodec is not using a hardware decoder on this device. A lower resolution or frame rate may be more reliable.", + kind = SessionReportFindingKind.Warning, + ), + ) + } + }.distinctBy { it.title } + val facts = buildList { + add("Requested $requestedResolution@${evidence.requestedSettings.fps}") + add("Requested max ${evidence.requestedSettings.maxBitrateMbps} Mbps") + evidence.recommendedSettings?.let { add("Detected Recommended ${it.recommendationSummary()}") } + if (recommendationOverrides.isNotEmpty()) { + add("Above recommendation: ${recommendationOverrides.joinToString()}") + } + deliveredResolution?.let { add("Delivered $it") } + add("Codec $deliveredCodec") + hardwareDecoder?.let { add(if (it) "Hardware decoder" else "Software decoder") } + averageFps?.let { add("${it.roundToInt()} FPS average") } + averageBitrateKbps?.takeIf { it >= 0 }?.let { add("${formatPreflightMbps(it)} Mbps video") } + evidence.runtimeStats.availableIncomingBitrateKbps + ?.takeIf { it >= 0 } + ?.let { add("${formatPreflightMbps(it)} Mbps WebRTC receive estimate") } + when { + evidence.codecReport?.constrainedRuntimeProfile == true -> add("Constrained device profile") + evidence.codecReport?.lowPowerGpuProfile == true -> add("Low-power device profile") + evidence.androidTvProfile -> add("Android TV profile") + else -> add("Android device profile") + } + if (thermalStatus != AndroidThermalStatus.Unknown) add("Thermal ${thermalStatus.logValue}") + } + val tone = when { + videoRecommendations.isNotEmpty() || resolutionChanged || codecChanged -> BugReportPreflightTone.Warning + deliveredResolution == null && averageFps == null -> BugReportPreflightTone.Notice + else -> BugReportPreflightTone.Healthy + } + val title = when { + resolutionChanged || codecChanged -> "The delivered stream changed" + recommendationOverrides.isNotEmpty() -> "Selected settings exceed the device recommendation" + tone == BugReportPreflightTone.Warning -> "The device or decoder needs attention" + tone == BugReportPreflightTone.Healthy -> "The video path looks healthy" + else -> "Video evidence is limited" + } + val summary = when { + resolutionChanged || codecChanged -> + "The requested and delivered profiles differ. That difference will be included in the report automatically." + recommendationOverrides.isNotEmpty() -> + "OpenNOW detected a safer profile for this hardware. Higher settings can add decoder, GPU, or network load, so reproduce with Recommended before assigning the lag to the app." + videoRecommendations.isNotEmpty() -> + "Only checks that match the current decoder and thermal state are shown below." + tone == BugReportPreflightTone.Healthy -> + "The detected decoder, delivered profile, and device state do not show an obvious local bottleneck." + else -> + "Reproduce the visual issue while the stream is active so the report can capture delivery data." + } + return BugReportPreflightCard( + area = BugReportPreflightArea.VideoDevice, + label = "VIDEO + DEVICE", + title = title, + summary = summary, + facts = facts, + recommendations = videoRecommendations, + tone = tone, + ) +} + +private fun buildInputPreflightCard(inputDiagnostics: String): BugReportPreflightCard { + val paths = buildList { + if (inputDiagnostics.contains("external mouse", ignoreCase = true)) add("External mouse") + if (inputDiagnostics.contains("hardware keyboard", ignoreCase = true)) add("Hardware keyboard") + if ( + inputDiagnostics.contains("physical gamepad connected=true", ignoreCase = true) || + inputDiagnostics.contains("physical gamepad motion", ignoreCase = true) || + inputDiagnostics.contains("physical gamepad axes", ignoreCase = true) || + inputDiagnostics.contains("physical gamepad analog", ignoreCase = true) || + inputDiagnostics.contains("physical gamepad key", ignoreCase = true) + ) { + add("Physical gamepad") + } + if (inputDiagnostics.contains("controller mouse", ignoreCase = true)) add("Controller mouse") + if (inputDiagnostics.contains("touch mouse", ignoreCase = true)) { + add("Touch / Finger Mouse") + } + }.distinct() + val reliableOpen = inputDiagnostics.contains("input channel open label=input_channel_v1", ignoreCase = true) + val partialOpen = inputDiagnostics.contains("input channel open label=input_channel_partially_reliable", ignoreCase = true) + val successfulMouseSend = inputDiagnostics.contains("external mouse move sent", ignoreCase = true) || + inputDiagnostics.contains("controller mouse move sent", ignoreCase = true) + val droppedWithoutChannel = inputDiagnostics.contains("input dropped noOpenChannel", ignoreCase = true) + val facts = buildList { + if (paths.isNotEmpty()) add("Detected ${paths.joinToString()}") + if (reliableOpen && partialOpen) add("Input channels opened") + else if (reliableOpen) add("Reliable input opened") + if (successfulMouseSend) add("Mouse movement sent") + if (droppedWithoutChannel) add("A no-channel drop was recorded") + } + val recommendations = when { + paths.isEmpty() -> listOf( + SessionReportFinding( + title = "For an input problem, reproduce it once", + detail = "No recent mouse, keyboard, gamepad, or touch event is present. If the report is about input, move or press the affected control first so the attached diagnostics identify the real path. For other problems, continue as normal.", + ), + ) + droppedWithoutChannel && !reliableOpen -> listOf( + SessionReportFinding( + title = "Wait for the input channel to reconnect", + detail = "The recent input event arrived while no cloud input channel was open. Reproduce after the stream reconnects; if it still fails, continue with the report.", + kind = SessionReportFindingKind.Warning, + ), + ) + else -> emptyList() + } + val tone = when { + recommendations.any { it.kind == SessionReportFindingKind.Warning } -> BugReportPreflightTone.Warning + paths.isNotEmpty() && (reliableOpen || successfulMouseSend) -> BugReportPreflightTone.Healthy + else -> BugReportPreflightTone.Notice + } + return BugReportPreflightCard( + area = BugReportPreflightArea.Input, + label = "INPUT EVIDENCE", + title = when (tone) { + BugReportPreflightTone.Warning -> "Capture the affected input first" + BugReportPreflightTone.Healthy -> "The input path was detected" + BugReportPreflightTone.Notice -> "No recent input was detected" + }, + summary = when { + recommendations.any { it.kind == SessionReportFindingKind.Warning } -> + "The report is more useful after the exact control has been moved or pressed during the failure." + successfulMouseSend -> + "Recent diagnostics show mouse movement reached the cloud input path." + paths.isNotEmpty() -> + "The attached diagnostics identify the active control type without guessing." + else -> + "This only matters for mouse, keyboard, gamepad, or touch reports; other reports can continue." + }, + facts = facts, + recommendations = recommendations, + tone = tone, + ) +} + +internal fun bugReportKnownIssueBlock( + title: String, + description: String, + deck: BugReportPreflightDeck, +): BugReportKnownIssueBlock? { + val reportText = "$title $description".lowercase(Locale.ROOT) + val experimental = deck.cards.firstOrNull { + it.area == BugReportPreflightArea.Experimental && it.tone == BugReportPreflightTone.Warning + } + if (experimental != null) { + return BugReportKnownIssueBlock( + key = "experimental-native-streamer", + title = "Turn off Native streamer first", + action = "Restart the stream with Native streamer off, then reproduce the issue.", + ).withRepeatedLagReportWarning(reportText) + } + + val connection = deck.cards.firstOrNull { + it.area == BugReportPreflightArea.Connection && it.tone == BugReportPreflightTone.Warning + } + val manualServerSelection = connection?.recommendations?.any { + it.title == MANUAL_SERVER_SELECTION_ACTION_TITLE + } == true + if ( + connection != null && + manualServerSelection && + reportText.containsAnyWholeTerm(BUG_REPORT_MANUAL_SERVER_SYMPTOM_PATTERNS) + ) { + return BugReportKnownIssueBlock( + key = "network-manual-server", + title = "Manual server selection must be ruled out", + action = + "Switch Region to Auto, start a new cloud session, and reproduce the issue. " + + "Reports from manually selected servers may not be investigated.", + ).withRepeatedLagReportWarning(reportText) + } + if (connection != null && reportText.containsAnyWholeTerm(BUG_REPORT_NETWORK_SYMPTOM_PATTERNS)) { + val twoPointFourGhz = connection.facts.any { it.contains("2.4 GHz", ignoreCase = true) } + return BugReportKnownIssueBlock( + key = if (twoPointFourGhz) "network-2.4ghz" else "network-measured", + title = if (twoPointFourGhz) "2.4 GHz likely explains this" else "Connection issue detected", + action = if (twoPointFourGhz) { + "Use 5/6 GHz Wi-Fi, Ethernet, or stable cellular, then try again." + } else { + connection.recommendations.firstOrNull()?.compactPreflightAction() + ?: "Fix the measured connection warning, then reproduce the issue." + }, + ).withRepeatedLagReportWarning(reportText) + } + + val video = deck.cards.firstOrNull { + it.area == BugReportPreflightArea.VideoDevice && it.tone == BugReportPreflightTone.Warning + } + if (video != null && reportText.containsAnyWholeTerm(BUG_REPORT_VIDEO_SYMPTOM_PATTERNS)) { + val recommendationOverride = video.recommendations.firstOrNull { + it.title == DEVICE_RECOMMENDATION_ACTION_TITLE + } + return BugReportKnownIssueBlock( + key = if (recommendationOverride != null) "device-profile-override" else "video-device-measured", + title = if (recommendationOverride != null) { + "Selected profile exceeds this device's recommendation" + } else { + "Local video issue detected" + }, + action = recommendationOverride?.compactPreflightAction() + ?: video.recommendations.firstOrNull()?.compactPreflightAction() + ?: "Apply the video or device fix shown above, then reproduce the issue.", + ).withRepeatedLagReportWarning(reportText) + } + + val input = deck.cards.firstOrNull { + it.area == BugReportPreflightArea.Input && it.tone == BugReportPreflightTone.Warning + } + if (input != null && reportText.containsAnyWholeTerm(BUG_REPORT_INPUT_SYMPTOM_PATTERNS)) { + return BugReportKnownIssueBlock( + key = "input-measured", + title = "Input path issue detected", + action = input.recommendations.firstOrNull()?.compactPreflightAction() + ?: "Reconnect the input path and reproduce the issue before reporting.", + ).withRepeatedLagReportWarning(reportText) + } + return null +} + +private fun BugReportKnownIssueBlock.withRepeatedLagReportWarning(reportText: String): BugReportKnownIssueBlock { + if (!reportText.containsAnyWholeTerm(BUG_REPORT_EXACT_LAG_PATTERN)) return this + return copy( + action = action.trimEnd() + + " Repeating a lag report after this cause has already been identified, without first trying the requested step, may result in a bug-reporting ban.", + ) +} + +internal fun bugReportKnownIssueAllowsSubmission( + block: BugReportKnownIssueBlock?, + acknowledgedBlockKey: String?, +): Boolean = block == null || block.key == acknowledgedBlockKey + +private fun String.containsAnyWholeTerm(patterns: List): Boolean = patterns.any { it.containsMatchIn(this) } + +private fun SessionReportFinding.compactPreflightAction(): String { + val firstSentence = detail.trim().substringBefore('.').trim() + return if (firstSentence.isNotEmpty()) "$firstSentence." else title +} + +private fun bugReportTermPatterns(vararg terms: String): List = terms.map { term -> + Regex("(^|[^a-z0-9])${Regex.escape(term)}([^a-z0-9]|$)") +} + +private val BUG_REPORT_NETWORK_SYMPTOM_PATTERNS = bugReportTermPatterns( + "lag", "laggy", "latency", "ping", "delay", "delayed", "jitter", "packet loss", + "buffering", "stutter", "stuttering", "choppy", "pixelated", "blurry", "slow", +) + +private val BUG_REPORT_EXACT_LAG_PATTERN = bugReportTermPatterns("lag") + +private val BUG_REPORT_VIDEO_SYMPTOM_PATTERNS = bugReportTermPatterns( + "fps", "frame", "frames", "video", "decoder", "decode", "freeze", "frozen", "blurry", + "pixelated", "stutter", "stuttering", "choppy", "lag", "laggy", "slow", "overheat", "hot", +) + +private val BUG_REPORT_INPUT_SYMPTOM_PATTERNS = bugReportTermPatterns( + "input", "mouse", "keyboard", "controller", "gamepad", "touch", "button", "joystick", + "stick", "click", "cursor", +) + +private val BUG_REPORT_MANUAL_SERVER_SYMPTOM_PATTERNS = + BUG_REPORT_NETWORK_SYMPTOM_PATTERNS + BUG_REPORT_VIDEO_SYMPTOM_PATTERNS + +private const val MANUAL_SERVER_SELECTION_ACTION_TITLE = "Use Auto server selection and reproduce" +private const val DEVICE_RECOMMENDATION_ACTION_TITLE = "Use the detected Recommended profile" + +private fun streamResolutionLabelForPreflight(settings: StreamSettings): String { + val (width, height) = streamResolutionPixels(settings) + return "${width}x$height" +} + +private fun String.toVideoCodecOrNull(): VideoCodec? { + val normalized = uppercase(Locale.US).replace(".", "").replace("-", "") + return when { + "AV1" in normalized -> VideoCodec.AV1 + "H265" in normalized || "HEVC" in normalized -> VideoCodec.H265 + "H264" in normalized || "AVC" in normalized -> VideoCodec.H264 + else -> null + } +} + +private fun formatPreflightMbps(kbps: Int): String = + "%.1f".format(Locale.US, kbps / 1000.0).removeSuffix(".0") diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/BugReportReceipt.kt b/android/app/src/main/java/com/opencloudgaming/opennow/BugReportReceipt.kt new file mode 100644 index 000000000..48cc09820 --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/BugReportReceipt.kt @@ -0,0 +1,73 @@ +package com.opencloudgaming.opennow + +import android.content.ClipData +import android.content.ClipboardManager +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +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.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import com.opencloudgaming.opennow.ui.theme.OpenNowPalette + +@Composable +internal fun CopyableBugReportId( + reportId: String, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + var copied by remember(reportId) { mutableStateOf(false) } + + Surface( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(10.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.42f), + border = BorderStroke(1.dp, OpenNowPalette.PanelHairline), + ) { + Column( + modifier = Modifier.padding(10.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = stringResource(R.string.bug_report_id_label), + style = MaterialTheme.typography.labelMedium, + color = TextMuted, + ) + SelectionContainer { + Text( + text = reportId, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + ) + } + OutlinedButton( + onClick = { + context.getSystemService(ClipboardManager::class.java) + ?.setPrimaryClip(ClipData.newPlainText("OpenNOW bug report ID", reportId)) + copied = true + }, + ) { + Text( + stringResource( + if (copied) R.string.bug_report_id_copied else R.string.bug_report_copy_id, + ), + ) + } + } + } +} diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/BugReports.kt b/android/app/src/main/java/com/opencloudgaming/opennow/BugReports.kt new file mode 100644 index 000000000..27d7696dc --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/BugReports.kt @@ -0,0 +1,317 @@ +package com.opencloudgaming.opennow + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.MultipartBody +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import java.security.MessageDigest + +internal const val ANDROID_BUG_REPORT_ENDPOINT = + "https://api.printedwaste.com/releases/opennow/bug-reports" +internal const val ANDROID_BUG_REPORT_MAX_FILES = 5 +internal const val ANDROID_BUG_REPORT_MAX_FILE_BYTES = 10L * 1024L * 1024L +internal const val ANDROID_BUG_REPORT_REPORTER_ID_PREFIX = "br1_" + +enum class AndroidBugReportVersionCheckStatus { + NotChecked, + Checking, + Current, + UpdateRequired, + CheckFailed, +} + +data class AndroidBugReportVersionCheckState( + val status: AndroidBugReportVersionCheckStatus = AndroidBugReportVersionCheckStatus.NotChecked, + val message: String? = null, +) + +internal fun androidBugReportsAllowed( + update: AndroidUpdateState, + versionCheck: AndroidBugReportVersionCheckState, +): Boolean { + if (!update.installSource.isGooglePlay) return true + return versionCheck.status == AndroidBugReportVersionCheckStatus.Current && + update.status == AndroidUpdateStatus.NotAvailable +} + +internal fun androidBugReportBlockMessage( + update: AndroidUpdateState, + versionCheck: AndroidBugReportVersionCheckState, +): String? { + if (!update.installSource.isGooglePlay) return null + return when { + update.status == AndroidUpdateStatus.Available || + versionCheck.status == AndroidBugReportVersionCheckStatus.UpdateRequired -> + "Update OpenNOW from Google Play before sending a bug report. This keeps reports tied to the latest supported build." + versionCheck.status == AndroidBugReportVersionCheckStatus.CheckFailed -> + versionCheck.message ?: "OpenNOW could not verify the latest Google Play version. Retry the check before reporting." + versionCheck.status == AndroidBugReportVersionCheckStatus.Checking -> + "Checking Google Play for a newer OpenNOW build before bug reporting is enabled." + versionCheck.status == AndroidBugReportVersionCheckStatus.Current && + update.status == AndroidUpdateStatus.NotAvailable -> null + versionCheck.status == AndroidBugReportVersionCheckStatus.Current -> + "OpenNOW could not confirm that this is still the latest Google Play build. Retry the check before reporting." + else -> "Check Google Play for updates before sending a bug report." + } +} + +internal data class AndroidBugReportAttachment( + val fileName: String, + val contentType: String, + val bytes: ByteArray, +) + +internal data class AndroidBugReport( + val title: String, + val description: String, + val versionName: String, + val versionCode: String, + val reporterId: String, + val appLanguageSelectionTag: String, + val languageCheck: AndroidBugReportLanguageCheck, + val metadata: String, + val files: List, +) + +internal data class AndroidBugReportReceipt( + val reference: String, +) + +internal data class AndroidBugReportServerError( + val code: String?, + val message: String, + val retryable: Boolean?, +) + +internal class AndroidBugReportUploadException( + val serverCode: String?, + val retryable: Boolean?, + message: String, +) : IllegalStateException(message) + +/** + * Stable, installation-scoped abuse-prevention key. The raw GFN device ID is deliberately never + * uploaded: a namespaced SHA-256 digest keeps bug reports unlinkable to the provider credential + * while still giving the report service a consistent value to rate-limit or block. + */ +internal fun androidBugReportReporterId(stableDeviceId: String): String { + require(stableDeviceId.isNotBlank()) { "Bug report installation ID is unavailable" } + val digest = MessageDigest.getInstance("SHA-256") + .digest("opennow-android-bug-report-v1:$stableDeviceId".toByteArray(Charsets.UTF_8)) + return ANDROID_BUG_REPORT_REPORTER_ID_PREFIX + digest.joinToString("") { "%02x".format(it) } +} + +internal fun buildAndroidBugReportMetadata( + logFileName: String, + knownIssueOverrideKey: String? = null, + device: AndroidDeviceDiagnosticsSnapshot? = null, +): String = buildJsonObject { + put("source", "settings-advanced-debug-logs") + put("attachment", logFileName) + device?.let { snapshot -> + put("device", buildJsonObject { + put("manufacturer", snapshot.manufacturer) + put("brand", snapshot.brand) + put("model", snapshot.model) + put("codename", snapshot.deviceCodename) + put("product", snapshot.product) + put("formFactor", snapshot.formFactor) + put("emulator", snapshot.emulator) + }) + put("android", buildJsonObject { + put("release", snapshot.androidRelease) + put("codename", snapshot.androidCodename) + put("sdk", snapshot.androidSdk) + put("targetSdk", snapshot.targetSdk) + put("securityPatch", snapshot.securityPatch) + }) + put("hardware", buildJsonObject { + put("name", snapshot.hardware) + put("board", snapshot.board) + put("supportedAbis", buildJsonArray { + snapshot.supportedAbis.forEach { add(JsonPrimitive(it)) } + }) + put("runtimeBits", if (snapshot.is64BitRuntime) 64 else 32) + put("processorCount", snapshot.processorCount) + snapshot.totalMemoryMiB?.let { put("totalMemoryMiB", it) } + snapshot.lowRamDevice?.let { put("lowRamDevice", it) } + }) + put("display", buildJsonObject { + put("widthPixels", snapshot.displayWidthPixels) + put("heightPixels", snapshot.displayHeightPixels) + put("densityDpi", snapshot.densityDpi) + put("smallestWidthDp", snapshot.smallestScreenWidthDp) + }) + } + knownIssueOverrideKey?.trim()?.takeIf { it.isNotEmpty() }?.let { key -> + put("knownIssueOverride", true) + put("knownIssueKey", key) + } +}.toString() + +internal fun buildAndroidBugReportRequest( + report: AndroidBugReport, + endpoint: String = ANDROID_BUG_REPORT_ENDPOINT, +): Request { + val title = report.title.trim() + val description = report.description.trim() + androidBugReportTitleError(title)?.let { error -> throw IllegalArgumentException(error) } + androidBugReportDescriptionError(description)?.let { error -> throw IllegalArgumentException(error) } + require(androidAppLocaleIsEnglish(report.appLanguageSelectionTag)) { + "Set the OpenNOW or device language to English before sending a bug report" + } + androidBugReportLanguageError( + listOf( + AndroidBugReportLanguageCandidate( + languageTag = report.languageCheck.languageTag, + confidence = report.languageCheck.confidence, + ), + ), + )?.let { error -> throw IllegalArgumentException(error) } + require(report.versionName.isNotBlank()) { "App version is unavailable" } + require(report.versionCode.isNotBlank()) { "App build is unavailable" } + require(report.reporterId.matches(ANDROID_BUG_REPORT_REPORTER_ID_REGEX)) { + "Bug report installation ID is invalid" + } + require(report.files.size <= ANDROID_BUG_REPORT_MAX_FILES) { + "Bug reports support up to $ANDROID_BUG_REPORT_MAX_FILES files" + } + runCatching { OpenNowJson.parseToJsonElement(report.metadata).jsonObject } + .getOrElse { throw IllegalArgumentException("Bug report metadata must be a JSON object", it) } + + val multipart = MultipartBody.Builder() + .setType(MultipartBody.FORM) + .addFormDataPart("title", title) + .addFormDataPart("description", description) + .addFormDataPart("versionName", report.versionName) + .addFormDataPart("versionCode", report.versionCode) + .addFormDataPart("platform", "android") + .addFormDataPart("reporterId", report.reporterId) + .addFormDataPart("metadata", report.metadata) + + report.files.forEach { attachment -> + require(attachment.fileName.isNotBlank()) { "Bug report files must have a name" } + require(attachment.bytes.size.toLong() <= ANDROID_BUG_REPORT_MAX_FILE_BYTES) { + "${attachment.fileName} is larger than 10 MiB" + } + val mediaType = attachment.contentType.toMediaType() + multipart.addFormDataPart( + "files", + attachment.fileName, + attachment.bytes.toRequestBody(mediaType), + ) + } + + return Request.Builder() + .url(endpoint) + .header("Accept", "application/json") + .post(multipart.build()) + .build() +} + +internal suspend fun uploadAndroidBugReport( + http: OkHttpClient, + report: AndroidBugReport, +): AndroidBugReportReceipt = withContext(Dispatchers.IO) { + http.newCall(buildAndroidBugReportRequest(report)).execute().use { response -> + val body = response.body.string().take(MAX_BUG_REPORT_RESPONSE_CHARS) + if (!response.isSuccessful) { + val serverError = parseAndroidBugReportServerError(body, response.code) + throw AndroidBugReportUploadException( + serverCode = serverError.code, + retryable = serverError.retryable, + message = serverError.message, + ) + } + if (androidBugReportResponseExplicitlyRejected(body)) { + val serverError = parseAndroidBugReportServerError(body, response.code) + throw AndroidBugReportUploadException( + serverCode = serverError.code, + retryable = serverError.retryable, + message = serverError.message, + ) + } + parseAndroidBugReportReceipt(body) + } +} + +internal fun parseAndroidBugReportReceipt(body: String): AndroidBugReportReceipt = + AndroidBugReportReceipt( + reference = parseAndroidBugReportReference(body) + ?: throw AndroidBugReportUploadException( + serverCode = "INVALID_RESPONSE", + retryable = false, + message = "The bug report service did not return a report ID.", + ), + ) + +internal fun parseAndroidBugReportReference(body: String): String? = runCatching { + val json = OpenNowJson.parseToJsonElement(body).jsonObject + listOf("id", "reportId", "bugReportId") + .firstNotNullOfOrNull { key -> + json[key]?.jsonPrimitive?.contentOrNull + ?.trim() + ?.take(MAX_BUG_REPORT_REFERENCE_CHARS) + ?.takeIf(String::isNotBlank) + } +}.getOrNull() + +internal fun parseAndroidBugReportServerError( + body: String, + statusCode: Int, +): AndroidBugReportServerError { + val root = parseBugReportJsonObject(body) + val error = root?.get("error")?.let { element -> + runCatching { element.jsonObject }.getOrNull() + } + val payload = error ?: root + val customMessage = payload?.serverString("message") + ?.replace(BUG_REPORT_RESPONSE_WHITESPACE, " ") + ?.trim() + ?.take(MAX_BUG_REPORT_PUBLIC_MESSAGE_CHARS) + ?.takeIf(String::isNotBlank) + return AndroidBugReportServerError( + code = payload?.serverString("code")?.take(MAX_BUG_REPORT_SERVER_CODE_CHARS), + message = customMessage ?: when (statusCode) { + 403 -> "Bug reporting is unavailable for this installation." + 429 -> "Too many bug reports were sent. Try again later." + else -> "Bug report upload failed (HTTP $statusCode)." + }, + retryable = payload?.get("retryable")?.let { element -> + runCatching { element.jsonPrimitive.booleanOrNull }.getOrNull() + }, + ) +} + +private fun androidBugReportResponseExplicitlyRejected(body: String): Boolean = + (parseBugReportJsonObject(body) + ?.get("ok") + ?.let { element -> runCatching { element.jsonPrimitive.booleanOrNull }.getOrNull() }) == false + +private fun parseBugReportJsonObject(body: String): JsonObject? = runCatching { + OpenNowJson.parseToJsonElement(body).jsonObject +}.getOrNull() + +private fun JsonObject.serverString(key: String): String? = + get(key)?.let { element -> + runCatching { element.jsonPrimitive.contentOrNull }.getOrNull() + }?.takeIf(String::isNotBlank) + +private const val MAX_BUG_REPORT_RESPONSE_CHARS = 64 * 1024 +private const val MAX_BUG_REPORT_PUBLIC_MESSAGE_CHARS = 320 +private const val MAX_BUG_REPORT_SERVER_CODE_CHARS = 80 +private const val MAX_BUG_REPORT_REFERENCE_CHARS = 160 +private val ANDROID_BUG_REPORT_REPORTER_ID_REGEX = Regex("^br1_[0-9a-f]{64}$") +private val BUG_REPORT_RESPONSE_WHITESPACE = Regex("\\s+") diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/CellularNetworkStatus.kt b/android/app/src/main/java/com/opencloudgaming/opennow/CellularNetworkStatus.kt new file mode 100644 index 000000000..31dce7f48 --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/CellularNetworkStatus.kt @@ -0,0 +1,107 @@ +package com.opencloudgaming.opennow + +import android.content.Context +import android.os.Build +import android.telephony.PhoneStateListener +import android.telephony.TelephonyCallback +import android.telephony.TelephonyDisplayInfo +import android.telephony.TelephonyManager + +/** + * Owns the carrier-facing cellular generation shown by compact stream stats. + * TelephonyDisplayInfo is intentionally used instead of guessing from bandwidth: + * it includes carrier display overrides such as 5G NSA and 5G+. + */ +internal object CellularNetworkStatus { + @Volatile + private var displayLabel: String? = null + + @Volatile + private var monitoringStarted = false + + private var retainedCallback: Any? = null + + fun displayLabel(context: Context): String? { + startMonitoring(context.applicationContext) + return displayLabel + } + + fun signalBars(context: Context): Int? { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) return null + val telephony = context.getSystemService(Context.TELEPHONY_SERVICE) as? TelephonyManager ?: return null + return runCatching { telephony.signalStrength?.level?.coerceIn(0, 4) }.getOrNull() + } + + @Synchronized + private fun startMonitoring(context: Context) { + if (monitoringStarted || Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return + val telephony = context.getSystemService(Context.TELEPHONY_SERVICE) as? TelephonyManager ?: return + monitoringStarted = true + runCatching { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + registerModernCallback(context, telephony) + } else { + registerAndroid11Listener(telephony) + } + }.onFailure { + monitoringStarted = false + retainedCallback = null + } + } + + @android.annotation.TargetApi(Build.VERSION_CODES.S) + private fun registerModernCallback(context: Context, telephony: TelephonyManager) { + val callback = object : TelephonyCallback(), TelephonyCallback.DisplayInfoListener { + override fun onDisplayInfoChanged(displayInfo: TelephonyDisplayInfo) { + displayLabel = cellularGenerationLabel(displayInfo.networkType, displayInfo.overrideNetworkType) + } + } + retainedCallback = callback + telephony.registerTelephonyCallback(context.mainExecutor, callback) + } + + @Suppress("DEPRECATION") + @android.annotation.TargetApi(Build.VERSION_CODES.R) + private fun registerAndroid11Listener(telephony: TelephonyManager) { + val listener = object : PhoneStateListener() { + override fun onDisplayInfoChanged(displayInfo: TelephonyDisplayInfo) { + displayLabel = cellularGenerationLabel(displayInfo.networkType, displayInfo.overrideNetworkType) + } + } + retainedCallback = listener + telephony.listen(listener, PhoneStateListener.LISTEN_DISPLAY_INFO_CHANGED) + } +} + +internal fun cellularGenerationLabel(networkType: Int, overrideNetworkType: Int): String? = when (overrideNetworkType) { + TelephonyDisplayInfo.OVERRIDE_NETWORK_TYPE_NR_ADVANCED, + TelephonyDisplayInfo.OVERRIDE_NETWORK_TYPE_NR_NSA_MMWAVE, + -> "5G+" + TelephonyDisplayInfo.OVERRIDE_NETWORK_TYPE_NR_NSA -> "5G" + TelephonyDisplayInfo.OVERRIDE_NETWORK_TYPE_LTE_ADVANCED_PRO, + TelephonyDisplayInfo.OVERRIDE_NETWORK_TYPE_LTE_CA, + -> "LTE+" + else -> when (networkType) { + TelephonyManager.NETWORK_TYPE_NR -> "5G" + TelephonyManager.NETWORK_TYPE_LTE -> "LTE" + TelephonyManager.NETWORK_TYPE_HSPAP -> "H+" + TelephonyManager.NETWORK_TYPE_HSPA, + TelephonyManager.NETWORK_TYPE_HSDPA, + TelephonyManager.NETWORK_TYPE_HSUPA, + -> "H" + TelephonyManager.NETWORK_TYPE_UMTS, + TelephonyManager.NETWORK_TYPE_TD_SCDMA, + TelephonyManager.NETWORK_TYPE_EVDO_0, + TelephonyManager.NETWORK_TYPE_EVDO_A, + TelephonyManager.NETWORK_TYPE_EVDO_B, + TelephonyManager.NETWORK_TYPE_EHRPD, + -> "3G" + TelephonyManager.NETWORK_TYPE_EDGE -> "E" + TelephonyManager.NETWORK_TYPE_GPRS, + TelephonyManager.NETWORK_TYPE_GSM, + TelephonyManager.NETWORK_TYPE_CDMA, + TelephonyManager.NETWORK_TYPE_1xRTT, + -> "2G" + else -> null + } +} diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/ControllerFocusFrame.kt b/android/app/src/main/java/com/opencloudgaming/opennow/ControllerFocusFrame.kt new file mode 100644 index 000000000..51f416965 --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/ControllerFocusFrame.kt @@ -0,0 +1,314 @@ +package com.opencloudgaming.opennow + +import android.graphics.DiscretePathEffect +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.toComposePathEffect +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.opencloudgaming.opennow.ui.theme.LocalReduceMotion +import com.opencloudgaming.opennow.ui.theme.OpenNowPalette +import kotlin.math.PI +import kotlin.math.floor +import kotlin.math.sin + +private const val ENERGY_ORBIT_DURATION_MS = 2_600 + +internal fun shouldShowEnhancedControllerFocus( + focused: Boolean, + tvProfile: Boolean, + controllerActionMode: Boolean, +): Boolean = focused && (tvProfile || controllerActionMode) + +internal fun shouldShowActiveSelectionOutline( + selected: Boolean, + enabled: Boolean, +): Boolean = selected && enabled + +internal fun shouldAnimateControllerFocusFrame( + absoluteCinemaEnabled: Boolean, + reduceMotion: Boolean, +): Boolean = absoluteCinemaEnabled && !reduceMotion + +internal fun shouldDrawStaticInteractionFocus( + visible: Boolean, + cinemaEffectEnabled: Boolean, +): Boolean = visible && !cinemaEffectEnabled + +/** + * Static game-card strokes are independent from the optional animated sibling frame. Controller + * focus always keeps a bold white navigation cue when those effects are disabled, even if the user + * also turned off resting game borders. + */ +internal fun catalogCardBorderColor( + selectionColor: Color, + gameBorderEnabled: Boolean, + controllerFocused: Boolean = false, + borderEffectsEnabled: Boolean = false, +): Color = when { + controllerFocused && !borderEffectsEnabled -> Color.White + gameBorderEnabled -> selectionColor + else -> Color.Transparent +} + +internal fun cinemaBorderColor( + absoluteCinemaEnabled: Boolean, + cinemaColor: Color, +): Color = if (absoluteCinemaEnabled) cinemaColor else Color.Transparent + +private fun controllerFocusLoopProgress(progress: Float): Float { + val clamped = progress.coerceIn(0f, 1f) + return if (clamped >= 1f) 0f else clamped +} + +internal fun controllerFocusOrbitPhasePx(progress: Float, perimeterPx: Float): Float = + controllerFocusLoopProgress(progress) * perimeterPx.coerceAtLeast(0f) + +internal fun controllerFocusStaticStep(progress: Float): Int = + floor(controllerFocusLoopProgress(progress) * 48f).toInt() + +internal fun controllerFocusFlickerAlpha(progress: Float): Float { + val loop = controllerFocusLoopProgress(progress) + return ( + 0.88f + + 0.07f * sin(loop * 43.982296f) + + 0.05f * sin(loop * 81.68141f) + ).coerceIn(0.72f, 1f) +} + +/** Keeps the classic Cinema palette scoped to animated energy instead of static theme borders. */ +internal fun controllerFocusEnergyColors( + absoluteCinemaPalette: Boolean, + tint: Color?, + secondaryTint: Color?, +): Pair = if (absoluteCinemaPalette) { + OpenNowPalette.AccentCinemaOrange to OpenNowPalette.AccentCinemaBlue +} else { + (tint ?: Color.White) to (secondaryTint ?: tint?.focusShade() ?: Color.White) +} + +/** + * Draws on the exact bounds of an unclipped parent [BoxScope]. Keep this as a sibling of the + * clipped card or artwork so the core follows its edge and the glow remains visible outside it. + */ +@Composable +internal fun BoxScope.ControllerFocusFrame( + visible: Boolean, + cornerRadius: Dp, + tint: Color? = null, + secondaryTint: Color? = null, + verticalInset: Dp = 0.dp, +) { + // This component used to fall back to a solid white outline. Borders now belong exclusively + // to the opt-in Absolute Cinema mode. + if (!visible || !LocalAbsoluteCinemaEffects.current) return + val absoluteCinemaPalette = LocalAbsoluteCinemaPalette.current + val animateEnergy = shouldAnimateControllerFocusFrame( + absoluteCinemaEnabled = LocalAbsoluteCinemaEffects.current, + reduceMotion = LocalReduceMotion.current, + ) + if (!animateEnergy) { + Canvas(Modifier.matchParentSize()) { + val insetPx = verticalInset.toPx().coerceIn(0f, size.height / 2f) + drawRoundRect( + color = (tint ?: Color.White).copy(alpha = 0.96f), + topLeft = Offset(0f, insetPx), + size = Size(size.width, (size.height - insetPx * 2f).coerceAtLeast(0f)), + cornerRadius = CornerRadius(cornerRadius.toPx(), cornerRadius.toPx()), + style = Stroke(width = 3.dp.toPx()), + ) + } + return + } + val orbitProgress = rememberInfiniteTransition(label = "controller-focus-energy").animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(ENERGY_ORBIT_DURATION_MS, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + label = "controller-focus-energy-orbit", + ).value + + Canvas(Modifier.matchParentSize()) { + // The path is centered on the parent's exact bounds. Callers place this Canvas beside the + // clipped artwork, so the glow can spill outward instead of consuming the image edge. + val insetPx = verticalInset.toPx().coerceIn(0f, size.height / 2f) + val borderSize = Size(size.width, (size.height - insetPx * 2f).coerceAtLeast(0f)) + if (borderSize.width == 0f || borderSize.height == 0f) return@Canvas + val radius = cornerRadius.toPx().coerceAtLeast(0f) + val perimeter = ( + 2f * (borderSize.width + borderSize.height - 4f * radius) + + 2f * PI.toFloat() * radius + ).coerceAtLeast(1f) + val progress = orbitProgress + val orbitPhase = controllerFocusOrbitPhasePx(progress, perimeter) + val arcIntervals = floatArrayOf(perimeter * 0.44f, perimeter * 0.56f) + val blueArc = PathEffect.dashPathEffect(arcIntervals, orbitPhase) + val fireArc = PathEffect.dashPathEffect(arcIntervals, orbitPhase + perimeter * 0.5f) + val staticStep = controllerFocusStaticStep(progress) + val blueStatic = PathEffect.chainPathEffect( + DiscretePathEffect( + (3.7f + (staticStep % 4) * 0.15f).dp.toPx(), + (1.2f + (staticStep % 5) * 0.08f).dp.toPx(), + ).toComposePathEffect(), + blueArc, + ) + val fireStatic = PathEffect.chainPathEffect( + DiscretePathEffect( + (3.8f + ((staticStep + 1) % 4) * 0.14f).dp.toPx(), + (1.16f + ((staticStep + 2) % 5) * 0.09f).dp.toPx(), + ).toComposePathEffect(), + fireArc, + ) + val flicker = controllerFocusFlickerAlpha(orbitProgress) + val topLeft = Offset(0f, insetPx) + val roundedCorner = CornerRadius(radius, radius) + + fun drawEnergyArc(color: Color, hotColor: Color, smooth: PathEffect, electric: PathEffect) { + drawRoundRect( + color = color.copy(alpha = 0.18f * flicker), + topLeft = topLeft, + size = borderSize, + cornerRadius = roundedCorner, + style = Stroke(width = 9.dp.toPx(), cap = StrokeCap.Round, pathEffect = smooth), + ) + drawRoundRect( + color = color.copy(alpha = 0.98f), + topLeft = topLeft, + size = borderSize, + cornerRadius = roundedCorner, + style = Stroke(width = 2.8.dp.toPx(), cap = StrokeCap.Round, pathEffect = smooth), + ) + drawRoundRect( + color = hotColor.copy(alpha = flicker), + topLeft = topLeft, + size = borderSize, + cornerRadius = roundedCorner, + style = Stroke(width = 1.15.dp.toPx(), cap = StrokeCap.Round, pathEffect = electric), + ) + } + + val (firstColor, secondColor) = controllerFocusEnergyColors( + absoluteCinemaPalette = absoluteCinemaPalette, + tint = tint, + secondaryTint = secondaryTint, + ) + val firstHotColor = if (absoluteCinemaPalette) { + Color(0xffffd166) + } else { + tint?.focusHighlight() ?: Color.White + } + val secondHotColor = if (absoluteCinemaPalette) { + Color(0xffd9f8ff) + } else { + secondaryTint?.focusHighlight() ?: tint ?: Color.White + } + drawEnergyArc(firstColor, firstHotColor, fireArc, fireStatic) + drawEnergyArc(secondColor, secondHotColor, blueArc, blueStatic) + + val sparkOn = 1.4.dp.toPx() + val sparkOff = 8.6.dp.toPx() + val sparkPhase = controllerFocusLoopProgress(progress) * (sparkOn + sparkOff) * 8f + val sparks = PathEffect.chainPathEffect( + DiscretePathEffect( + 3.dp.toPx(), + (1.5f + (staticStep % 4) * 0.12f).dp.toPx(), + ).toComposePathEffect(), + PathEffect.dashPathEffect(floatArrayOf(sparkOn, sparkOff), sparkPhase), + ) + drawRoundRect( + color = Color.White.copy(alpha = 0.48f * flicker), + topLeft = topLeft, + size = borderSize, + cornerRadius = roundedCorner, + style = Stroke(width = 1.dp.toPx(), cap = StrokeCap.Round, pathEffect = sparks), + ) + } +} + +/** + * One focus owner for controls that need a bold white fallback and may opt into Cinema motion. + * Callers must not draw another focused border underneath this frame: doing so produces the + * doubled rings that made Settings look heavier than the first account row. + */ +@Composable +internal fun BoxScope.InteractionFocusFrame( + visible: Boolean, + cornerRadius: Dp, + cinemaEffectEnabled: Boolean, + verticalInset: Dp = 0.dp, +) { + if (!visible) return + val cinemaEffectActive = cinemaEffectEnabled && LocalAbsoluteCinemaEffects.current + if (cinemaEffectActive) { + ControllerFocusFrame( + visible = true, + cornerRadius = cornerRadius, + tint = LocalActiveSelectionColor.current, + secondaryTint = LocalActiveSelectionSecondaryColor.current, + verticalInset = verticalInset, + ) + return + } + if (!shouldDrawStaticInteractionFocus(visible, cinemaEffectActive)) return + Canvas(Modifier.matchParentSize()) { + val insetPx = verticalInset.toPx().coerceIn(0f, size.height / 2f) + drawRoundRect( + color = Color.White.copy(alpha = 0.96f), + topLeft = Offset(0f, insetPx), + size = Size(size.width, (size.height - insetPx * 2f).coerceAtLeast(0f)), + cornerRadius = CornerRadius(cornerRadius.toPx(), cornerRadius.toPx()), + style = Stroke(width = 3.dp.toPx()), + ) + } +} + +/** + * The opt-in, everywhere variant deliberately stays interaction-driven: only the surface currently + * under a pointer or focus receives another animated canvas. This keeps the exuberant look without + * running an infinite transition for every visible control at once. + */ +@Composable +internal fun BoxScope.AbsoluteCinemaEverywhereFrame( + visible: Boolean, + cornerRadius: Dp, + verticalInset: Dp = 0.dp, +) { + ControllerFocusFrame( + visible = visible && LocalAbsoluteCinemaEverywhere.current, + cornerRadius = cornerRadius, + tint = LocalActiveSelectionColor.current, + secondaryTint = LocalActiveSelectionSecondaryColor.current, + verticalInset = verticalInset, + ) +} + +private fun Color.focusShade(): Color = Color( + red = red * 0.62f, + green = green * 0.62f, + blue = blue * 0.62f, + alpha = alpha, +) + +private fun Color.focusHighlight(): Color = Color( + red = red + (1f - red) * 0.42f, + green = green + (1f - green) * 0.42f, + blue = blue + (1f - blue) * 0.42f, + alpha = alpha, +) diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/DiagnosticHistoryStore.kt b/android/app/src/main/java/com/opencloudgaming/opennow/DiagnosticHistoryStore.kt new file mode 100644 index 000000000..34d2078c4 --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/DiagnosticHistoryStore.kt @@ -0,0 +1,158 @@ +package com.opencloudgaming.opennow + +import java.io.BufferedReader +import java.io.File +import java.io.InputStreamReader +import java.io.OutputStream +import java.io.OutputStreamWriter +import java.util.zip.Deflater +import java.util.zip.GZIPInputStream +import java.util.zip.GZIPOutputStream + +internal data class PreviousDiagnosticSnapshot( + val capturedAtEpochMs: Long, + val text: String, +) + +/** + * Keeps the latest bounded diagnostic export from the current process so the next app run can + * attach it. Snapshots are compressed because HTTP response diagnostics are intentionally rich, + * and writing an uncompressed copy every few seconds would create unnecessary storage traffic on + * lower-end Android TV hardware. + */ +internal class DiagnosticHistoryStore( + directory: File, + private val nowEpochMs: () -> Long = System::currentTimeMillis, +) { + private val historyDirectory = File(directory, DIRECTORY_NAME) + private val currentFile = File(historyDirectory, CURRENT_FILE_NAME) + private val previousFile = File(historyDirectory, PREVIOUS_FILE_NAME) + + /** + * Promotes the last process snapshot exactly once during Application startup. If the previous + * process died before creating a usable snapshot, the older previous snapshot is preserved. + */ + @Synchronized + fun beginAppRun() { + historyDirectory.mkdirs() + recoverInterruptedReplacement(currentFile) + recoverInterruptedReplacement(previousFile) + val current = readSnapshot(currentFile) + if (current == null) { + currentFile.delete() + return + } + + val stagedPrevious = File(historyDirectory, "$PREVIOUS_FILE_NAME.stage") + stagedPrevious.delete() + currentFile.copyTo(stagedPrevious, overwrite = true) + replaceFile(stagedPrevious, previousFile) + currentFile.delete() + } + + @Synchronized + fun saveCurrent(text: String) { + historyDirectory.mkdirs() + recoverInterruptedReplacement(currentFile) + val stagedCurrent = File(historyDirectory, "$CURRENT_FILE_NAME.stage") + stagedCurrent.delete() + val bounded = boundDiagnosticSnapshot(text) + // This runs periodically during a stream. BEST_SPEED keeps the crash-history feature while + // minimizing CPU contention with WebRTC/decoder threads; snapshots are small and rotated. + FastGzipOutputStream(stagedCurrent.outputStream().buffered()).use { compressed -> + OutputStreamWriter(compressed, Charsets.UTF_8).use { writer -> + writer.append(nowEpochMs().toString()) + writer.append('\n') + writer.append(bounded) + } + } + replaceFile(stagedCurrent, currentFile) + } + + @Synchronized + fun previousSnapshot(): PreviousDiagnosticSnapshot? { + recoverInterruptedReplacement(previousFile) + return readSnapshot(previousFile) + } + + private fun readSnapshot(file: File): PreviousDiagnosticSnapshot? { + if (!file.isFile || file.length() <= 0L) return null + return runCatching { + GZIPInputStream(file.inputStream().buffered()).use { compressed -> + BufferedReader(InputStreamReader(compressed, Charsets.UTF_8)).use { reader -> + val capturedAt = reader.readLine()?.toLongOrNull() ?: return null + val text = reader.readText().trimEnd() + if (text.isBlank()) return null + PreviousDiagnosticSnapshot(capturedAtEpochMs = capturedAt, text = text) + } + } + }.getOrNull() + } + + private fun recoverInterruptedReplacement(target: File) { + val backup = File(historyDirectory, "${target.name}.backup") + if (!target.exists() && backup.isFile) { + backup.renameTo(target) + } else if (target.exists()) { + backup.delete() + } + } + + private fun replaceFile(staged: File, target: File) { + val backup = File(historyDirectory, "${target.name}.backup") + backup.delete() + val hadTarget = target.isFile + if (hadTarget && !target.renameTo(backup)) { + staged.delete() + error("Could not stage existing diagnostic history") + } + if (!staged.renameTo(target)) { + if (hadTarget) backup.renameTo(target) + staged.delete() + error("Could not save diagnostic history") + } + backup.delete() + } + + private companion object { + const val DIRECTORY_NAME = "diagnostic-history" + const val CURRENT_FILE_NAME = "current.txt.gz" + const val PREVIOUS_FILE_NAME = "previous.txt.gz" + } +} + +private class FastGzipOutputStream(output: OutputStream) : GZIPOutputStream(output) { + init { + def.setLevel(Deflater.BEST_SPEED) + } +} + +internal fun boundDiagnosticSnapshot( + text: String, + maxCharacters: Int = 1_500_000, +): String { + require(maxCharacters >= 256) + if (text.length <= maxCharacters) return text + val marker = "\n... persisted diagnostic snapshot truncated ${text.length - maxCharacters} characters ...\n" + val available = (maxCharacters - marker.length).coerceAtLeast(2) + val headLength = available / 2 + val tailLength = available - headLength + return text.take(headLength) + marker + text.takeLast(tailLength) +} + +internal fun appendPreviousDiagnosticSnapshot( + current: String, + previous: PreviousDiagnosticSnapshot?, +): String { + if (previous == null) return current + return buildString(current.length + previous.text.length + 160) { + append(current.trimEnd()) + appendLine() + appendLine() + appendLine("previousAppRun.diagnostics:") + appendLine("previousAppRun.capturedAtEpochMs=${previous.capturedAtEpochMs}") + appendLine("----- BEGIN PREVIOUS APP RUN -----") + appendLine(previous.text.trimEnd()) + append("----- END PREVIOUS APP RUN -----") + } +} diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/Diagnostics.kt b/android/app/src/main/java/com/opencloudgaming/opennow/Diagnostics.kt new file mode 100644 index 000000000..26c08a8c3 --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/Diagnostics.kt @@ -0,0 +1,274 @@ +package com.opencloudgaming.opennow + +import android.os.SystemClock +import android.util.Log +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.Request +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.RequestBody.Companion.toRequestBody +import okio.Buffer +import java.util.Locale +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +internal const val OPENNOW_DEBUG_LOG_TAG = "OpenNOWDebug" + +private const val DIAGNOSTIC_PAYLOAD_BODY_LIMIT = 20_000 +private const val HTTP_DIAGNOSTIC_LIMIT = 80 +private const val HTTP_DIAGNOSTIC_BODY_LIMIT = 4_000 +private const val HTTP_DIAGNOSTIC_MAX_REQUEST_CAPTURE_BYTES = 48_000L + +private val DIAGNOSTIC_SENSITIVE_TEXT_PATTERN = Regex( + """(?i)\b(authorization|access[_-]?token|id[_-]?token|refresh[_-]?token|client[_-]?token|device[_-]?code|user[_-]?code|verification[_-]?uri[_-]?complete|credential|password|secret|cookie|code|sub)(\s*[=:]\s*)([^\s,;&]+)""", +) +private val DIAGNOSTIC_BEARER_PATTERN = Regex("""(?i)\bBearer\s+[A-Za-z0-9._~+/=-]+""") +private val DIAGNOSTIC_JSON_IDENTITY_PATTERN = Regex( + """(?i)([\"']?(?:email|user(?:[_-]?id|[_-]?name)?|display[_-]?name|account[_-]?id|profile[_-]?id|session[_-]?id|server[_-]?ip|device[_-]?id|device[_-]?name|ip[_-]?address)[\"']?\s*:\s*)(\"(?:\\.|[^\"])*\"|'(?:\\.|[^'])*'|[^,}\r\n]+)""", +) +private val DIAGNOSTIC_LINE_IDENTITY_PATTERN = Regex( + """(?im)\b(email|user|user[_-]?id|user[_-]?name|display[_-]?name|account|account[_-]?id|profile[_-]?id|session|session[_-]?id|server|server[_-]?ip|device|device[_-]?id|device[_-]?name|ip[_-]?address)(\s*[=:]\s*)(.*?)(?=\s+[a-z][a-z0-9_.-]*\s*[=:]|$)""", +) +private val DIAGNOSTIC_EMAIL_PATTERN = Regex( + """\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b""", + RegexOption.IGNORE_CASE, +) +private val DIAGNOSTIC_IPV4_PATTERN = Regex("""\b(?:\d{1,3}\.){3}\d{1,3}\b""") +private val DIAGNOSTIC_IPV6_FULL_PATTERN = Regex( + """(?i)(?() + + @Synchronized + fun record( + request: Request, + requestBody: String, + statusCode: Int?, + responseBody: String, + elapsedMs: Long, + error: Throwable? = null, + ) { + val status = statusCode?.toString() ?: "ERR:${error?.javaClass?.simpleName ?: "unknown"}" + val requestBytes = request.body?.safeContentLength()?.takeIf { it >= 0 }?.toString() ?: "none" + val responseBytes = if (responseBody.isBlank() && error != null) "none" else responseBody.length.toString() + val requestPreview = requestBody.takeIf { it.isNotBlank() }?.let(::singleLineDiagnosticPreview) + val responsePreview = responseBody.takeIf { it.isNotBlank() }?.let(::singleLineDiagnosticPreview) + val errorMessage = error?.let { "${it.javaClass.simpleName}: ${it.message.orEmpty()}".take(320) } + val line = buildString { + append(SystemClock.elapsedRealtime()) + append(' ') + append(request.method) + append(' ') + append(redactDiagnosticUrl(request.url.toString())) + append(" -> http=") + append(status) + append(" elapsedMs=") + append(elapsedMs) + append(" reqBytes=") + append(requestBytes) + append(" respBytes=") + append(responseBytes) + if (!requestPreview.isNullOrBlank()) { + append(" request=") + append(requestPreview) + } + if (!responsePreview.isNullOrBlank()) { + append(" response=") + append(responsePreview) + } + if (!errorMessage.isNullOrBlank()) { + append(" error=") + append(errorMessage) + } + } + lines.addLast(line) + while (lines.size > HTTP_DIAGNOSTIC_LIMIT) { + lines.removeFirst() + } + Log.d(OPENNOW_DEBUG_LOG_TAG, "http: $line") + } + + fun captureRequestBody(request: Request): String { + val body = request.body ?: return "" + val contentLength = body.safeContentLength() + if (contentLength > HTTP_DIAGNOSTIC_MAX_REQUEST_CAPTURE_BYTES) { + return "(request body omitted ${contentLength}B)" + } + if (body.isDuplex()) return "(duplex request body omitted)" + if (body.isOneShot()) return "(one-shot request body omitted)" + return runCatching { + val buffer = Buffer() + body.writeTo(buffer) + buffer.readUtf8() + }.getOrElse { error -> + "(request body unavailable ${error.javaClass.simpleName})" + } + } + + @Synchronized + fun snapshot(): String = + if (lines.isEmpty()) { + "network.diagnostics=empty" + } else { + buildString { + appendLine("network.diagnostics:") + lines.forEachIndexed { index, line -> + appendLine("network.${index + 1} $line") + } + }.trimEnd() + } + +} + +internal fun sanitizeDiagnosticLogPayload( + raw: String, + limit: Int = DIAGNOSTIC_PAYLOAD_BODY_LIMIT, +): String { + val trimmed = raw.trim() + if (trimmed.isBlank()) return "(empty)" + val formatted = runCatching { + val sanitized = redactDiagnosticJsonElement(OpenNowJson.parseToJsonElement(trimmed)) + DebugPayloadJson.encodeToString(JsonElement.serializer(), sanitized) + }.getOrElse { + redactDiagnosticText(trimmed) + } + val redacted = redactDiagnosticText(formatted) + return if (redacted.length <= limit) { + redacted + } else { + redacted.take(limit) + "\n... truncated ${redacted.length - limit} chars ..." + } +} + +internal fun redactDiagnosticUrl(raw: String): String { + val parsed = raw.toHttpUrlOrNull() ?: return redactDiagnosticText(raw) + val redactedNames = (0 until parsed.querySize) + .map { parsed.queryParameterName(it) } + .filter(::shouldRedactDiagnosticKey) + .distinct() + if (redactedNames.isEmpty()) return raw + val builder = parsed.newBuilder() + redactedNames.forEach { name -> builder.setQueryParameter(name, "[redacted]") } + return builder.build().toString() +} + +private fun redactDiagnosticJsonElement(element: JsonElement, keyHint: String? = null): JsonElement = + when { + keyHint != null && shouldRedactDiagnosticKey(keyHint) -> JsonPrimitive("[redacted]") + element is JsonObject -> JsonObject(element.mapValues { (key, value) -> redactDiagnosticJsonElement(value, key) }) + element is JsonArray -> JsonArray(element.map { redactDiagnosticJsonElement(it) }) + else -> element + } + +private fun shouldRedactDiagnosticKey(key: String): Boolean { + val normalized = key.lowercase(Locale.US).filter(Char::isLetterOrDigit) + return normalized.contains("authorization") || + normalized.contains("token") || + normalized.contains("credential") || + normalized.contains("password") || + normalized.contains("secret") || + normalized.contains("cookie") || + normalized == "code" || + normalized == "devicecode" || + normalized == "usercode" || + normalized == "verificationuricomplete" || + normalized == "deviceid" || + normalized == "devicehashid" || + normalized == "sub" || + normalized == "email" || + normalized == "userid" +} + +private fun redactDiagnosticText(text: String): String { + return DIAGNOSTIC_SENSITIVE_TEXT_PATTERN.replace(text) { match -> + "${match.groupValues[1]}${match.groupValues[2]}[redacted]" + } +} + +internal fun sanitizeDiagnosticExport(raw: String): String { + var sanitized = DIAGNOSTIC_BEARER_PATTERN.replace(raw, "Bearer [redacted]") + sanitized = redactDiagnosticText(sanitized) + sanitized = DIAGNOSTIC_JSON_IDENTITY_PATTERN.replace(sanitized) { match -> + "${match.groupValues[1]}\"[redacted]\"" + } + sanitized = DIAGNOSTIC_LINE_IDENTITY_PATTERN.replace(sanitized) { match -> + "${match.groupValues[1]}${match.groupValues[2]}[redacted]" + } + sanitized = DIAGNOSTIC_EMAIL_PATTERN.replace(sanitized, "[redacted-email]") + sanitized = DIAGNOSTIC_IPV4_PATTERN.replace(sanitized, "[redacted-ip]") + sanitized = DIAGNOSTIC_IPV6_FULL_PATTERN.replace(sanitized, "[redacted-ip]") + sanitized = DIAGNOSTIC_IPV6_COMPRESSED_PATTERN.replace(sanitized, "[redacted-ip]") + sanitized = DIAGNOSTIC_UUID_PATTERN.replace(sanitized, "[redacted-id]") + return sanitized +} + +private const val ANDROID_DIAGNOSTIC_PASTE_URL = + "https://paste.rtech.support/upload/opennow-android-diagnostics.txt" +private const val ANDROID_DIAGNOSTIC_PASTE_EXPIRY_SECONDS = 86_400 + +internal suspend fun uploadAndroidDiagnosticPaste( + http: OkHttpClient, + sanitizedText: String, +): String = withContext(Dispatchers.IO) { + val request = Request.Builder() + .url(ANDROID_DIAGNOSTIC_PASTE_URL) + .header("Accept", "application/json") + .header("Linx-Randomize", "yes") + .header("Linx-Expiry", ANDROID_DIAGNOSTIC_PASTE_EXPIRY_SECONDS.toString()) + .put(sanitizedText.toRequestBody("text/plain; charset=utf-8".toMediaType())) + .build() + http.newCall(request).execute().use { response -> + val body = response.body.string().trim() + if (!response.isSuccessful) { + error("Diagnostics upload failed (HTTP ${response.code})") + } + val jsonUrl = runCatching { + OpenNowJson.parseToJsonElement(body).jsonObject["url"]?.jsonPrimitive?.content + }.getOrNull() + (jsonUrl ?: body.lineSequence().firstOrNull { it.startsWith("https://") }) + ?.trim() + ?.takeIf { it.startsWith("https://paste.rtech.support/") } + ?: error("Diagnostics upload returned no paste URL") + } +} + +private fun singleLineDiagnosticPreview(raw: String): String { + // Parsing and pretty-printing multi-hundred-kilobyte catalog responses used to run + // before the preview was truncated. Keep diagnostics bounded before any JSON work. + val bounded = if (raw.length > HTTP_DIAGNOSTIC_BODY_LIMIT * 2) { + raw.take(HTTP_DIAGNOSTIC_BODY_LIMIT * 2) + + "\n... omitted ${raw.length - (HTTP_DIAGNOSTIC_BODY_LIMIT * 2)} chars before formatting ..." + } else { + raw + } + return redactDiagnosticText(sanitizeDiagnosticLogPayload(bounded, HTTP_DIAGNOSTIC_BODY_LIMIT)) + .lineSequence() + .joinToString(" ") { it.trim() } + .take(HTTP_DIAGNOSTIC_BODY_LIMIT) +} + +private fun okhttp3.RequestBody.safeContentLength(): Long = + runCatching { contentLength() }.getOrDefault(-1L) diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/DisplayRefreshRate.kt b/android/app/src/main/java/com/opencloudgaming/opennow/DisplayRefreshRate.kt new file mode 100644 index 000000000..44a9d1a0f --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/DisplayRefreshRate.kt @@ -0,0 +1,99 @@ +package com.opencloudgaming.opennow + +import kotlin.math.abs +import kotlin.math.roundToInt + +internal data class DisplayRefreshMode( + val id: Int, + val refreshRate: Float, + val physicalWidth: Int, + val physicalHeight: Int, +) + +internal object DisplayRefreshDiagnostics { + @Volatile + private var latestSnapshot = "display.refresh=unavailable" + + fun update( + active: Boolean, + requestedFps: Int, + currentMode: DisplayRefreshMode?, + selectedMode: DisplayRefreshMode?, + supportedModes: List, + preferredModeId: Int, + preferredRefreshRate: Float, + applied: Boolean, + error: Throwable? = null, + ) { + latestSnapshot = buildString { + appendLine("display.refresh.active=$active requestedFps=$requestedFps applied=$applied") + appendLine("display.refresh.current=${currentMode.debugLabel()} selected=${selectedMode.debugLabel()}") + appendLine("display.refresh.preferredModeId=$preferredModeId preferredRefreshRate=${preferredRefreshRate.formatRefreshRate()}") + appendLine("display.refresh.supported=${supportedModes.supportedModesLabel()}") + error?.let { + appendLine("display.refresh.error=${it.javaClass.simpleName}:${it.message.orEmpty().take(120)}") + } + }.trimEnd() + } + + fun snapshot(): String = latestSnapshot +} + +internal fun selectStreamDisplayMode( + supportedModes: List, + currentMode: DisplayRefreshMode?, + requestedFps: Int, +): DisplayRefreshMode? { + if (supportedModes.isEmpty()) return null + val target = requestedFps.coerceIn(MIN_STREAM_DISPLAY_FPS, MAX_STREAM_DISPLAY_FPS).toFloat() + val resolutionMatched = currentMode?.let { current -> + supportedModes.filter { mode -> + mode.physicalWidth == current.physicalWidth && mode.physicalHeight == current.physicalHeight + } + }.orEmpty() + val candidates = resolutionMatched.ifEmpty { supportedModes } + val cadenceMatched = candidates.filter { mode -> + mode.refreshRate + STREAM_REFRESH_TOLERANCE_FPS >= target && + streamCadenceError(mode.refreshRate, target) <= STREAM_CADENCE_TOLERANCE + } + val currentCandidate = currentMode?.takeIf { current -> + cadenceMatched.any { mode -> mode.id == current.id } + } + if (currentCandidate != null) return currentCandidate + + return cadenceMatched.minByOrNull { it.refreshRate } + ?: candidates + .filter { it.refreshRate + STREAM_REFRESH_TOLERANCE_FPS >= target } + .minByOrNull { it.refreshRate } + ?: candidates.maxByOrNull { it.refreshRate } +} + +private fun streamCadenceError(refreshRate: Float, streamFps: Float): Float { + if (refreshRate <= 0f || streamFps <= 0f) return Float.MAX_VALUE + val ratio = refreshRate / streamFps + return abs(ratio - ratio.roundToInt().coerceAtLeast(1)) +} + +internal fun normalizedStreamDisplayFps(requestedFps: Int): Float = + requestedFps.coerceIn(MIN_STREAM_DISPLAY_FPS, MAX_STREAM_DISPLAY_FPS).toFloat() + +private fun List.supportedModesLabel(): String = + if (isEmpty()) { + "[]" + } else { + sortedWith(compareBy { it.physicalWidth * it.physicalHeight }.thenBy { it.refreshRate }.thenBy { it.id }) + .joinToString(prefix = "[", postfix = "]") { it.debugLabel() } + } + +private fun DisplayRefreshMode?.debugLabel(): String = + this?.let { "id=${it.id}:${it.physicalWidth}x${it.physicalHeight}@${it.refreshRate.formatRefreshRate()}Hz" } ?: "none" + +private fun Float.formatRefreshRate(): String { + val rounded = (this * 100f).roundToInt() / 100f + return if (rounded % 1f == 0f) rounded.toInt().toString() else rounded.toString() +} + +private const val MIN_STREAM_DISPLAY_FPS = 30 +private const val MAX_STREAM_DISPLAY_FPS = 360 +private const val STREAM_REFRESH_TOLERANCE_FPS = 0.5f +private const val STREAM_CADENCE_TOLERANCE = 0.01f diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/ExternalMouseAbsolutePosition.kt b/android/app/src/main/java/com/opencloudgaming/opennow/ExternalMouseAbsolutePosition.kt new file mode 100644 index 000000000..dd6b1377d --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/ExternalMouseAbsolutePosition.kt @@ -0,0 +1,46 @@ +package com.opencloudgaming.opennow + +import kotlin.math.roundToInt + +internal data class AbsoluteMousePosition( + val x: Int, + val y: Int, + val width: Int, + val height: Int, +) + +/** + * Tracks a captured physical mouse inside the remote stream extent. + * + * Desktop GFN sends absolute type-5 packets while the host cursor is visible. Android pointer + * capture only supplies relative deltas, so retain the equivalent absolute position locally. + */ +internal class ExternalMouseAbsolutePosition { + private var x = 0f + private var y = 0f + private var initialized = false + + fun moveBy(dx: Int, dy: Int, width: Int, height: Int): AbsoluteMousePosition { + val safeWidth = width.coerceIn(1, 65535) + val safeHeight = height.coerceIn(1, 65535) + if (!initialized) { + x = safeWidth / 2f + y = safeHeight / 2f + initialized = true + } + x = (x + dx).coerceIn(0f, safeWidth.toFloat()) + y = (y + dy).coerceIn(0f, safeHeight.toFloat()) + return AbsoluteMousePosition( + x = x.roundToInt(), + y = y.roundToInt(), + width = safeWidth, + height = safeHeight, + ) + } + + fun reset() { + x = 0f + y = 0f + initialized = false + } +} diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/GameDetailsTransition.kt b/android/app/src/main/java/com/opencloudgaming/opennow/GameDetailsTransition.kt new file mode 100644 index 000000000..eb146753a --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/GameDetailsTransition.kt @@ -0,0 +1,132 @@ +package com.opencloudgaming.opennow + +import androidx.compose.runtime.Stable +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned +import com.opencloudgaming.opennow.ui.theme.LocalReduceMotion +import com.opencloudgaming.opennow.ui.theme.OpenNowMotion + +/** The last catalog artwork rectangle activated by the user, in window coordinates. */ +internal data class GameDetailsTransitionOrigin( + val gameId: String, + val bounds: Rect, + val kind: GameDetailsTransitionKind, +) + +internal enum class GameDetailsTransitionKind { + Card, + Hero, +} + +/** + * Bridges catalog artwork and the details overlay without coupling navigation state to view + * geometry. The source kind decides whether the surface or only its artwork should transform. + */ +@Stable +internal class GameDetailsTransitionRegistry { + private var latestOrigin by mutableStateOf(null) + + fun record(gameId: String, bounds: Rect, kind: GameDetailsTransitionKind) { + if (bounds.width <= 0f || bounds.height <= 0f) return + latestOrigin = GameDetailsTransitionOrigin(gameId, bounds, kind) + } + + fun originFor(gameId: String): GameDetailsTransitionOrigin? = latestOrigin + ?.takeIf { it.gameId == gameId } + + fun clear(gameId: String) { + if (latestOrigin?.gameId == gameId) latestOrigin = null + } +} + +internal val LocalGameDetailsTransitionRegistry = + staticCompositionLocalOf { null } + +internal data class GameDetailsContainerTransform( + val scaleX: Float, + val scaleY: Float, + val translationX: Float, + val translationY: Float, +) + +/** Maps a full details surface onto the source card at 0 and onto itself at 1. */ +internal fun gameDetailsContainerTransform( + source: Rect, + target: Rect, + progress: Float, +): GameDetailsContainerTransform { + if (target.width <= 0f || target.height <= 0f) { + return GameDetailsContainerTransform(1f, 1f, 0f, 0f) + } + val fraction = progress.coerceIn(0f, 1f) + fun interpolate(start: Float, end: Float): Float = start + ((end - start) * fraction) + return GameDetailsContainerTransform( + scaleX = interpolate(source.width / target.width, 1f), + scaleY = interpolate(source.height / target.height, 1f), + translationX = interpolate(source.left - target.left, 0f), + translationY = interpolate(source.top - target.top, 0f), + ) +} + +/** + * Moves the details artwork from the activated catalog artwork into its final banner bounds. + * The target is captured before the layer transform and held stable for the short entrance. + */ +@Composable +internal fun Modifier.gameDetailsArtworkEntrance(gameId: String): Modifier { + val transitionOrigin = LocalGameDetailsTransitionRegistry.current + ?.originFor(gameId) + ?.takeIf { it.kind == GameDetailsTransitionKind.Hero } + ?.bounds + val reduceMotion = LocalReduceMotion.current + var targetBounds by remember(gameId) { mutableStateOf(null) } + val progress = remember(gameId) { + Animatable(if (transitionOrigin == null || reduceMotion) 1f else 0f) + } + LaunchedEffect(gameId, transitionOrigin, targetBounds, reduceMotion) { + val target = targetBounds + if (transitionOrigin == null || target == null || reduceMotion) { + if (transitionOrigin == null || reduceMotion) progress.snapTo(1f) + return@LaunchedEffect + } + progress.snapTo(0f) + progress.animateTo( + targetValue = 1f, + animationSpec = tween( + durationMillis = OpenNowMotion.DurationStandard, + easing = OpenNowMotion.EasingStandard, + ), + ) + } + return onGloballyPositioned { coordinates -> + if (targetBounds == null) targetBounds = coordinates.boundsInWindow() + }.graphicsLayer { + val target = targetBounds + if (transitionOrigin != null && target != null && !reduceMotion) { + val transform = gameDetailsContainerTransform( + source = transitionOrigin, + target = target, + progress = progress.value, + ) + transformOrigin = TransformOrigin(0f, 0f) + scaleX = transform.scaleX + scaleY = transform.scaleY + translationX = transform.translationX + translationY = transform.translationY + } + alpha = if (transitionOrigin != null && target == null && !reduceMotion) 0f else 1f + } +} diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/GamepadStateBurstLimiter.kt b/android/app/src/main/java/com/opencloudgaming/opennow/GamepadStateBurstLimiter.kt new file mode 100644 index 000000000..7deb4a882 --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/GamepadStateBurstLimiter.kt @@ -0,0 +1,47 @@ +package com.opencloudgaming.opennow + +/** + * Coalesces high-rate analog snapshots while keeping the leading state change immediate. + * + * Gamepad packets contain the complete current state, so intermediate stick positions can be + * replaced by the newest position. Button and trigger edges bypass this limiter. + */ +internal class GamepadStateBurstLimiter( + private val minimumIntervalMs: Long, +) { + init { + require(minimumIntervalMs > 0L) + } + + private var lastSentAtMs: Long? = null + private var pendingControllerId: Int? = null + + fun offer(controllerId: Int, nowMs: Long): Int? { + val lastSent = lastSentAtMs + if (lastSent == null || nowMs - lastSent >= minimumIntervalMs) { + pendingControllerId = null + lastSentAtMs = nowMs + return controllerId + } + pendingControllerId = controllerId + return null + } + + fun delayUntilFlushMs(nowMs: Long): Long? { + if (pendingControllerId == null) return null + val lastSent = lastSentAtMs ?: return 0L + return (minimumIntervalMs - (nowMs - lastSent)).coerceAtLeast(0L) + } + + fun flush(nowMs: Long): Int? { + val controllerId = pendingControllerId ?: return null + pendingControllerId = null + lastSentAtMs = nowMs + return controllerId + } + + fun reset() { + lastSentAtMs = null + pendingControllerId = null + } +} diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/GfnApi.kt b/android/app/src/main/java/com/opencloudgaming/opennow/GfnApi.kt new file mode 100644 index 000000000..dc9c5a286 --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/GfnApi.kt @@ -0,0 +1,3758 @@ +package com.opencloudgaming.opennow + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.SystemClock +import android.util.Base64 +import androidx.browser.customtabs.CustomTabsIntent +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.longOrNull +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonArray +import kotlinx.serialization.json.putJsonObject +import okhttp3.Dns +import okhttp3.FormBody +import okhttp3.Headers +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Credentials +import okhttp3.dnsoverhttps.DnsOverHttps +import java.io.Closeable +import java.io.BufferedReader +import java.io.InputStreamReader +import java.io.OutputStreamWriter +import java.net.BindException +import java.net.InetSocketAddress +import java.net.InetAddress +import java.net.Proxy +import java.net.ServerSocket +import java.net.Socket +import java.net.SocketException +import java.net.SocketTimeoutException +import java.net.URI +import java.net.UnknownHostException +import java.net.URLDecoder +import java.net.URLEncoder +import java.security.MessageDigest +import java.security.SecureRandom +import java.util.Locale +import java.util.UUID +import java.util.concurrent.TimeUnit +import kotlin.math.max +import kotlin.math.min + +private const val GFN_USER_AGENT = + "GFN-PC/22.0 (Android 14) PGC/3.8 (6.36.38319306) okhttp/4.12.0" +// User-Agent used by the official GeForce NOW Android client for touch sessions. +private const val GFN_ANDROID_TOUCH_USER_AGENT = + "GFN-PC/22.0 (Android-Generic-Touch 14) PGC/3.8 (6.36.38319306) okhttp/4.12.0" +// User-Agent used by the official GeForce NOW Android client for TV sessions. +private const val GFN_ANDROID_TV_USER_AGENT = + "GFN-PC/22.0 (Android-Generic-TV 14) PGC/3.8 (6.36.38319306) okhttp/4.12.0" +private const val GFN_CLIENT_VERSION = "2.0.80.173" +private const val GFN_BROWSER_USER_AGENT = + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36" +private const val GFN_BROWSER_CLIENT_VERSION = "2.0.86.124" +private const val LCARS_CLIENT_ID = "ec7e38d4-03af-4b58-b131-cfb0495903ab" +private const val GFN_PLAY_ORIGIN = "https://play.geforcenow.com" +private const val GFN_PLAY_REFERER = "https://play.geforcenow.com/" +private const val NVIDIA_FILE_ORIGIN = "https://nvfile" +private const val NVIDIA_FILE_REFERER = "https://nvfile/" +private const val SERVICE_URLS_ENDPOINT = "https://pcs.geforcenow.com/v1/serviceUrls" +private const val TOKEN_ENDPOINT = "https://login.nvidia.com/token" +private const val CLIENT_TOKEN_ENDPOINT = "https://login.nvidia.com/client_token" +private const val USERINFO_ENDPOINT = "https://login.nvidia.com/userinfo" +private const val AUTH_ENDPOINT = "https://login.nvidia.com/authorize" +private const val DEVICE_AUTHORIZATION_ENDPOINT = "https://login.nvidia.com/device/authorize" +private const val GAMES_GRAPHQL_URL = "https://games.geforce.com/graphql" +internal const val GFN_APPS_GRAPHQL_URL = "https://apps.gxn.nvidia.com/graphql" +private const val MES_URL = "https://mes.geforcenow.com/v4/subscriptions" +private const val PRINTEDWASTE_QUEUE_URL = "https://api.printedwaste.com/gfn/queue/" +private const val PRINTEDWASTE_SERVER_MAPPING_URL = "https://remote.printedwaste.com/config/GFN_SERVERID_TO_REGION_MAPPING" +private const val DEFAULT_STREAMING_SERVICE_URL = "https://prod.cloudmatchbeta.nvidiagrid.net/" +private const val CLIENT_ID = "ZU7sPN-miLujMD95LfOQ453IB0AtjM8sMyvgJ9wCXEQ" +private const val DEVICE_CODE_CLIENT_ID = "q61ddeJrVt7O90Nl-P-N7I36yctih4Ml6FyXLrb6j-U" +private const val DEFAULT_IDP_ID = "PDiAhv2kJTFeQ7WOPqiQ2tRZ7lGhR2X11dXvM4TZSxg" +private const val SCOPES = "openid consent email tk_client age" +private const val PANELS_QUERY_HASH = "46ec15f267a056e7d5e46e629efa929529e5e7542a4850faece90b9f8fa5f810" +internal const val GFN_APP_METADATA_QUERY_HASH = "cf8b620dfd03617017ba7c858cee65197e1ace5180e41be194b39227227ced63" + +private data class CloudMatchClientIdentity( + val platformName: String, + val persistGameSettings: Boolean, + val streamer: String, + val clientType: String, + val clientVersion: String, + val deviceOs: String, + val deviceType: String, + val userAgent: String, + val desktopMonitorDescriptor: Boolean, +) + +private val NVIDIA_BROWSER_CLOUD_MATCH_IDENTITY = CloudMatchClientIdentity( + platformName = "browser", + persistGameSettings = false, + streamer = "WEBRTC", + clientType = "BROWSER", + clientVersion = GFN_BROWSER_CLIENT_VERSION, + deviceOs = "ANDROID", + deviceType = "PHONE", + userAgent = GFN_BROWSER_USER_AGENT, + desktopMonitorDescriptor = false, +) + +private val NVIDIA_NATIVE_CLOUD_MATCH_IDENTITY = CloudMatchClientIdentity( + platformName = "windows", + persistGameSettings = true, + streamer = "NVIDIA-CLASSIC", + clientType = "NATIVE", + clientVersion = GFN_CLIENT_VERSION, + deviceOs = "WINDOWS", + deviceType = "DESKTOP", + userAgent = GFN_BROWSER_USER_AGENT, + desktopMonitorDescriptor = true, +) + +// Touch-capable identity mirroring commit 160e439e: uses the desktop-native streamer/client +// (NVIDIA-CLASSIC / NATIVE) with ANDROID os + TABLET device type. This combination +// tells the server to allocate the full desktop resolution matrix (including ultrawide +// 2560x1080) while still enabling the native touch digitizer on the host. +// desktopMonitorDescriptor = true ensures monitorSettings emits the full desktop +// descriptor (monitorId/positionX/Y/dpi=100). +private val NVIDIA_NATIVE_TOUCH_CLOUD_MATCH_IDENTITY = CloudMatchClientIdentity( + platformName = "browser", + persistGameSettings = false, + streamer = "NVIDIA-CLASSIC", + clientType = "NATIVE", + clientVersion = GFN_CLIENT_VERSION, + deviceOs = "ANDROID", + deviceType = "TABLET", + userAgent = GFN_ANDROID_TOUCH_USER_AGENT, + desktopMonitorDescriptor = true, +) + +// Default high-quality allocation for Android TVs other than an explicitly detected SHIELD. +private val NVIDIA_NATIVE_TV_CLOUD_MATCH_IDENTITY = CloudMatchClientIdentity( + platformName = "android", + persistGameSettings = false, + streamer = "NVIDIA-CLASSIC", + clientType = "NATIVE", + clientVersion = GFN_CLIENT_VERSION, + deviceOs = "ANDROID", + deviceType = "DESKTOP", + userAgent = GFN_ANDROID_TV_USER_AGENT, + desktopMonitorDescriptor = true, +) + +private val ALLIANCE_CLOUD_MATCH_IDENTITY = CloudMatchClientIdentity( + platformName = "windows", + persistGameSettings = true, + streamer = "NVIDIA-CLASSIC", + clientType = "NATIVE", + clientVersion = GFN_CLIENT_VERSION, + deviceOs = "WINDOWS", + deviceType = "DESKTOP", + userAgent = GFN_USER_AGENT, + desktopMonitorDescriptor = true, +) + +// NVIDIA's Browser/WebRTC identity preserves the standard mobile allocation, but CloudMatch limits +// its mode matrix and rejects HDR requests from that client class. Use the internally consistent +// desktop-native identity only for explicit gamepad launches that need the high-quality mode +// matrix. SHIELD and the third-generation Fire TV Cube are the known TV exceptions whose +// Android/native allocations silently provisioned 1080p for higher-resolution requests. Other +// Android TVs retain the Android/native identity. Generic follow-up requests stay on the browser +// identity so they cannot change an existing allocation. +private fun cloudMatchClientIdentity( + streamingBaseUrl: String?, + appLaunchMode: Int? = null, + preferNativeDesktopMode: Boolean = false, + isAndroidTv: Boolean = false, + useDesktopNativeTvIdentity: Boolean = false, +): CloudMatchClientIdentity { + // Touch sessions use the desktop-native CloudMatch identity (NVIDIA-CLASSIC / NATIVE) + // with Android os + TABLET device type, so the server allocates the full desktop + // resolution matrix (including ultrawide 2560×1080) while still enabling the + // native touch digitizer on the host. + if (appLaunchMode == GfnAppLaunchMode.TOUCH_FRIENDLY) { + return NVIDIA_NATIVE_TOUCH_CLOUD_MATCH_IDENTITY + } + val requestedNativeIdentity = when { + appLaunchMode == null || !preferNativeDesktopMode -> null + isAndroidTv && useDesktopNativeTvIdentity -> NVIDIA_NATIVE_CLOUD_MATCH_IDENTITY + isAndroidTv -> NVIDIA_NATIVE_TV_CLOUD_MATCH_IDENTITY + else -> NVIDIA_NATIVE_CLOUD_MATCH_IDENTITY + } + if (streamingBaseUrl.isNullOrBlank()) { + return requestedNativeIdentity ?: NVIDIA_BROWSER_CLOUD_MATCH_IDENTITY + } + val host = streamingBaseUrl.toHttpUrlOrNull()?.host?.lowercase(Locale.US) + ?: return ALLIANCE_CLOUD_MATCH_IDENTITY + val isNvidiaCloudMatch = host == "cloudmatchbeta.nvidiagrid.net" || + host.endsWith(".cloudmatchbeta.nvidiagrid.net") || + host == "cloudmatch.nvidiagrid.net" || + host.endsWith(".cloudmatch.nvidiagrid.net") + if (!isNvidiaCloudMatch) return ALLIANCE_CLOUD_MATCH_IDENTITY + return requestedNativeIdentity ?: NVIDIA_BROWSER_CLOUD_MATCH_IDENTITY +} + +internal fun isNvidiaShieldTvDevice( + androidTvProfile: Boolean, + manufacturer: String?, + model: String?, +): Boolean = + androidTvProfile && + manufacturer?.trim()?.equals("NVIDIA", ignoreCase = true) == true && + model?.contains("SHIELD", ignoreCase = true) == true + +internal fun isThirdGenerationFireTvCubeDevice( + androidTvProfile: Boolean, + manufacturer: String?, + model: String?, +): Boolean = + androidTvProfile && + manufacturer?.trim()?.equals("Amazon", ignoreCase = true) == true && + model?.trim()?.equals("AFTGAZL", ignoreCase = true) == true + +internal fun usesDesktopNativeTvCloudMatchIdentity( + androidTvProfile: Boolean, + manufacturer: String?, + model: String?, +): Boolean = + isNvidiaShieldTvDevice(androidTvProfile, manufacturer, model) || + isThirdGenerationFireTvCubeDevice(androidTvProfile, manufacturer, model) + +/** + * Server-side values, chosen when the session is created. They decide which virtual input devices + * the host sets up, which is why the choice cannot be revisited once the game is running. + * + * [TOUCH_FRIENDLY] is what makes the host present a digitizer. The official client gates its whole + * touch pipeline on it — `enableTouchInput: appLaunchMode === AppLaunchMode.TouchFriendly` — so a + * session created as [GAMEPAD_FRIENDLY] will silently ignore perfectly well-formed touch packets. + */ +internal object GfnAppLaunchMode { + const val DEFAULT = 1 + const val GAMEPAD_FRIENDLY = 2 + const val TOUCH_FRIENDLY = 3 +} + +private const val DEFAULT_REMOTE_CONTROLLERS_BITMAP = 1 +private const val DEFAULT_SUPPORTED_CONTROLLER_TYPE = 2 + +private data class GfnControllerCapabilities( + val remoteControllersBitmap: Int, + val supportedControllerTypes: List, +) + +/** + * CloudMatch provisions input devices when the session is created. Keep controller advertising + * exclusive to non-touch launches so virtual/physical gamepad packets have a host device without + * changing touch-friendly sessions back into controller mode. + */ +private fun gfnControllerCapabilities(appLaunchMode: Int): GfnControllerCapabilities = + if (appLaunchMode == GfnAppLaunchMode.TOUCH_FRIENDLY) { + GfnControllerCapabilities(remoteControllersBitmap = 0, supportedControllerTypes = emptyList()) + } else { + GfnControllerCapabilities( + remoteControllersBitmap = DEFAULT_REMOTE_CONTROLLERS_BITMAP, + supportedControllerTypes = listOf(DEFAULT_SUPPORTED_CONTROLLER_TYPE), + ) + } + +private const val LIBRARY_WITH_TIME_QUERY_HASH = "7f54d6bbbf3b1c09d0e5264dfa36f0f4aaf5e2678f2089f0cbf0d4dda18c3af9" +private const val DEFAULT_LOCALE = "en_US" + +internal fun gfnLocaleForAndroidLanguageTag(languageTag: String): String { + val locale = Locale.forLanguageTag(languageTag.trim().replace('_', '-')) + return when (locale.language.lowercase(Locale.US)) { + "ar" -> "ar_SA" + "de" -> "de_DE" + "es" -> "es_ES" + "fr" -> "fr_FR" + "ja" -> "ja_JP" + "ko" -> "ko_KR" + "nl" -> "nl_NL" + "pl" -> "pl_PL" + "pt" -> if (locale.country.equals("BR", ignoreCase = true)) "pt_BR" else "pt_PT" + "ro" -> "ro_RO" + "ru" -> "ru_RU" + "tr" -> "tr_TR" + "zh" -> if ( + locale.script.equals("Hant", ignoreCase = true) || + locale.country.uppercase(Locale.US) in setOf("TW", "HK", "MO") + ) { + "zh_TW" + } else { + "zh_CN" + } + else -> DEFAULT_LOCALE + } +} +private const val DEFAULT_CATALOG_FETCH_COUNT = 120 +private const val MAX_CATALOG_PAGES = 3 +internal const val MAX_CATALOG_REQUEST_PAGES = 50 +private const val DEFAULT_SORT_ID = DEFAULT_CATALOG_SORT_ID +private const val POPULAR_SORT_ORDER = "itemMetadata.relevance:DESC,sortName:ASC" +private const val LAST_PLAYED_SORT_ORDER = "variants.gfn.library.lastPlayedDate:DESC,sortName:ASC" +private const val GFN_THURSDAY_SECTION_TITLE = "GFN Thursday" +private const val GFN_THURSDAY_SECTION_ID_PREFIX = "section-cbc43218-6ad6-4ff3-8538-bc84f90c796c-" +private const val LIBRARY_APPS_FETCH_COUNT = 200 +private const val MAX_LIBRARY_APPS_PAGES = 25 +private const val LIBRARY_APPS_SORT_ORDER = + "variants.gfn.library.lastPlayedDate:DESC,computedValues.libraryAddedDate:DESC,sortName:ASC" +private const val SESSION_MODIFY_ACTION_AD_UPDATE = 6 +internal const val OPENNOW_STREAM_SETTINGS_METADATA_KEY = "OpenNOWStreamSettingsSignature" +private const val STORAGE_ADDON_TYPE = "STORAGE" +private const val TOTAL_STORAGE_SIZE_IN_GB = "TOTAL_STORAGE_SIZE_IN_GB" +private const val USED_STORAGE_SIZE_IN_GB = "USED_STORAGE_SIZE_IN_GB" +private const val STORAGE_METRO_REGION = "STORAGE_METRO_REGION" +private const val STORAGE_METRO_REGION_NAME = "STORAGE_METRO_REGION_NAME" +private const val ACCOUNT_LINKING_BASE_URL = "https://als.geforcenow.com/v1" +private const val ACCOUNT_LINKING_CLIENT_ID = "gfn-pc" +private const val ACCOUNT_LINKING_REDIRECT_URL = "http://localhost:2259/" + +private val JSON_MEDIA_TYPE = "application/json; charset=utf-8".toMediaType() +private val GRAPHQL_MEDIA_TYPE = "application/graphql".toMediaType() +private val REDIRECT_PORTS = intArrayOf(2259, 6460, 7119, 8870, 9096) +private const val OAUTH_CALLBACK_TIMEOUT_MS = 120_000L +private const val OAUTH_CALLBACK_PROBE_TIMEOUT_MS = 2_000 +private const val OAUTH_CALLBACK_PROBE_PATH = "/opennow-callback-probe" +private const val DEVICE_CODE_MIN_POLL_INTERVAL_SECONDS = 5 +internal const val TOKEN_REFRESH_WINDOW_MS = 10 * 60 * 1000L +internal const val CLIENT_TOKEN_REFRESH_WINDOW_MS = 5 * 60 * 1000L +private val AUTH_RESTORE_MUTEX = Mutex() +private val READY_SESSION_STATUSES = setOf(2, 3) +internal fun shouldResumeClaimedSession(status: Int?, recoveryMode: Boolean): Boolean = + status != 1 && !(recoveryMode && status != null && status in READY_SESSION_STATUSES) +private const val INVALID_SESSION_PROXY_MESSAGE = + "Invalid session proxy URL. Use http://host:port, https://host:port, socks4://host:port, or socks5://host:port." + +internal class SessionClaimNotReadyException( + val latestSession: SessionInfo?, +) : IllegalStateException("Session did not become ready after claiming.") + +internal class TerminalSessionStatusException( + val status: Int, + val latestSession: SessionInfo?, +) : IllegalStateException("Cloud session entered terminal status $status.") + +val OpenNowJson: Json = Json { + ignoreUnknownKeys = true + explicitNulls = false + isLenient = true + encodeDefaults = true +} + +fun defaultHttpClient(): OkHttpClient = + OkHttpClient.Builder() + .addInterceptor { chain -> + val request = chain.request() + val canonicalUrl = canonicalizeGfnRequestUrl(request.url) + val canonicalRequest = if (canonicalUrl == request.url) { + request + } else { + request.newBuilder().url(canonicalUrl).build() + } + chain.proceed(canonicalRequest) + } + .dns(OpenNowDns) + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .pingInterval(15, TimeUnit.SECONDS) + .build() + +internal fun canonicalizeGfnRequestUrl(url: HttpUrl): HttpUrl = + when (url.host.lowercase(Locale.US)) { + "games.geforcenow.com" -> url.newBuilder().host("games.geforce.com").build() + else -> url + } + +private fun metadataEntry(key: String, value: String): JsonObject = buildJsonObject { + put("key", key) + put("value", value) +} + +private fun hdrCapabilitiesJson(): JsonObject = + buildJsonObject { + put("version", 1) + put("hdrEdrSupportedFlagsInUint32", 1) + put("staticMetadataDescriptorId", 0) + } + +private fun hdrDisplayDataJson(): JsonObject = + buildJsonObject { + put("desiredContentMaxLuminance", 1000) + put("desiredContentMinLuminance", 0) + put("desiredContentMaxFrameAverageLuminance", 500) + } + +private data class StreamRequestProfile( + val width: Int, + val height: Int, + val hdrEnabled: Boolean, + val bitDepth: Int, + val chroma: Int, +) + +private fun StreamSettings.requestProfile(): StreamRequestProfile { + val compatible = withCodecColorCompatibility() + val (width, height) = streamResolutionPixels(compatible) + val hdrEnabled = compatible.hdrEnabled + return StreamRequestProfile( + width = width, + height = height, + hdrEnabled = hdrEnabled, + bitDepth = if (hdrEnabled || compatible.colorQuality.name.startsWith("TenBit")) 10 else 0, + chroma = if (compatible.colorQuality == ColorQuality.EightBit444 || compatible.colorQuality == ColorQuality.TenBit444) 2 else 0, + ) +} + +private fun monitorSettings( + profile: StreamRequestProfile, + fps: Int, + identity: CloudMatchClientIdentity, +): JsonObject = + buildJsonObject { + // For touch sessions we MUST emit the full desktop descriptor + // (monitorId=0, positionX=0, positionY=0, dpi=100) so the server + // allocates the full resolution matrix including ultrawide. + if (identity.desktopMonitorDescriptor) { + put("monitorId", 0) + put("positionX", 0) + put("positionY", 0) + } + put("widthInPixels", profile.width) + put("heightInPixels", profile.height) + put("framesPerSecond", fps) + put("sdrHdrMode", if (profile.hdrEnabled) 1 else 0) + put("displayData", if (profile.hdrEnabled) hdrDisplayDataJson() else JsonNull) + put("hdr10PlusGamingData", JsonNull) + put("dpi", if (identity.desktopMonitorDescriptor) 100 else 0) + } + +private fun requestedStreamingFeatures(settings: StreamSettings, profile: StreamRequestProfile): JsonObject = + buildJsonObject { + put("reflex", settings.fps >= 120) + put("bitDepth", profile.bitDepth) + // OpenNOW no longer requests cloud G-Sync. The key stays on the wire with its previous + // default so the request shape CloudMatch validates against is unchanged. + put("cloudGsync", false) + put("enabledL4S", settings.enableL4S) + put("trueHdr", profile.hdrEnabled) + put("mouseMovementFlags", 0) + put("supportedHidDevices", 0) + put("profile", 0) + put("fallbackToLogicalResolution", false) + put("hidDevices", JsonNull) + put("chromaFormat", profile.chroma) + put("prefilterMode", 0) + put("prefilterSharpness", 0) + put("prefilterNoiseReduction", 0) + put("hudStreamingMode", 0) + put("sdrColorSpace", 2) + put("hdrColorSpace", if (profile.hdrEnabled) 4 else 0) + } + +private fun baseWebRtcSessionMetadata(): JsonArray = buildJsonArray { + add(metadataEntry("SubSessionId", UUID.randomUUID().toString())) + add(metadataEntry("wssignaling", "1")) + add(metadataEntry("GSStreamerType", "WebRTC")) + add(metadataEntry("networkType", "Unknown")) + add(metadataEntry("ClientImeSupport", "0")) + add(metadataEntry("surroundAudioInfo", "2")) +} + +private fun webRtcSessionMetadata( + settings: StreamSettings, + profile: StreamRequestProfile, + physicalDisplayResolution: Pair? = null, +): JsonArray = buildJsonArray { + baseWebRtcSessionMetadata().forEach { add(it) } + val requestedResolution = profile.width to profile.height + val (physicalWidth, physicalHeight) = physicalDisplayResolution + ?.takeIf { (width, height) -> + width > 0 && height > 0 && + width >= requestedResolution.first && height >= requestedResolution.second + } + ?: requestedResolution + if (physicalWidth > 0 && physicalHeight > 0) { + add( + metadataEntry( + "clientPhysicalResolution", + buildJsonObject { + put("horizontalPixels", physicalWidth) + put("verticalPixels", physicalHeight) + }.toString(), + ), + ) + } + add(metadataEntry(OPENNOW_STREAM_SETTINGS_METADATA_KEY, streamSettingsSessionSignature(settings))) +} + +internal fun activeSessionMonitorSettings(session: JsonObject): JsonObject? = + session.arr("monitorSettings")?.firstOrNull()?.asObject() + ?: session.obj("sessionRequestData")?.arr("clientRequestMonitorSettings")?.firstOrNull()?.asObject() + +private fun monitorResolution(monitor: JsonObject?): String? { + val width = monitor?.int("widthInPixels") + ?: monitor?.int("horizontalPixels") + ?: monitor?.int("width") + val height = monitor?.int("heightInPixels") + ?: monitor?.int("verticalPixels") + ?: monitor?.int("height") + return if (width != null && height != null && width > 0 && height > 0) "${width}x$height" else null +} + +private fun selectedResolution(value: JsonElement?): String? { + val objectResolution = value.asObject()?.let(::monitorResolution) + if (objectResolution != null) return objectResolution + val arrayResolution = value.asArray()?.firstOrNull()?.asObject()?.let(::monitorResolution) + if (arrayResolution != null) return arrayResolution + val text = value.asString()?.trim().orEmpty() + val match = Regex("""(\d{3,5})\s*[xX]\s*(\d{3,5})""").find(text) ?: return null + return "${match.groupValues[1]}x${match.groupValues[2]}" +} + +internal fun extractSessionMonitorSnapshot(session: JsonObject): SessionMonitorSnapshot? { + val requested = session.obj("sessionRequestData") + ?.arr("clientRequestMonitorSettings") + ?.firstOrNull() + ?.asObject() + val returned = session.arr("monitorSettings")?.firstOrNull()?.asObject() + val snapshot = SessionMonitorSnapshot( + requestedResolution = monitorResolution(requested), + requestedFps = requested?.int("framesPerSecond"), + returnedResolution = monitorResolution(returned), + returnedFps = returned?.int("framesPerSecond"), + finalSelectedResolution = selectedResolution(session["finalSelectedScreenResolution"]), + ) + return snapshot.takeIf { + it.requestedResolution != null || + it.requestedFps != null || + it.returnedResolution != null || + it.returnedFps != null || + it.finalSelectedResolution != null + } +} + +internal fun activeSessionSettingsSignature(session: JsonObject): String? = + session.obj("sessionRequestData")?.arr("metaData")?.metadataValue(OPENNOW_STREAM_SETTINGS_METADATA_KEY) + ?: session.arr("metaData")?.metadataValue(OPENNOW_STREAM_SETTINGS_METADATA_KEY) + +private fun JsonArray.metadataValue(key: String): String? = + firstNotNullOfOrNull { item -> + item.asObject() + ?.takeIf { it.string("key") == key } + ?.string("value") + ?.takeIf(String::isNotBlank) + } + +internal fun buildMinimalClaimRequestBody( + appId: String, + deviceId: String, + settings: StreamSettings? = null, + physicalDisplayResolution: Pair? = null, + streamingBaseUrl: String? = null, + appLaunchMode: Int = GfnAppLaunchMode.GAMEPAD_FRIENDLY, + isAndroidTv: Boolean = false, + useDesktopNativeTvIdentity: Boolean = false, +): JsonObject { + val identity = cloudMatchClientIdentity( + streamingBaseUrl = streamingBaseUrl, + appLaunchMode = appLaunchMode, + preferNativeDesktopMode = if (appLaunchMode == GfnAppLaunchMode.TOUCH_FRIENDLY) false else settings?.requiresNativeDesktopCloudMatchMode() == true, + isAndroidTv = isAndroidTv, + useDesktopNativeTvIdentity = useDesktopNativeTvIdentity, + ) + val profile = settings?.requestProfile() + val controllerCapabilities = gfnControllerCapabilities(appLaunchMode) + return buildJsonObject { + put("action", 2) + put("data", "RESUME") + putJsonObject("sessionRequestData") { + put("audioMode", 2) + put("remoteControllersBitmap", controllerCapabilities.remoteControllersBitmap) + put("sdrHdrMode", if (profile?.hdrEnabled == true) 1 else 0) + put("networkTestSessionId", JsonNull) + putJsonArray("availableSupportedControllers") { + controllerCapabilities.supportedControllerTypes.forEach { add(JsonPrimitive(it)) } + } + put("clientVersion", "30.0") + put("deviceHashId", deviceId) + put("internalTitle", JsonNull) + put("clientPlatformName", if (appLaunchMode == GfnAppLaunchMode.TOUCH_FRIENDLY) "android" else identity.platformName) + if (settings != null && profile != null) { + putJsonArray("clientRequestMonitorSettings") { + add(monitorSettings(profile, settings.fps, identity)) + } + } + put( + "metaData", + if (settings != null && profile != null) { + webRtcSessionMetadata(settings, profile, physicalDisplayResolution) + } else { + baseWebRtcSessionMetadata() + }, + ) + put("surroundAudioInfo", 0) + put("clientTimezoneOffset", java.util.TimeZone.getDefault().getOffset(System.currentTimeMillis())) + put("clientIdentification", "GFN-PC") + put("parentSessionId", JsonNull) + put("appId", appId.toIntOrNull() ?: 0) + put("streamerVersion", 1) + put("appLaunchMode", appLaunchMode) + put("sdkVersion", "1.0") + put("enhancedStreamMode", 1) + put("useOps", true) + put("clientDisplayHdrCapabilities", if (profile?.hdrEnabled == true) hdrCapabilitiesJson() else JsonNull) + put("accountLinked", true) + put("partnerCustomData", "") + put("enablePersistingInGameSettings", identity.persistGameSettings) + put("secureRTSPSupported", false) + put("userAge", 26) + if (settings != null && profile != null) { + put("requestedStreamingFeatures", requestedStreamingFeatures(settings, profile)) + } + } + putJsonArray("metaData") {} + } +} + +private data class SessionProxyConfig( + val normalizedUrl: String, + val proxy: Proxy, + val username: String, + val password: String, +) + +private val sessionProxyClients = mutableMapOf() + +private fun sessionProxyHttpClient(settings: StreamSettings, fallback: OkHttpClient): OkHttpClient { + val proxyConfig = resolveSessionProxyConfig(settings) ?: return fallback + return synchronized(sessionProxyClients) { + sessionProxyClients.getOrPut(proxyConfig.normalizedUrl) { + fallback.newBuilder() + .proxy(proxyConfig.proxy) + .apply { + if (proxyConfig.username.isNotBlank()) { + proxyAuthenticator { _, response -> + if (response.request.header("Proxy-Authorization") != null) { + return@proxyAuthenticator null + } + response.request.newBuilder() + .header("Proxy-Authorization", Credentials.basic(proxyConfig.username, proxyConfig.password)) + .build() + } + } + } + .build() + } + } +} + +private fun resolveSessionProxyConfig(settings: StreamSettings): SessionProxyConfig? { + if (!settings.sessionProxyEnabled) return null + val raw = settings.sessionProxyUrl.trim() + if (raw.isBlank()) return null + val candidate = if (Regex("^[a-z][a-z0-9+.-]*://", RegexOption.IGNORE_CASE).containsMatchIn(raw)) raw else "http://$raw" + val uri = runCatching { URI(candidate) }.getOrNull() ?: error(INVALID_SESSION_PROXY_MESSAGE) + val scheme = uri.scheme?.lowercase(Locale.US) ?: error(INVALID_SESSION_PROXY_MESSAGE) + val host = uri.host?.takeIf { it.isNotBlank() } ?: error(INVALID_SESSION_PROXY_MESSAGE) + val port = uri.port.takeIf { it in 1..65535 } ?: error(INVALID_SESSION_PROXY_MESSAGE) + val proxyType = when (scheme) { + "http", "https" -> Proxy.Type.HTTP + "socks4", "socks5" -> Proxy.Type.SOCKS + else -> error(INVALID_SESSION_PROXY_MESSAGE) + } + val username = uri.userInfo?.substringBefore(":")?.let(::urlDecode).orEmpty() + val password = uri.userInfo?.substringAfter(":", "")?.let(::urlDecode).orEmpty() + val credentials = if (username.isBlank()) "" else "${urlEncode(username)}${if (password.isNotEmpty()) ":${urlEncode(password)}" else ""}@" + return SessionProxyConfig( + normalizedUrl = "$scheme://$credentials$host:$port", + proxy = Proxy(proxyType, InetSocketAddress.createUnresolved(host, port)), + username = username, + password = password, + ) +} + +private data class NamedDnsResolver(val name: String, val dns: Dns) + +private object OpenNowDns : Dns { + private val dohResolvers: List by lazy { + val bootstrapClient = OkHttpClient.Builder() + .connectTimeout(5, TimeUnit.SECONDS) + .readTimeout(5, TimeUnit.SECONDS) + .build() + listOf( + NamedDnsResolver( + name = "cloudflare-doh", + dns = DnsOverHttps.Builder() + .client(bootstrapClient) + .url("https://cloudflare-dns.com/dns-query".toHttpUrl()) + .bootstrapDnsHosts(ipv4(1, 1, 1, 1), ipv4(1, 0, 0, 1)) + .build(), + ), + NamedDnsResolver( + name = "google-doh", + dns = DnsOverHttps.Builder() + .client(bootstrapClient) + .url("https://dns.google/dns-query".toHttpUrl()) + .bootstrapDnsHosts(ipv4(8, 8, 8, 8), ipv4(8, 8, 4, 4)) + .build(), + ), + NamedDnsResolver( + name = "quad9-doh", + dns = DnsOverHttps.Builder() + .client(bootstrapClient) + .url("https://dns.quad9.net/dns-query".toHttpUrl()) + .bootstrapDnsHosts(ipv4(9, 9, 9, 9), ipv4(149, 112, 112, 112)) + .build(), + ), + ) + } + + override fun lookup(hostname: String): List { + val failures = mutableListOf() + val systemResult = runCatching { Dns.SYSTEM.lookup(hostname) } + .onFailure { failures += "system=${it.message ?: it::class.java.simpleName}" } + .getOrNull() + if (!systemResult.isNullOrEmpty()) return systemResult + + for (resolver in dohResolvers) { + val result = runCatching { resolver.dns.lookup(hostname) } + .onFailure { failures += "${resolver.name}=${it.message ?: it::class.java.simpleName}" } + .getOrNull() + if (!result.isNullOrEmpty()) return result + } + + throw UnknownHostException("$hostname: DNS lookup failed after system, Cloudflare, Google, and Quad9 (${failures.joinToString("; ")})") + } + + private fun ipv4(a: Int, b: Int, c: Int, d: Int): InetAddress = + InetAddress.getByAddress(byteArrayOf(a.toByte(), b.toByte(), c.toByte(), d.toByte())) +} + +private fun JsonObject.string(key: String): String? = this[key]?.jsonPrimitive?.contentOrNull +private fun JsonObject.int(key: String): Int? = this[key]?.jsonPrimitive?.intOrNull +private fun JsonObject.long(key: String): Long? = this[key]?.jsonPrimitive?.longOrNull +private fun JsonObject.double(key: String): Double? = this[key]?.jsonPrimitive?.doubleOrNull +private fun JsonObject.boolean(key: String): Boolean? = this[key]?.jsonPrimitive?.booleanOrNull +private fun JsonObject.obj(key: String): JsonObject? = this[key] as? JsonObject +private fun JsonObject.arr(key: String): JsonArray? = this[key] as? JsonArray +private fun JsonElement?.asObject(): JsonObject? = this as? JsonObject +private fun JsonElement?.asArray(): JsonArray? = this as? JsonArray +private fun JsonElement?.asString(): String? = this?.jsonPrimitive?.contentOrNull +private fun JsonElement?.asInt(): Int? = this?.jsonPrimitive?.intOrNull +private fun JsonElement?.asDouble(): Double? = this?.jsonPrimitive?.doubleOrNull +private fun JsonElement?.asBoolean(): Boolean? = this?.jsonPrimitive?.booleanOrNull +private fun JsonObject.graphQlErrorMessage(): String? = + arr("errors") + ?.mapNotNull { it.asObject()?.string("message")?.takeIf(String::isNotBlank) } + ?.takeIf { it.isNotEmpty() } + ?.joinToString(", ") + +private fun JsonObject.checkGraphQlErrors(label: String = "GFN GraphQL"): JsonObject { + graphQlErrorMessage()?.let { message -> error("$label: $message") } + return this +} + +internal fun isAppStoreEnumSerializationError(error: Throwable): Boolean { + var current: Throwable? = error + while (current != null) { + val message = current.message.orEmpty() + if (message.contains("AppStoreEnum") && message.contains("cannot represent value", ignoreCase = true)) { + return true + } + current = current.cause + } + return false +} + +private suspend fun OkHttpClient.awaitText(request: Request): Pair = + withContext(Dispatchers.IO) { + val requestBody = OpenNowHttpDiagnostics.captureRequestBody(request) + val startedAtMs = SystemClock.elapsedRealtime() + try { + newCall(request).execute().use { response -> + val text = response.body?.string().orEmpty() + OpenNowHttpDiagnostics.record( + request = request, + requestBody = requestBody, + statusCode = response.code, + responseBody = text, + elapsedMs = SystemClock.elapsedRealtime() - startedAtMs, + ) + response.code to text + } + } catch (error: Throwable) { + OpenNowHttpDiagnostics.record( + request = request, + requestBody = requestBody, + statusCode = null, + responseBody = "", + elapsedMs = SystemClock.elapsedRealtime() - startedAtMs, + error = error, + ) + throw error + } + } + +private fun bearerAuthorization(token: String): String = "Bearer $token" +private fun gfnJwtAuthorization(token: String): String = "GFNJWT $token" + +private fun Headers.Builder.putDesktopLcars( + token: String? = null, + clientType: String = "NATIVE", + clientStreamer: String = "NVIDIA-CLASSIC", + accept: String = "application/json", + includeUserAgent: Boolean = false, + includeEmptyTokenAuthorization: Boolean = false, +): Headers.Builder { + add("Accept", accept) + if (token != null || includeEmptyTokenAuthorization) add("Authorization", gfnJwtAuthorization(token.orEmpty())) + add("nv-client-id", LCARS_CLIENT_ID) + add("nv-client-type", clientType) + add("nv-client-version", GFN_CLIENT_VERSION) + add("nv-client-streamer", clientStreamer) + add("nv-device-os", "WINDOWS") + add("nv-device-type", "DESKTOP") + if (includeUserAgent) add("User-Agent", GFN_USER_AGENT) + return this +} + +private fun desktopGraphQlHeaders(token: String? = null): Headers = + Headers.Builder() + .add("Accept", "application/json, text/plain, */*") + .add("Content-Type", "application/json") + .add("Origin", GFN_PLAY_ORIGIN) + .add("Referer", GFN_PLAY_REFERER) + .apply { + if (!token.isNullOrBlank()) add("Authorization", gfnJwtAuthorization(token)) + } + .add("nv-client-id", LCARS_CLIENT_ID) + .add("nv-client-type", "NATIVE") + .add("nv-client-version", GFN_CLIENT_VERSION) + .add("nv-client-streamer", "NVIDIA-CLASSIC") + .add("nv-device-os", "WINDOWS") + .add("nv-device-type", "DESKTOP") + .add("nv-device-make", "UNKNOWN") + .add("nv-device-model", "UNKNOWN") + .add("nv-browser-type", "CHROME") + .add("User-Agent", GFN_USER_AGENT) + .build() + +internal fun cloudMatchHeaders( + token: String, + clientId: String, + deviceId: String, + includeOrigin: Boolean, + streamingBaseUrl: String? = null, + appLaunchMode: Int? = null, + preferNativeDesktopMode: Boolean = false, + isAndroidTv: Boolean = false, + useDesktopNativeTvIdentity: Boolean = false, +): Headers { + val identity = cloudMatchClientIdentity( + streamingBaseUrl = streamingBaseUrl, + appLaunchMode = appLaunchMode, + preferNativeDesktopMode = preferNativeDesktopMode, + isAndroidTv = isAndroidTv, + useDesktopNativeTvIdentity = useDesktopNativeTvIdentity, + ) + val userAgent = when { + identity == NVIDIA_NATIVE_CLOUD_MATCH_IDENTITY -> identity.userAgent + isAndroidTv -> GFN_ANDROID_TV_USER_AGENT + appLaunchMode == GfnAppLaunchMode.TOUCH_FRIENDLY -> GFN_ANDROID_TOUCH_USER_AGENT + else -> identity.userAgent + } + val deviceType = when { + isAndroidTv -> "DESKTOP" + appLaunchMode == GfnAppLaunchMode.TOUCH_FRIENDLY -> "TABLET" + else -> identity.deviceType + } + return Headers.Builder() + .add("User-Agent", userAgent) + .add("Authorization", gfnJwtAuthorization(token)) + .add("Content-Type", "application/json") + .add("nv-browser-type", "CHROME") + .add("nv-client-id", clientId) + .add("nv-client-streamer", identity.streamer) + .add("nv-client-type", identity.clientType) + .add("nv-client-version", identity.clientVersion) + .add("nv-device-make", "UNKNOWN") + .add("nv-device-model", "UNKNOWN") + .add("nv-device-os", identity.deviceOs) + .add("nv-device-type", deviceType) + .add("x-device-id", deviceId) + .apply { + if (includeOrigin) { + add("Origin", GFN_PLAY_ORIGIN) + add("Referer", GFN_PLAY_REFERER) + } + } + .build() +} + +private fun normalizeStreamingServiceUrl(value: String): String? { + val url = value.trim().toHttpUrlOrNull() ?: return null + if (url.scheme != "https") return null + val host = url.host + if (host.isBlank() || host.startsWith(".") || host.contains("..")) return null + val port = if (url.port != 443) ":${url.port}" else "" + return "https://$host$port/" +} + +private fun normalizeProvider(provider: LoginProvider): LoginProvider = + provider.copy(streamingServiceUrl = normalizeStreamingServiceUrl(provider.streamingServiceUrl) ?: DEFAULT_STREAMING_SERVICE_URL) + +fun defaultProvider(): LoginProvider = + LoginProvider( + idpId = DEFAULT_IDP_ID, + code = "NVIDIA", + displayName = "NVIDIA", + streamingServiceUrl = DEFAULT_STREAMING_SERVICE_URL, + priority = 0, + ) + +private fun nowMs(): Long = System.currentTimeMillis() +private fun expiresAt(seconds: Int?, defaultSeconds: Int = 86400): Long = nowMs() + ((seconds ?: defaultSeconds) * 1000L) +private fun isExpired(expiresAt: Long?): Boolean = expiresAt == null || expiresAt <= nowMs() +private fun isNearExpiry(expiresAt: Long?, windowMs: Long): Boolean = expiresAt == null || expiresAt - nowMs() < windowMs + +private fun JsonObject.firstString(vararg keys: String): String? = + keys.firstNotNullOfOrNull { key -> string(key)?.trim()?.takeIf(String::isNotEmpty) } + +private fun JsonObject.firstLong(vararg keys: String): Long? = + keys.firstNotNullOfOrNull(::long) + +private fun epochMilliseconds(value: Long): Long = + if (value in 1..9_999_999_999L) value * 1_000L else value + +internal fun parseManualAuthTokens(input: String, currentTimeMs: Long = nowMs()): AuthTokens { + val trimmed = input.trim() + require(trimmed.isNotEmpty()) { "Paste an NVIDIA access token or token-response JSON." } + require(trimmed.length <= 64_000) { "The pasted token data is too large." } + + val root = if (trimmed.startsWith('{')) { + runCatching { OpenNowJson.parseToJsonElement(trimmed).jsonObject } + .getOrElse { throw IllegalArgumentException("The pasted token JSON is invalid.", it) } + } else { + null + } + val tokenObject = root?.obj("tokens") ?: root + val rawAccessToken = tokenObject?.firstString("access_token", "accessToken") ?: trimmed + val accessToken = rawAccessToken.replaceFirst(Regex("^Bearer\\s+", RegexOption.IGNORE_CASE), "").trim() + require(accessToken.isNotEmpty() && !accessToken.startsWith('{')) { + "The pasted data does not contain an access token." + } + + val absoluteExpiry = tokenObject?.firstLong("expires_at", "expiresAt")?.let(::epochMilliseconds) + val expiresInSeconds = tokenObject?.firstLong("expires_in", "expiresIn") + require(expiresInSeconds == null || expiresInSeconds > 0) { "The pasted token expiry is invalid." } + val tokenExpiresAt = absoluteExpiry ?: currentTimeMs + (expiresInSeconds ?: 86_400L) * 1_000L + val clientTokenExpiresAt = tokenObject + ?.firstLong("client_token_expires_at", "clientTokenExpiresAt") + ?.let(::epochMilliseconds) + + return AuthTokens( + accessToken = accessToken, + refreshToken = tokenObject?.firstString("refresh_token", "refreshToken"), + idToken = tokenObject?.firstString("id_token", "idToken"), + expiresAt = tokenExpiresAt, + clientToken = tokenObject?.firstString("client_token", "clientToken"), + clientTokenExpiresAt = clientTokenExpiresAt, + authClientId = tokenObject?.firstString("auth_client_id", "authClientId"), + ) +} + +private fun randomBase64Url(byteCount: Int): String { + val bytes = ByteArray(byteCount) + SecureRandom().nextBytes(bytes) + return Base64.encodeToString(bytes, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING) +} + +private fun sha256Base64Url(input: String): String { + val bytes = MessageDigest.getInstance("SHA-256").digest(input.toByteArray(Charsets.US_ASCII)) + return Base64.encodeToString(bytes, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING) +} + +private fun decodeJwtPayload(token: String): JsonObject? { + val payload = token.split(".").getOrNull(1) ?: return null + return runCatching { + val json = String(Base64.decode(payload, Base64.URL_SAFE or Base64.NO_WRAP), Charsets.UTF_8) + OpenNowJson.parseToJsonElement(json).jsonObject + }.getOrNull() +} + +private fun encoded(value: String): String = URLEncoder.encode(value, Charsets.UTF_8.name()) +private fun urlEncode(value: String): String = URLEncoder.encode(value, Charsets.UTF_8.name()) +private fun urlDecode(value: String): String = URLDecoder.decode(value, Charsets.UTF_8.name()) + +class GfnAuthRepository( + private val context: Context, + private val authStore: AuthStore, + private val http: OkHttpClient = defaultHttpClient(), +) { + private val externalOAuthRedirects = Channel>(capacity = 4) + + private data class OAuthCallbackServers( + val port: Int, + val sockets: List, + ) : Closeable { + override fun close() { + sockets.forEach { socket -> runCatching { socket.close() } } + } + } + + suspend fun loginProviders(): List { + val request = Request.Builder() + .url(SERVICE_URLS_ENDPOINT) + .headers( + Headers.Builder() + .add("Accept", "application/json, text/plain, */*") + .add("Origin", NVIDIA_FILE_ORIGIN) + .add("Referer", NVIDIA_FILE_REFERER) + .add("User-Agent", GFN_USER_AGENT) + .build(), + ) + .build() + val (code, text) = http.awaitText(request) + if (code !in 200..299) return listOf(defaultProvider()) + val root = runCatching { OpenNowJson.parseToJsonElement(text).jsonObject }.getOrNull() ?: return listOf(defaultProvider()) + val providers = root.obj("gfnServiceInfo") + ?.arr("gfnServiceEndpoints") + ?.mapNotNull { item -> + val obj = item.asObject() ?: return@mapNotNull null + val idp = obj.string("idpId") ?: return@mapNotNull null + val codeValue = obj.string("loginProviderCode") ?: "NVIDIA" + val display = obj.string("loginProviderDisplayName") ?: codeValue + val url = obj.string("streamingServiceUrl") ?: return@mapNotNull null + val streamingServiceUrl = normalizeStreamingServiceUrl(url) ?: return@mapNotNull null + normalizeProvider( + LoginProvider( + idpId = idp, + code = codeValue, + displayName = display, + streamingServiceUrl = streamingServiceUrl, + priority = obj.int("loginProviderPriority") ?: 0, + ), + ) + } + ?.sortedWith(compareBy { it.priority }.thenBy { it.displayName }) + .orEmpty() + return providers.ifEmpty { listOf(defaultProvider()) } + } + + suspend fun restore( + forceRefresh: Boolean = false, + throwOnRefreshFailure: Boolean = forceRefresh, + removeExpiredSessionOnFailure: Boolean = true, + ): AuthSession? = + AUTH_RESTORE_MUTEX.withLock { + authStore.reload() + val restored = authStore.activeSession() ?: return@withLock null + var session = restored + if (session.tokens.clientToken.isNullOrBlank() || isNearExpiry(session.tokens.clientTokenExpiresAt, CLIENT_TOKEN_REFRESH_WINDOW_MS)) { + val withClientToken = runCatching { ensureClientToken(session.tokens) }.getOrElse { session.tokens } + if (withClientToken != session.tokens) { + val updatedSession = session.copy(tokens = withClientToken) + if (!authStore.updateSessionIfUnchanged(session, updatedSession)) { + return@withLock authStore.activeSession() + } + session = updatedSession + } + } + + val refreshed = if (forceRefresh || isNearExpiry(session.tokens.expiresAt, TOKEN_REFRESH_WINDOW_MS)) { + refreshSession( + session = session, + forceRefresh = forceRefresh || throwOnRefreshFailure, + removeExpiredSessionOnFailure = removeExpiredSessionOnFailure, + ) + } else { + session + } + + if (refreshed != session && !authStore.updateSessionIfUnchanged(session, refreshed)) { + return@withLock authStore.activeSession() + } + refreshed + } + + suspend fun login( + provider: LoginProvider, + onAuthorizationCodeReceived: suspend () -> Unit = {}, + ): AuthSession { + drainExternalOAuthRedirects() + val callbackServers = openAvailableCallbackServers() + val port = callbackServers.port + val verifier = randomBase64Url(64).take(86) + val challenge = sha256Base64Url(verifier) + val authUrl = buildAuthUrl(provider, challenge, port) + val code = coroutineScope { + val codeDeferred = async(Dispatchers.IO) { waitForAuthorizationCode(callbackServers) } + runCatching { + verifyCallbackListenerReachable(port) + }.onFailure { error -> + codeDeferred.cancel() + callbackServers.close() + throw IllegalStateException( + "OAuth callback listener was not reachable on localhost:$port before opening the browser.", + error, + ) + } + val customTabs = CustomTabsIntent.Builder().build() + customTabs.intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + runCatching { + customTabs.launchUrl(context, Uri.parse(authUrl)) + }.onFailure { + callbackServers.close() + throw it + } + codeDeferred.await() + } + onAuthorizationCodeReceived() + val tokens = ensureClientTokenBestEffort(exchangeAuthorizationCode(code, verifier, port)) + val session = buildSession(provider, tokens) + authStore.upsertSession(session) + return session + } + + fun handleOAuthRedirect(uri: Uri?): Boolean { + if (uri == null || !isLoopbackOAuthRedirect(uri)) return false + val params = uri.queryParameterNames + .associateWith { name -> uri.getQueryParameter(name).orEmpty() } + .filterValues { it.isNotBlank() } + if (!params.containsKey("code") && !params.containsKey("error")) return false + externalOAuthRedirects.trySend(params) + return true + } + + suspend fun loginWithDeviceCode(provider: LoginProvider, onPrompt: suspend (DeviceLoginPrompt) -> Unit): AuthSession { + check(provider.supportsDeviceCodeLogin) { "Code sign-in is only available for NVIDIA accounts." } + val deviceCode = requestDeviceCode(provider) + onPrompt(deviceCode.prompt) + val tokens = ensureClientTokenBestEffort(pollDeviceCodeToken(deviceCode)) + val session = buildSession(provider, tokens) + authStore.upsertSession(session) + return session + } + + suspend fun loginWithToken(provider: LoginProvider, tokenInput: String): AuthSession { + val parsedTokens = parseManualAuthTokens(tokenInput) + require(!isExpired(parsedTokens.expiresAt)) { "The pasted access token has expired." } + val tokens = ensureClientTokenBestEffort(parsedTokens) + val session = buildSession(provider, tokens, requireVerifiedIdentity = true) + authStore.upsertSession(session) + return session + } + + suspend fun logout(userId: String? = null) { + val activeId = userId ?: authStore.activeSession()?.user?.userId + if (activeId != null) authStore.removeSession(activeId) + } + + fun logoutAll() = authStore.clear() + + private data class ClientTokenResponse(val token: String, val expiresAt: Long) + + private suspend fun ensureClientTokenBestEffort(tokens: AuthTokens): AuthTokens = + runCatching { ensureClientToken(tokens) }.getOrElse { tokens } + + private suspend fun ensureClientToken(tokens: AuthTokens): AuthTokens { + val hasUsableClientToken = + !tokens.clientToken.isNullOrBlank() && + !isNearExpiry(tokens.clientTokenExpiresAt, CLIENT_TOKEN_REFRESH_WINDOW_MS) + if (hasUsableClientToken || isExpired(tokens.expiresAt)) return tokens + + val clientToken = requestClientToken(tokens.accessToken) + return tokens.copy( + clientToken = clientToken.token, + clientTokenExpiresAt = clientToken.expiresAt, + ) + } + + private suspend fun requestClientToken(accessToken: String): ClientTokenResponse { + val request = Request.Builder() + .url(CLIENT_TOKEN_ENDPOINT) + .headers(nvidiaFileHeaders(bearerToken = accessToken, includeReferer = false)) + .build() + val (code, text) = http.awaitText(request) + check(code in 200..299) { "Client token request failed ($code): ${text.take(400)}" } + val root = OpenNowJson.parseToJsonElement(text).jsonObject + return ClientTokenResponse( + token = requireNotNull(root.string("client_token")) { "Missing client token" }, + expiresAt = expiresAt(root.int("expires_in")), + ) + } + + private suspend fun refreshSession( + session: AuthSession, + forceRefresh: Boolean, + removeExpiredSessionOnFailure: Boolean, + ): AuthSession { + val tokens = session.tokens + val refreshErrors = mutableListOf() + val refreshClientIds = authenticationRefreshClientIds( + savedClientId = tokens.authClientId, + browserClientId = CLIENT_ID, + deviceClientId = DEVICE_CODE_CLIENT_ID, + ) + + if (!tokens.clientToken.isNullOrBlank()) { + for (clientId in refreshClientIds) { + try { + val refreshed = mergeTokenSnapshot( + base = tokens, + root = refreshWithClientToken(tokens.clientToken, session.user.userId, clientId), + authClientId = clientId, + ) + return buildRefreshedSession(session, ensureClientTokenBestEffort(refreshed), source = "client token") + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + refreshErrors += "client_token(${authClientLabel(clientId)}): ${error.message ?: "Unknown refresh error"}" + } + } + } + + val refresh = tokens.refreshToken + if (!refresh.isNullOrBlank()) { + for (clientId in refreshClientIds) { + try { + val refreshed = refreshAuthTokens(refresh, tokens, clientId) + return buildRefreshedSession(session, ensureClientTokenBestEffort(refreshed), source = "refresh token") + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + refreshErrors += "refresh_token(${authClientLabel(clientId)}): ${error.message ?: "Unknown refresh error"}" + } + } + } + + val hasRefreshMechanism = !tokens.clientToken.isNullOrBlank() || !tokens.refreshToken.isNullOrBlank() + if (!hasRefreshMechanism) { + if (isExpired(tokens.expiresAt)) { + if (removeExpiredSessionOnFailure) { + authStore.removeSession(session.user.userId) + } + error("Saved session expired and has no refresh mechanism. Please log in again.") + } + return session + } + + if (isExpired(tokens.expiresAt)) { + if (removeExpiredSessionOnFailure) { + authStore.removeSession(session.user.userId) + } + val detail = refreshErrors.takeIf { it.isNotEmpty() }?.joinToString(" | ") + error("Token refresh failed and the saved session expired. Please log in again.${detail?.let { " $it" }.orEmpty()}") + } + + if (forceRefresh && refreshErrors.isNotEmpty()) { + error("Token refresh failed. Using saved session token. ${refreshErrors.joinToString(" | ")}") + } + return session + } + + private suspend fun refreshWithClientToken(clientToken: String, userId: String, authClientId: String): JsonObject { + val body = FormBody.Builder() + .add("grant_type", "urn:ietf:params:oauth:grant-type:client_token") + .add("client_token", clientToken) + .add("client_id", authClientId) + .add("sub", userId) + .build() + val request = Request.Builder() + .url(TOKEN_ENDPOINT) + .headers(nvidiaFileHeaders(includeReferer = false)) + .post(body) + .build() + val (code, text) = http.awaitText(request) + check(code in 200..299) { "Client-token refresh failed ($code): ${text.take(400)}" } + return OpenNowJson.parseToJsonElement(text).jsonObject + } + + private suspend fun refreshAuthTokens(refresh: String, base: AuthTokens, authClientId: String): AuthTokens { + val body = FormBody.Builder() + .add("grant_type", "refresh_token") + .add("refresh_token", refresh) + .add("client_id", authClientId) + .build() + val request = Request.Builder() + .url(TOKEN_ENDPOINT) + .headers(nvidiaFileHeaders(includeReferer = false)) + .post(body) + .build() + val (code, text) = http.awaitText(request) + check(code in 200..299) { "Token refresh failed ($code): ${text.take(400)}" } + val root = OpenNowJson.parseToJsonElement(text).jsonObject + return AuthTokens( + accessToken = requireNotNull(root.string("access_token")) { "Missing access token" }, + refreshToken = root.string("refresh_token") ?: refresh, + idToken = root.string("id_token") ?: base.idToken, + expiresAt = expiresAt(root.int("expires_in")), + clientToken = base.clientToken, + clientTokenExpiresAt = base.clientTokenExpiresAt, + authClientId = authClientId, + ) + } + + private fun mergeTokenSnapshot(base: AuthTokens, root: JsonObject, authClientId: String): AuthTokens = + AuthTokens( + accessToken = requireNotNull(root.string("access_token")) { "Missing access token" }, + refreshToken = root.string("refresh_token") ?: base.refreshToken, + idToken = root.string("id_token") ?: base.idToken, + expiresAt = expiresAt(root.int("expires_in")), + clientToken = root.string("client_token") ?: base.clientToken, + clientTokenExpiresAt = base.clientTokenExpiresAt, + authClientId = authClientId, + ) + + private fun authClientLabel(clientId: String): String = + when (clientId) { + CLIENT_ID -> "browser" + DEVICE_CODE_CLIENT_ID -> "device" + else -> "saved" + } + + private suspend fun buildRefreshedSession(session: AuthSession, tokens: AuthTokens, source: String): AuthSession { + val refreshed = buildSession(session.provider, tokens, fallbackUser = session.user) + check(refreshed.user.userId == session.user.userId) { + "Token refresh via $source returned a different account than expected." + } + return refreshed + } + + private suspend fun exchangeAuthorizationCode(code: String, verifier: String, port: Int): AuthTokens { + val body = FormBody.Builder() + .add("grant_type", "authorization_code") + .add("code", code) + .add("redirect_uri", "http://localhost:$port") + .add("code_verifier", verifier) + .build() + val request = Request.Builder() + .url(TOKEN_ENDPOINT) + .headers(nvidiaFileHeaders(includeReferer = true)) + .post(body) + .build() + val (status, text) = http.awaitText(request) + check(status in 200..299) { "Token exchange failed ($status): ${text.take(400)}" } + val root = OpenNowJson.parseToJsonElement(text).jsonObject + return AuthTokens( + accessToken = requireNotNull(root.string("access_token")) { "Missing access token" }, + refreshToken = root.string("refresh_token"), + idToken = root.string("id_token"), + expiresAt = expiresAt(root.int("expires_in")), + clientToken = root.string("client_token"), + authClientId = CLIENT_ID, + ) + } + + private data class DeviceCodeChallenge( + val deviceCode: String, + val prompt: DeviceLoginPrompt, + val intervalSeconds: Int, + ) + + private suspend fun requestDeviceCode(provider: LoginProvider): DeviceCodeChallenge { + val body = FormBody.Builder() + .add("client_id", DEVICE_CODE_CLIENT_ID) + .add("scope", SCOPES) + .add("device_id", authStore.stableDeviceId()) + .add("display_name", androidDeviceDisplayName()) + .add("idp_id", provider.idpId) + .build() + val request = Request.Builder() + .url(DEVICE_AUTHORIZATION_ENDPOINT) + .headers(starfleetFormHeaders()) + .post(body) + .build() + val (status, text) = http.awaitText(request) + check(status in 200..299) { "Device sign-in failed ($status): ${text.take(400)}" } + val root = OpenNowJson.parseToJsonElement(text).jsonObject + val deviceCode = requireNotNull(root.string("device_code")) { "Missing device code" } + val userCode = requireNotNull(root.string("user_code")) { "Missing user code" } + val verificationUri = root.string("verification_uri") + ?: root.string("verification_url") + ?: "https://login.nvidia.com" + val expiresIn = root.int("expires_in") ?: 600 + val interval = (root.int("interval") ?: DEVICE_CODE_MIN_POLL_INTERVAL_SECONDS) + .coerceAtLeast(DEVICE_CODE_MIN_POLL_INTERVAL_SECONDS) + return DeviceCodeChallenge( + deviceCode = deviceCode, + intervalSeconds = interval, + prompt = DeviceLoginPrompt( + userCode = userCode, + verificationUri = verificationUri, + verificationUriComplete = root.string("verification_uri_complete"), + expiresAt = nowMs() + expiresIn * 1000L, + ), + ) + } + + private suspend fun pollDeviceCodeToken(challenge: DeviceCodeChallenge): AuthTokens { + var intervalSeconds = challenge.intervalSeconds + while (nowMs() < challenge.prompt.expiresAt) { + delay(intervalSeconds * 1000L) + val body = FormBody.Builder() + .add("grant_type", "urn:ietf:params:oauth:grant-type:device_code") + .add("device_code", challenge.deviceCode) + .add("client_id", DEVICE_CODE_CLIENT_ID) + .build() + val request = Request.Builder() + .url(TOKEN_ENDPOINT) + .headers(starfleetFormHeaders()) + .post(body) + .build() + val (status, text) = http.awaitText(request) + val root = runCatching { OpenNowJson.parseToJsonElement(text).jsonObject }.getOrNull() + if (status in 200..299 && root != null) { + return AuthTokens( + accessToken = requireNotNull(root.string("access_token")) { "Missing access token" }, + refreshToken = root.string("refresh_token"), + idToken = root.string("id_token"), + expiresAt = expiresAt(root.int("expires_in")), + clientToken = root.string("client_token"), + authClientId = DEVICE_CODE_CLIENT_ID, + ) + } + val error = root?.string("error").orEmpty() + when (error) { + "authorization_pending" -> Unit + "slow_down" -> intervalSeconds += 5 + "access_denied" -> error("Device sign-in was cancelled.") + "expired_token" -> error("Device sign-in code expired.") + else -> check(status in 200..299) { "Device token exchange failed ($status): ${text.take(400)}" } + } + } + error("Device sign-in code expired.") + } + + private suspend fun buildSession( + provider: LoginProvider, + tokens: AuthTokens, + fallbackUser: AuthUser? = null, + requireVerifiedIdentity: Boolean = false, + ): AuthSession { + val userInfoResult = runCatching { fetchUserInfo(tokens.accessToken) } + if (requireVerifiedIdentity && userInfoResult.isFailure) { + throw IllegalArgumentException( + "NVIDIA did not accept the pasted access token.", + userInfoResult.exceptionOrNull(), + ) + } + val userInfo = userInfoResult.getOrDefault(JsonObject(emptyMap())) + val jwt = tokens.idToken?.let(::decodeJwtPayload) + val verifiedUserId = userInfo.string("sub") ?: userInfo.string("id") + if (requireVerifiedIdentity) { + require(!verifiedUserId.isNullOrBlank()) { "The pasted access token did not identify an NVIDIA account." } + } + val userId = verifiedUserId ?: jwt?.string("sub") ?: fallbackUser?.userId ?: "nvidia-user" + val email = userInfo.string("email") ?: jwt?.string("email") ?: fallbackUser?.email + val displayName = userInfo.string("name") + ?: userInfo.string("preferred_username") + ?: email + ?: fallbackUser?.displayName + ?: "NVIDIA Account" + val tier = userInfo.string("membershipTier") ?: jwt?.string("membershipTier") ?: fallbackUser?.membershipTier ?: "FREE" + return AuthSession( + provider = normalizeProvider(provider), + tokens = tokens, + user = AuthUser( + userId = userId, + displayName = displayName, + email = email, + avatarUrl = userInfo.string("picture") ?: fallbackUser?.avatarUrl, + membershipTier = tier, + ), + ) + } + + private suspend fun fetchUserInfo(accessToken: String): JsonObject { + val request = Request.Builder() + .url(USERINFO_ENDPOINT) + .headers(nvidiaFileHeaders(bearerToken = accessToken, includeReferer = true)) + .build() + val (code, text) = http.awaitText(request) + return if (code in 200..299) { + runCatching { OpenNowJson.parseToJsonElement(text).jsonObject }.getOrDefault(JsonObject(emptyMap())) + } else { + JsonObject(emptyMap()) + } + } + + private fun nvidiaFileHeaders(bearerToken: String? = null, includeReferer: Boolean): Headers = + Headers.Builder() + .apply { + if (bearerToken != null) add("Authorization", bearerAuthorization(bearerToken)) + add("Origin", NVIDIA_FILE_ORIGIN) + if (includeReferer) add("Referer", NVIDIA_FILE_REFERER) + add("Accept", "application/json, text/plain, */*") + add("User-Agent", GFN_USER_AGENT) + } + .build() + + private fun starfleetFormHeaders(): Headers = + Headers.Builder() + .add("Accept", "application/json, text/plain, */*") + .add("User-Agent", GFN_USER_AGENT) + .build() + + private fun androidDeviceDisplayName(): String { + val model = listOf(Build.MANUFACTURER, Build.MODEL) + .map { it.trim() } + .filter { it.isNotBlank() && !it.equals("unknown", ignoreCase = true) } + .distinctBy { it.lowercase(Locale.US) } + .joinToString(" ") + return model.ifBlank { "OpenNOW Android" } + } + + private fun buildAuthUrl(provider: LoginProvider, challenge: String, port: Int): String { + val deviceId = authStore.stableDeviceId() + val nonce = randomBase64Url(16) + val params = linkedMapOf( + "response_type" to "code", + "device_id" to deviceId, + "scope" to SCOPES, + "client_id" to CLIENT_ID, + "redirect_uri" to "http://localhost:$port", + "ui_locales" to "en_US", + "nonce" to nonce, + "prompt" to "select_account", + "code_challenge" to challenge, + "code_challenge_method" to "S256", + "idp_id" to provider.idpId, + ).map { (key, value) -> "${encoded(key)}=${encoded(value)}" }.joinToString("&") + return "$AUTH_ENDPOINT?$params" + } + + private suspend fun openAvailableCallbackServers(): OAuthCallbackServers = withContext(Dispatchers.IO) { + for (port in REDIRECT_PORTS) { + val server = runCatching { openCallbackServerSockets(port) }.getOrNull() + if (server != null) return@withContext server + } + error("No available OAuth callback ports") + } + + private suspend fun waitForAuthorizationCode(callbackServers: OAuthCallbackServers): String = withContext(Dispatchers.IO) { + callbackServers.use { + val deadline = System.currentTimeMillis() + OAUTH_CALLBACK_TIMEOUT_MS + while (System.currentTimeMillis() < deadline) { + externalOAuthRedirects.tryReceive().getOrNull()?.let { params -> + authorizationCodeFromParams(params)?.let { code -> return@withContext code } + } + for (server in callbackServers.sockets) { + val remainingMs = deadline - System.currentTimeMillis() + if (remainingMs <= 0L) break + server.soTimeout = minOf(500, remainingMs.coerceAtLeast(1)).toInt() + val socket = try { + server.accept() + } catch (_: SocketTimeoutException) { + null + } catch (error: SocketException) { + if (server.isClosed) null else throw error + } ?: continue + socket.use { callbackSocket -> + val params = runCatching { readCallbackQueryParams(callbackSocket) }.getOrDefault(emptyMap()) + params["error"]?.takeIf { it.isNotBlank() }?.let { error -> + writeCallbackResponse(callbackSocket, "Login failed or was cancelled.") + throw IllegalStateException(error) + } + val code = authorizationCodeFromParams(params) + if (code != null) { + writeCallbackResponse(callbackSocket, "Login complete. Return to OpenNOW.") + return@withContext code + } + writeCallbackResponse(callbackSocket, "Waiting for NVIDIA to finish sign-in.") + } + } + } + throw IllegalStateException("Timed out waiting for OAuth callback") + } + } + + private fun authorizationCodeFromParams(params: Map): String? { + params["error"]?.takeIf { it.isNotBlank() }?.let { error -> + throw IllegalStateException(error) + } + return params["code"]?.takeIf { code -> code.isNotBlank() } + } + + private fun isLoopbackOAuthRedirect(uri: Uri): Boolean { + if (uri.scheme != "http") return false + val host = uri.host?.lowercase(Locale.US) ?: return false + if (host != "localhost" && host != "127.0.0.1" && host != "::1") return false + return uri.port in REDIRECT_PORTS + } + + private fun drainExternalOAuthRedirects() { + while (externalOAuthRedirects.tryReceive().isSuccess) { + // discard stale browser callbacks from earlier attempts + } + } + + private suspend fun verifyCallbackListenerReachable(port: Int) = withContext(Dispatchers.IO) { + val failures = mutableListOf() + val reachable = listOf("127.0.0.1", "::1").any { host -> + runCatching { + probeCallbackListener(host, port) + }.onFailure { error -> + failures += "$host=${error.message ?: error::class.java.simpleName}" + }.getOrDefault(false) + } + check(reachable) { + "OAuth callback listener probe failed (${failures.joinToString("; ")})" + } + } + + private fun probeCallbackListener(host: String, port: Int): Boolean { + val address = InetAddress.getByName(host) + Socket().use { socket -> + socket.connect(InetSocketAddress(address, port), OAUTH_CALLBACK_PROBE_TIMEOUT_MS) + socket.soTimeout = OAUTH_CALLBACK_PROBE_TIMEOUT_MS + val hostHeader = if (host.contains(":")) "[$host]" else host + val writer = OutputStreamWriter(socket.getOutputStream()) + writer.write("GET $OAUTH_CALLBACK_PROBE_PATH HTTP/1.1\r\n") + writer.write("Host: $hostHeader:$port\r\n") + writer.write("Connection: close\r\n\r\n") + writer.flush() + val status = BufferedReader(InputStreamReader(socket.getInputStream())).use { reader -> + reader.readLine().orEmpty() + } + return status.startsWith("HTTP/1.1 200") || status.startsWith("HTTP/1.0 200") + } + } + + private fun openCallbackServerSockets(port: Int): OAuthCallbackServers { + val sockets = mutableListOf() + val failures = mutableListOf() + var portInUse = false + for (host in listOf("127.0.0.1", "::1")) { + runCatching { openCallbackServerSocket(port, host) } + .onSuccess { sockets += it } + .onFailure { error -> + failures += "$host=${error.message ?: error::class.java.simpleName}" + portInUse = portInUse || error is BindException + } + } + if (portInUse) { + sockets.forEach { socket -> runCatching { socket.close() } } + error("OAuth callback port $port is already in use") + } + if (sockets.isEmpty()) { + runCatching { openCallbackServerSocket(port, host = null) } + .onSuccess { sockets += it } + .onFailure { error -> failures += "wildcard=${error.message ?: error::class.java.simpleName}" } + } + if (sockets.isEmpty()) { + error("OAuth callback port $port unavailable (${failures.joinToString("; ")})") + } + return OAuthCallbackServers(port, sockets) + } + + private fun openCallbackServerSocket(port: Int, host: String?): ServerSocket { + val socket = ServerSocket() + return try { + socket.reuseAddress = true + val address = host?.let(InetAddress::getByName) + socket.bind(if (address == null) InetSocketAddress(port) else InetSocketAddress(address, port)) + socket + } catch (error: Throwable) { + runCatching { socket.close() } + throw error + } + } + + private fun readCallbackQueryParams(socket: Socket): Map { + socket.soTimeout = 2_000 + val reader = BufferedReader(InputStreamReader(socket.getInputStream())) + val requestLine = reader.readLine().orEmpty() + while (true) { + val line = reader.readLine() ?: break + if (line.isEmpty()) break + } + val target = requestLine.split(" ").getOrNull(1).orEmpty() + val query = target.substringAfter("?", "") + if (query.isBlank() || query == target) return emptyMap() + return query.split("&").mapNotNull { pair -> + val key = pair.substringBefore("=", "") + val value = pair.substringAfter("=", "") + if (key.isBlank()) null else key to Uri.decode(value) + }.toMap() + } + + private fun writeCallbackResponse(socket: Socket, message: String) { + val html = """ + OpenNOW Login + +
+

OpenNOW Login

$message

+
+ """.trimIndent() + val bytes = html.toByteArray() + val writer = OutputStreamWriter(socket.getOutputStream()) + writer.write("HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: ${bytes.size}\r\nConnection: close\r\n\r\n") + writer.write(html) + writer.flush() + } +} + +internal data class CatalogCardArtwork( + val mobileImageUrl: String?, + val tvImageUrl: String?, +) + +internal fun catalogCardArtwork( + keyArt: String?, + gameBoxArt: String?, + heroImage: String?, + tvBanner: String?, +): CatalogCardArtwork = CatalogCardArtwork( + mobileImageUrl = gameBoxArt?.takeIf { it.isNotBlank() }, + tvImageUrl = listOf(gameBoxArt, keyArt, heroImage, tvBanner).firstOrNull { !it.isNullOrBlank() }, +) + +internal fun catalogScreenshotUrls(images: JsonObject?): List = + images?.arr("SCREENSHOTS") + ?.mapNotNull { it.asString()?.trim()?.takeIf(String::isNotBlank) } + ?.distinct() + .orEmpty() + +internal fun catalogGameDescription(app: JsonObject): String? = + app.string("description") ?: app.string("shortDescription") + +internal fun gameStoreFromVariant(variant: JsonObject): String { + variant.string("appStore")?.trim()?.takeIf(String::isNotBlank)?.let { return it } + + val storeUrl = variant.string("storeUrl")?.trim().orEmpty() + val host = storeUrl.toHttpUrlOrNull()?.host?.lowercase(Locale.US).orEmpty() + val shortName = variant.string("shortName")?.lowercase(Locale.US).orEmpty().removeSuffix("_gfn_pc") + val publisher = variant.string("publisherName")?.lowercase(Locale.US).orEmpty() + return when { + host == "store.steampowered.com" -> "STEAM" + host == "epicgames.com" || host.endsWith(".epicgames.com") -> "EPIC" + host == "gog.com" || host.endsWith(".gog.com") -> "GOG" + host == "store.ubi.com" || host == "register.ubisoft.com" -> "UPLAY" + host == "xbox.com" || host.endsWith(".xbox.com") -> "XBOX" + host == "microsoft.com" || host.endsWith(".microsoft.com") -> "MICROSOFT_STORE" + host == "battle.net" || host.endsWith(".battle.net") -> "BATTLENET" + host == "ea.com" || host.endsWith(".ea.com") -> "EA" + host == "rockstargames.com" || host.endsWith(".rockstargames.com") -> "ROCKSTAR" + host == "play.google.com" -> "GOOGLE_PLAY" + host == "guildwars2.com" || host.endsWith(".guildwars2.com") || + host == "ncsoft.com" || host.endsWith(".ncsoft.com") || + host == "plaync.com" || host.endsWith(".plaync.com") || + host == "purpleonplay.com" || host.endsWith(".purpleonplay.com") || + publisher.contains("ncsoft") -> "NCSOFT" + shortName.endsWith("_steam") -> "STEAM" + shortName.endsWith("_epic") || shortName.endsWith("_egs") || shortName.endsWith("_epic_games_store") -> "EPIC" + shortName.endsWith("_uplay") || shortName.endsWith("_ubisoft") -> "UPLAY" + shortName.endsWith("_gog") -> "GOG" + shortName.endsWith("_xbox") || shortName.endsWith("_game_pass") -> "XBOX" + shortName.endsWith("_origin") || shortName.endsWith("_ea_app") -> "EA" + shortName.endsWith("_battlenet") || shortName.endsWith("_battle_net") -> "BATTLENET" + shortName.endsWith("_ncsoft") || shortName.endsWith("_purple") -> "NCSOFT" + else -> "Unknown" + } +} + +internal fun gfnVariantMetadataFields(includeAppStore: Boolean): String = """ + id + ${if (includeAppStore) "appStore" else ""} + shortName + storeUrl + publisherName + supportedControls + paymentModels { __typename } + gfn { status library { status selected lastPlayedDate } } +""".trimIndent() + +internal data class LibraryBrowseSpec( + val filterIds: List, + val sortOrderId: String?, +) + +internal fun libraryBrowseSpec(payload: JsonObject): LibraryBrowseSpec? = + payload.obj("data")?.arr("panels") + ?.flatMap { panel -> panel.asObject()?.arr("sections").orEmpty() } + ?.mapNotNull { section -> section.asObject()?.obj("seeMoreInfo") } + ?.firstNotNullOfOrNull { seeMore -> + val filterIds = seeMore.arr("filterIds")?.mapNotNull { it.asString() }.orEmpty() + filterIds.takeIf { it.isNotEmpty() }?.let { + LibraryBrowseSpec(filterIds = it, sortOrderId = seeMore.string("sortOrderId")) + } + } + +internal fun libraryAppsFilter(): JsonObject = buildJsonObject { + putJsonObject("variants") { + putJsonObject("gfn") { + putJsonObject("library") { + putJsonObject("status") { + put("notEquals", "NOT_OWNED") + } + } + } + } +} + +internal fun hasFreeToPlayPaymentModel(paymentModels: JsonArray?): Boolean = + paymentModels.orEmpty().any { model -> + val name = model.asObject()?.string("__typename") ?: model.asString() + name == "FreeToPlayPaymentModel" + } + +internal fun mergeSupplementalPublicGameVariants( + games: List, + publicGames: List, +): List { + val publicByTitle = publicGames + .groupBy { it.title.normalizedTitleKey() } + .mapValues { (_, bucket) -> bucket.reduce(::mergeGameInfo) } + return games.map { game -> + val publicGame = publicByTitle[game.title.normalizedTitleKey()] ?: return@map game + val existingStores = game.variants.map { normalizeGameStore(it.store) }.toSet() + val supplemental = publicGame.variants.filter { normalizeGameStore(it.store) !in existingStores } + if (supplemental.isEmpty()) game else game.copy( + launchAppId = game.launchAppId ?: publicGame.launchAppId, + imageUrl = game.imageUrl ?: publicGame.imageUrl, + tvCardImageUrl = game.tvCardImageUrl ?: publicGame.tvCardImageUrl, + screenshotUrl = game.screenshotUrl ?: publicGame.screenshotUrl, + screenshotUrls = (game.screenshotUrls + publicGame.screenshotUrls).distinct(), + tvBannerUrl = game.tvBannerUrl ?: publicGame.tvBannerUrl, + variants = game.variants + supplemental, + availableStores = displayStoresForVariants(game.variants + supplemental), + searchText = listOfNotNull(game.searchText, publicGame.searchText).joinToString(" "), + ) + } +} + +internal enum class CatalogSortKind { + Relevance, + Popular, + NewlyAdded, + LastPlayed, + Other, +} + +internal fun catalogSortKind(sortId: String): CatalogSortKind = + when (sortId.trim().lowercase(Locale.US)) { + "relevance" -> CatalogSortKind.Relevance + "popular", "most_popular" -> CatalogSortKind.Popular + "last_added", "latest", "new_games", "newly_added" -> CatalogSortKind.NewlyAdded + "last_played", "recently_played" -> CatalogSortKind.LastPlayed + else -> CatalogSortKind.Other + } + +internal fun resolveCatalogSort( + options: List, + requestedSortId: String, +): CatalogSortOption { + val requestedKind = catalogSortKind(requestedSortId) + return options.firstOrNull { it.id == requestedSortId } + ?: requestedKind.takeUnless { it == CatalogSortKind.Other }?.let { kind -> + options.firstOrNull { catalogSortKind(it.id) == kind } + } + ?: options.firstOrNull { catalogSortKind(it.id) == CatalogSortKind.Popular } + ?: CatalogSortOption(DEFAULT_SORT_ID, "Most Popular", POPULAR_SORT_ORDER) +} + +internal fun catalogSortOrder(option: CatalogSortOption): String = + when (catalogSortKind(option.id)) { + CatalogSortKind.LastPlayed -> LAST_PLAYED_SORT_ORDER + CatalogSortKind.Popular -> option.orderBy.ifBlank { POPULAR_SORT_ORDER } + else -> option.orderBy + } + +/** + * The provider has returned identical order strings for Last added and Last played on some + * catalogue versions. Last played has trustworthy per-game timestamps, so enforce that one + * locally while preserving the provider order for Popular and New games. + */ +internal fun applyCatalogSortGuarantees( + games: List, + sortId: String, +): List = + if (catalogSortKind(sortId) == CatalogSortKind.LastPlayed) { + games.sortedWith( + compareByDescending { game -> + game.lastPlayed?.takeIf(String::isNotBlank) + ?: game.variants.mapNotNull { it.lastPlayedDate?.takeIf(String::isNotBlank) }.maxOrNull() + }, + ) + } else { + games + } + +/** The MAIN panel is NVIDIA's authoritative weekly list; generic catalogue sort is only fallback. */ +internal fun gfnThursdayCatalogGames(games: List): List = + games.filter { game -> + game.catalogSectionTitle?.trim()?.equals(GFN_THURSDAY_SECTION_TITLE, ignoreCase = true) == true || + game.catalogSectionId?.startsWith(GFN_THURSDAY_SECTION_ID_PREFIX) == true + } + +internal fun catalogResultWithGfnThursdayGames( + fallback: CatalogBrowseResult, + games: List, +): CatalogBrowseResult { + if (games.isEmpty()) return fallback + return fallback.copy( + games = games, + numberReturned = games.size, + numberSupported = games.size, + totalCount = games.size, + hasNextPage = false, + endCursor = null, + searchQuery = "", + selectedSortId = NEWLY_ADDED_CATALOG_SORT_ID, + selectedFilterIds = emptyList(), + ) +} + +class GfnCatalogRepository( + private val http: OkHttpClient = defaultHttpClient(), + private val localeProvider: () -> String = { DEFAULT_LOCALE }, +) { + private data class CachedVpcId(val value: String, val expiresAtElapsedMs: Long) + private data class CachedCatalogDefinitions(val value: CatalogDefinitions, val expiresAtElapsedMs: Long) + private data class CachedPublicGames(val value: List, val expiresAtElapsedMs: Long) + + private val vpcIdMutex = Mutex() + private val vpcIdCache = mutableMapOf() + private val catalogDefinitionsMutex = Mutex() + private val catalogDefinitionsCache = mutableMapOf() + private val publicGamesMutex = Mutex() + private var publicGamesCache: CachedPublicGames? = null + + private fun requestLocale(): String = localeProvider().takeIf { + it.matches(Regex("^[a-z]{2}_[A-Z]{2}$")) + } ?: DEFAULT_LOCALE + + suspend fun fetchMainGames( + token: String, + providerStreamingBaseUrl: String, + includeSupplementalPublicVariants: Boolean = true, + ): List { + val vpcId = getVpcId(token, providerStreamingBaseUrl) + val panels = fetchPanels(token, listOf("MAIN"), vpcId, withLibraryTime = false) + val games = enrichGamesWithMetadata(token, vpcId, flattenPanels(panels)) + return if (includeSupplementalPublicVariants) mergePublicGameVariants(games, fetchPublicGames()) else games + } + + suspend fun fetchGfnThursdayGames( + token: String, + providerStreamingBaseUrl: String, + includeSupplementalPublicVariants: Boolean = true, + ): List { + val vpcId = getVpcId(token, providerStreamingBaseUrl) + val panels = fetchPanels(token, listOf("MAIN"), vpcId, withLibraryTime = false) + val games = enrichGamesWithMetadata( + token = token, + vpcId = vpcId, + games = gfnThursdayCatalogGames(flattenPanels(panels)), + ) + return if (includeSupplementalPublicVariants) mergePublicGameVariants(games, fetchPublicGames()) else games + } + + suspend fun fetchLibraryGames( + token: String, + providerStreamingBaseUrl: String, + includeSupplementalPublicVariants: Boolean = true, + ): List { + val vpcId = getVpcId(token, providerStreamingBaseUrl) + val paginatedLibrary = try { + val page = fetchCatalogAppsPages( + token = token, + vpcId = vpcId, + searchQuery = "", + sortOrder = LIBRARY_APPS_SORT_ORDER, + fetchCount = LIBRARY_APPS_FETCH_COUNT, + filters = libraryAppsFilter(), + maxPages = MAX_LIBRARY_APPS_PAGES, + ) + enrichGamesWithMetadata(token, vpcId, dedupeGames(page.apps.map(::appToGame))) + } catch (error: CancellationException) { + throw error + } catch (_: Exception) { + null + } + if (paginatedLibrary != null) { + return if (includeSupplementalPublicVariants) { + mergePublicGameVariants(paginatedLibrary, fetchPublicGames()) + } else { + paginatedLibrary + } + } + + val panels = runCatching { fetchPanels(token, listOf("LIBRARY"), vpcId, withLibraryTime = true) } + .getOrElse { fetchPanels(token, listOf("LIBRARY"), vpcId, withLibraryTime = false) } + val panelGames = enrichGamesWithMetadata(token, vpcId, flattenPanels(panels)) + val paginatedGames = libraryBrowseSpec(panels)?.let { spec -> + runCatching { + browseCatalog( + token = token, + providerStreamingBaseUrl = providerStreamingBaseUrl, + searchQuery = "", + sortId = spec.sortOrderId ?: DEFAULT_SORT_ID, + filterIds = spec.filterIds, + maxPages = MAX_CATALOG_REQUEST_PAGES, + includeSupplementalPublicVariants = false, + ).games + }.getOrDefault(emptyList()) + }.orEmpty() + val games = mergeKnownLibraryGames(panelGames, paginatedGames) + return if (includeSupplementalPublicVariants) mergePublicGameVariants(games, fetchPublicGames()) else games + } + + /** + * Browse pages intentionally stay lightweight. Hydrate the one game whose details were opened + * so Store entries get genres and the rest of the metadata response without delaying the whole + * catalogue behind hundreds of detail records. + */ + suspend fun hydrateGameDetails( + token: String, + providerStreamingBaseUrl: String, + game: GameInfo, + ): GameInfo { + val appId = game.uuid?.takeIf { it.isNotBlank() } ?: return game + val vpcId = getVpcId(token, providerStreamingBaseUrl) + val metadata = fetchAppMetaData(token, listOf(appId), vpcId) + .firstOrNull { it.string("id") == appId } + ?: return game + return mergePanelGameWithMetadata(game, appToGame(metadata)) + } + + suspend fun browseCatalog( + token: String, + providerStreamingBaseUrl: String, + searchQuery: String, + sortId: String = DEFAULT_SORT_ID, + filterIds: List = emptyList(), + maxPages: Int = MAX_CATALOG_PAGES, + includeSupplementalPublicVariants: Boolean = true, + ): CatalogBrowseResult { + val vpcId = getVpcId(token, providerStreamingBaseUrl) + val definitions = fetchFilterAndSortDefinitions(token) + val selectedSort = resolveCatalogSort(definitions.sortOptions, sortId) + val selectedFilters = filterIds.filter { definitions.filterPayloadById.containsKey(it) } + val filters = selectedFilters.mapNotNull { definitions.filterPayloadById[it]?.asObject() } + .fold(mutableMapOf()) { acc, obj -> + acc.putAll(obj) + acc + } + val page = fetchCatalogAppsPages( + token = token, + vpcId = vpcId, + searchQuery = searchQuery, + sortOrder = catalogSortOrder(selectedSort), + fetchCount = DEFAULT_CATALOG_FETCH_COUNT, + filters = JsonObject(filters), + maxPages = maxPages, + ) + val publicGames = if (includeSupplementalPublicVariants) fetchPublicGames() else emptyList() + val games = dedupeGames(page.apps.map(::appToGame)) + val withSearchFallbacks = if (searchQuery.isBlank() || publicGames.isEmpty()) { + games + } else { + dedupeGames(games + publicGames.filter { it.matchesSearch(searchQuery) }) + } + val merged = if (publicGames.isEmpty()) withSearchFallbacks else mergePublicGameVariants(withSearchFallbacks, publicGames) + val ordered = applyCatalogSortGuarantees(merged, selectedSort.id) + return CatalogBrowseResult( + games = ordered, + numberReturned = page.numberReturned, + numberSupported = max(page.numberSupported, ordered.size), + totalCount = max(page.totalCount, ordered.size), + hasNextPage = page.hasNextPage, + endCursor = page.endCursor?.takeIf { it.isNotBlank() }, + searchQuery = searchQuery, + selectedSortId = selectedSort.id, + selectedFilterIds = selectedFilters, + filterGroups = definitions.filterGroups, + sortOptions = definitions.sortOptions, + ) + } + + private suspend fun fetchCatalogAppsPages( + token: String, + vpcId: String, + searchQuery: String, + sortOrder: String, + fetchCount: Int, + filters: JsonObject, + maxPages: Int, + ): CatalogAppsPage { + val collectedApps = mutableListOf() + var numberReturned = 0 + var numberSupported = 0 + var totalCount = 0 + var hasNextPage = false + var endCursor: String? = null + var cursor = "" + for (page in 0 until maxPages.coerceIn(1, MAX_CATALOG_REQUEST_PAGES)) { + val payload = postGraphQlWithAppStoreFallback( + query = { includeAppStore -> catalogQuery(searchQuery.isNotBlank(), includeAppStore) }, + variables = buildJsonObject { + put("vpcId", vpcId) + put("locale", requestLocale()) + put("sortString", sortOrder) + put("fetchCount", fetchCount) + put("cursor", cursor) + put("filters", filters) + if (searchQuery.isNotBlank()) put("searchString", searchQuery.trim()) + }, + token = token, + ) + val apps = payload.obj("data")?.obj("apps") + val items = apps?.arr("items")?.mapNotNull { it.asObject() }.orEmpty() + collectedApps += items + numberReturned += apps?.int("numberReturned") ?: items.size + numberSupported = apps?.int("numberSupported") ?: numberSupported + totalCount = apps?.obj("pageInfo")?.int("totalCount") ?: totalCount + hasNextPage = apps?.obj("pageInfo")?.boolean("hasNextPage") ?: false + endCursor = apps?.obj("pageInfo")?.string("endCursor") + if (!hasNextPage || endCursor.isNullOrBlank()) break + cursor = endCursor.orEmpty() + } + return CatalogAppsPage( + apps = collectedApps, + numberReturned = numberReturned, + numberSupported = numberSupported, + totalCount = totalCount, + hasNextPage = hasNextPage, + endCursor = endCursor, + ) + } + + suspend fun fetchPublicGames(): List = publicGamesMutex.withLock { + val now = SystemClock.elapsedRealtime() + publicGamesCache + ?.takeIf { it.expiresAtElapsedMs > now } + ?.value + ?.let { return@withLock it } + + requestPublicGames().also { games -> + // The public list is static supplemental metadata. Cache successful responses so Store, + // Library, search, and sort refreshes do not download and parse the same large JSON. + if (games.isNotEmpty()) { + publicGamesCache = CachedPublicGames(games, now + PUBLIC_GAMES_CACHE_TTL_MS) + } + } + } + + private suspend fun requestPublicGames(): List { + val request = Request.Builder() + .url("https://static.nvidiagrid.net/supported-public-game-list/locales/gfnpc-en-US.json") + .header("User-Agent", GFN_USER_AGENT) + .build() + val (code, text) = http.awaitText(request) + if (code !in 200..299) return emptyList() + return dedupeGames(OpenNowJson.parseToJsonElement(text).jsonArray + .mapNotNull { item -> + val obj = item.asObject() ?: return@mapNotNull null + if (obj.string("status") != "AVAILABLE") return@mapNotNull null + val title = obj.string("title") ?: return@mapNotNull null + val id = obj["id"]?.jsonPrimitive?.contentOrNull ?: title + val steamAppId = obj.string("steamUrl")?.substringAfter("/app/", "")?.substringBefore("/") + val store = obj.string("store") ?: if (obj.string("publisher")?.contains("ncsoft", true) == true) "NCSoft" else "Unknown" + val posterUrl = steamAppId?.takeIf { it.isNotBlank() }?.let { "https://cdn.cloudflare.steamstatic.com/steam/apps/$it/library_600x900.jpg" } + GameInfo( + id = id, + uuid = id, + launchAppId = id.takeIf { it.all(Char::isDigit) }, + title = title, + imageUrl = posterUrl, + tvCardImageUrl = posterUrl, + screenshotUrl = steamAppId?.takeIf { it.isNotBlank() }?.let { "https://cdn.cloudflare.steamstatic.com/steam/apps/$it/library_hero.jpg" }, + tvBannerUrl = steamAppId?.takeIf { it.isNotBlank() }?.let { "https://cdn.cloudflare.steamstatic.com/steam/apps/$it/library_hero.jpg" }, + searchText = listOf(title, store, obj.string("publisher")).filterNotNull().joinToString(" ").lowercase(), + selectedVariantIndex = 0, + variants = listOf(GameVariant(id = id, store = store)), + availableStores = displayStoresForVariants(listOf(GameVariant(id = id, store = store))), + ) + }) + } + + suspend fun hydrateGameForLaunch( + token: String, + providerStreamingBaseUrl: String, + game: GameInfo, + selectedVariant: GameVariant?, + ): GameInfo { + val vpcId = getVpcId(token, providerStreamingBaseUrl) + val appId = game.uuid ?: game.id + val hydrated = fetchAppMetaData(token, listOf(appId), vpcId) + .firstOrNull { it.string("id") == appId } + ?.let(::appToGame) + ?: error("Launch metadata did not include ${game.title}") + val merged = mergeGameInfo(game, hydrated) + val requestedVariantId = selectedVariant?.id + val selectedVariantIndex = requestedVariantId + ?.let { id -> merged.variants.indexOfFirst { it.id == id } } + ?.takeIf { it >= 0 } + ?: merged.selectedVariantIndex + return merged.copy(selectedVariantIndex = selectedVariantIndex) + } + + suspend fun addOwnedVariant(token: String, variantId: String): String { + val query = """ + mutation AddOwnedVariant(${'$'}cmsId: String!, ${'$'}locale: String!) { + addOwnedVariant(language: ${'$'}locale, variantId: ${'$'}cmsId) { app { id } } + } + """.trimIndent() + val payload = postGraphQl( + query = query, + variables = buildJsonObject { + put("cmsId", variantId) + put("locale", requestLocale()) + }, + token = token, + endpoint = GFN_APPS_GRAPHQL_URL, + ).checkGraphQlErrors("Mark game as owned") + val confirmedVariantId = payload.obj("data")?.obj("addOwnedVariant")?.obj("app")?.string("id") + ?: error("GFN did not confirm that the game was marked as owned") + check(confirmedVariantId == variantId) { + "GFN confirmed a different owned variant ($confirmedVariantId instead of $variantId)" + } + return confirmedVariantId + } + + suspend fun resolveLaunchAppId(token: String, appIdOrUuid: String, providerStreamingBaseUrl: String): String? { + if (appIdOrUuid.all(Char::isDigit)) return appIdOrUuid + val vpcId = getVpcId(token, providerStreamingBaseUrl) + val meta = fetchAppMetaData(token, listOf(appIdOrUuid), vpcId) + return meta.firstOrNull()?.let(::resolveNumericAppId) + } + + suspend fun getVpcId(token: String, providerStreamingBaseUrl: String): String { + val base = normalizeStreamingServiceUrl(providerStreamingBaseUrl) ?: DEFAULT_STREAMING_SERVICE_URL + val cacheKey = base.lowercase(Locale.US) + return vpcIdMutex.withLock { + val now = SystemClock.elapsedRealtime() + vpcIdCache[cacheKey] + ?.takeIf { it.expiresAtElapsedMs > now } + ?.value + ?.let { return@withLock it } + + val resolved = runCatching { + val request = Request.Builder() + .url("${base}v2/serverInfo") + .headers( + Headers.Builder() + .putDesktopLcars(token, includeUserAgent = true, includeEmptyTokenAuthorization = true) + .build(), + ) + .build() + val (code, text) = http.awaitText(request) + if (code !in 200..299) { + "GFN-PC" + } else { + OpenNowJson.parseToJsonElement(text).jsonObject.obj("requestStatus")?.string("serverId") ?: "GFN-PC" + } + }.getOrDefault("GFN-PC") + // Catalog, library, and subscription refreshes start together. Share their + // server identity instead of issuing the same request several times. Do not + // cache the fallback so a transient network failure can recover immediately. + if (resolved != "GFN-PC") { + vpcIdCache[cacheKey] = CachedVpcId(resolved, now + VPC_ID_CACHE_TTL_MS) + } + resolved + } + } + + private companion object { + const val VPC_ID_CACHE_TTL_MS = 5 * 60 * 1_000L + const val CATALOG_DEFINITIONS_CACHE_TTL_MS = 30 * 60 * 1_000L + const val PUBLIC_GAMES_CACHE_TTL_MS = 6 * 60 * 60 * 1_000L + } + + private suspend fun fetchPanels(token: String, panelNames: List, vpcId: String, withLibraryTime: Boolean): JsonObject { + val variables = buildJsonObject { + put("vpcId", vpcId) + put("locale", requestLocale()) + putJsonArray("panelNames") { panelNames.forEach { add(JsonPrimitive(it)) } } + }.toString() + val extensions = buildJsonObject { + putJsonObject("persistedQuery") { + put("sha256Hash", if (withLibraryTime) LIBRARY_WITH_TIME_QUERY_HASH else PANELS_QUERY_HASH) + } + }.toString() + val requestType = if (panelNames.contains("LIBRARY")) "panels/Library" else "panels/MainV2" + val url = "$GAMES_GRAPHQL_URL?requestType=${encoded(requestType)}&extensions=${encoded(extensions)}&huId=${randomHuId()}&variables=${encoded(variables)}" + val request = Request.Builder() + .url(url) + .headers(desktopGraphQlHeaders(token).newBuilder().set("Content-Type", "application/graphql").build()) + .get() + .build() + val (code, text) = http.awaitText(request) + check(code in 200..299) { "Games GraphQL failed ($code): ${text.take(400)}" } + return OpenNowJson.parseToJsonElement(text).jsonObject + } + + private suspend fun fetchAppMetaData(token: String, appIds: List, vpcId: String): List { + if (appIds.isEmpty()) return emptyList() + val variables = buildJsonObject { + put("vpcId", vpcId) + put("locale", requestLocale()) + putJsonArray("appIds") { appIds.distinct().forEach { add(JsonPrimitive(it)) } } + }.toString() + val extensions = buildJsonObject { + putJsonObject("persistedQuery") { put("sha256Hash", GFN_APP_METADATA_QUERY_HASH) } + }.toString() + val url = "$GFN_APPS_GRAPHQL_URL?requestType=appMetaData&extensions=${encoded(extensions)}&huId=${randomHuId()}&variables=${encoded(variables)}" + val request = Request.Builder() + .url(url) + .headers(desktopGraphQlHeaders(token).newBuilder().set("Content-Type", "application/graphql").build()) + .build() + val (code, text) = http.awaitText(request) + if (code !in 200..299) return emptyList() + return OpenNowJson.parseToJsonElement(text).jsonObject.checkGraphQlErrors("App metadata") + .obj("data")?.obj("apps")?.arr("items")?.mapNotNull { it.asObject() }.orEmpty() + } + + private suspend fun enrichGamesWithMetadata(token: String, vpcId: String, games: List): List { + val ids = games.mapNotNull { it.uuid }.distinct() + if (ids.isEmpty()) return games + val apps = ids.chunked(40).flatMap { fetchAppMetaData(token, it, vpcId) } + val byId = apps.associateBy { it.string("id").orEmpty() } + return dedupeGames(games.map { game -> + val app = byId[game.uuid] ?: return@map game + mergePanelGameWithMetadata(game, appToGame(app)) + }) + } + + private fun flattenPanels(payload: JsonObject): List { + val games = payload.checkGraphQlErrors("Games GraphQL").obj("data")?.arr("panels")?.flatMap { panel -> + panel.asObject()?.arr("sections")?.flatMap { section -> + section.asObject()?.arr("items")?.mapNotNull { item -> + val obj = item.asObject() + val app = obj?.obj("app") + if (obj?.string("__typename") == "GameItem" && app != null) { + appToGame(app).copy( + catalogSectionId = section.asObject()?.string("id"), + catalogSectionTitle = section.asObject()?.string("title"), + ) + } else null + }.orEmpty() + }.orEmpty() + }.orEmpty() + return dedupeGames(games) + } + + private fun appToGame(app: JsonObject): GameInfo { + val appIsFreeToPlay = hasFreeToPlayPaymentModel(app.obj("computedValues")?.arr("paymentModels")) + val variants = app.arr("variants")?.mapNotNull { raw -> + val obj = raw.asObject() ?: return@mapNotNull null + val library = obj.obj("gfn")?.obj("library") + val variantPaymentModels = obj.arr("paymentModels") + GameVariant( + id = obj.string("id") ?: return@mapNotNull null, + store = gameStoreFromVariant(obj), + storeUrl = obj.string("storeUrl"), + supportedControls = obj.arr("supportedControls")?.mapNotNull { it.asString() }.orEmpty(), + librarySelected = library?.boolean("selected"), + libraryStatus = library?.string("status"), + lastPlayedDate = library?.string("lastPlayedDate"), + gfnStatus = obj.obj("gfn")?.string("status"), + isFreeToPlay = variantPaymentModels?.let(::hasFreeToPlayPaymentModel) ?: appIsFreeToPlay, + ) + }.orEmpty() + val numericAppId = resolveNumericAppId(app) + val selectedVariantId = app.arr("variants") + ?.mapNotNull { it.asObject() } + ?.firstOrNull { it.obj("gfn")?.obj("library")?.boolean("selected") == true } + ?.string("id") + val selectedIndex = max(0, variants.indexOfFirst { it.id == (selectedVariantId ?: numericAppId) }) + val images = app.obj("images") + val cardArtwork = catalogCardArtwork( + keyArt = images?.string("KEY_ART"), + gameBoxArt = images?.string("GAME_BOX_ART"), + heroImage = images?.string("HERO_IMAGE"), + tvBanner = images?.string("TV_BANNER"), + ) + val screenshotUrl = listOf("HERO_IMAGE", "TV_BANNER", "KEY_ART", "GAME_BOX_ART") + .firstNotNullOfOrNull { images?.string(it) } + val screenshotUrls = catalogScreenshotUrls(images) + val tvBannerUrl = listOf("TV_BANNER", "HERO_IMAGE", "KEY_ART", "GAME_BOX_ART") + .firstNotNullOfOrNull { images?.string(it) } + val genres = extractLabels(app.arr("genres")) + val featureLabels = (extractLabels(app.arr("features")) + extractLabels(app.arr("gameFeatures")) + extractLabels(app.arr("appFeatures")) + genres).distinct() + val title = app.string("title") ?: app.string("id") ?: "Unknown Game" + val stores = displayStoresForVariants(variants) + return GameInfo( + id = app.string("id") ?: title, + uuid = app.string("id"), + launchAppId = numericAppId, + title = title, + description = catalogGameDescription(app), + longDescription = app.string("longDescription"), + featureLabels = featureLabels, + genres = genres, + imageUrl = cardArtwork.mobileImageUrl, + tvCardImageUrl = cardArtwork.tvImageUrl, + screenshotUrl = screenshotUrl, + screenshotUrls = screenshotUrls, + tvBannerUrl = tvBannerUrl, + playType = app.obj("gfn")?.string("playType"), + membershipTierLabel = app.obj("gfn")?.string("minimumMembershipTierLabel"), + publisherName = app.string("publisherName"), + contentRatings = extractLabels(app.arr("contentRatings")), + playabilityState = app.obj("gfn")?.string("playabilityState"), + availableStores = stores, + searchText = (listOf(title, app.string("publisherName")) + stores + genres + featureLabels).filterNotNull().joinToString(" ").lowercase(), + lastPlayed = variants.firstNotNullOfOrNull { it.lastPlayedDate }, + isInLibrary = variants.any(::isOwnedGameVariant), + selectedVariantIndex = min(selectedIndex, max(variants.size - 1, 0)), + variants = variants, + ) + } + + private fun resolveNumericAppId(app: JsonObject): String? { + val variants = app.arr("variants")?.mapNotNull { it.asObject() }.orEmpty() + val selected = variants.firstOrNull { it.obj("gfn")?.obj("library")?.boolean("selected") == true }?.string("id") + return selected?.takeIf { it.all(Char::isDigit) } + ?: variants.firstNotNullOfOrNull { it.string("id")?.takeIf { value -> value.isNumeric() } } + ?: app.string("id")?.takeIf { value -> value.isNumeric() } + } + + private suspend fun fetchFilterAndSortDefinitions(token: String): CatalogDefinitions { + val locale = requestLocale() + return catalogDefinitionsMutex.withLock { + val now = SystemClock.elapsedRealtime() + catalogDefinitionsCache[locale] + ?.takeIf { it.expiresAtElapsedMs > now } + ?.value + ?.let { return@withLock it } + + requestFilterAndSortDefinitions(token, locale).also { definitions -> + catalogDefinitionsCache[locale] = CachedCatalogDefinitions( + definitions, + now + CATALOG_DEFINITIONS_CACHE_TTL_MS, + ) + } + } + } + + private suspend fun requestFilterAndSortDefinitions(token: String, locale: String): CatalogDefinitions { + val query = """ + query GetFilterGroupAndSortOrderDefinitions(${'$'}locale: String!) { + filterGroupDefinitions(language: ${'$'}locale) { id label filters { id label filters } } + sortOrderDefinitions(language: ${'$'}locale) { id label orderBy } + } + """.trimIndent() + val payload = postGraphQl(query, buildJsonObject { put("locale", locale) }, token).checkGraphQlErrors() + val data = payload.obj("data") + val filterPayloadById = mutableMapOf() + val groups = data?.arr("filterGroupDefinitions")?.mapNotNull groupMap@ { raw -> + val group = raw.asObject() ?: return@groupMap null + val options = group.arr("filters")?.mapNotNull filterMap@ { filterRaw -> + val filter = filterRaw.asObject() ?: return@filterMap null + val filterJson = filter.arr("filters")?.firstOrNull()?.asString() ?: return@filterMap null + val parsed = runCatching { OpenNowJson.parseToJsonElement(filterJson) }.getOrNull() ?: return@filterMap null + val id = filter.string("id") ?: return@filterMap null + filterPayloadById[id] = parsed + CatalogFilterOption( + id = id, + rawId = id, + label = filter.string("label") ?: id, + groupId = group.string("id") ?: "", + groupLabel = group.string("label") ?: "", + ) + }.orEmpty() + if (options.isEmpty()) null else CatalogFilterGroup( + id = group.string("id") ?: "", + label = group.string("label") ?: "", + options = options, + ) + }.orEmpty() + val sorts = data?.arr("sortOrderDefinitions")?.mapNotNull { + val obj = it.asObject() ?: return@mapNotNull null + CatalogSortOption(obj.string("id") ?: return@mapNotNull null, obj.string("label") ?: "", obj.string("orderBy") ?: "") + }.orEmpty() + return CatalogDefinitions(groups, sorts, filterPayloadById) + } + + private suspend fun postGraphQl( + query: String, + variables: JsonObject, + token: String, + endpoint: String = GAMES_GRAPHQL_URL, + ): JsonObject { + val body = buildJsonObject { + put("query", query) + put("variables", variables) + }.toString().toRequestBody(JSON_MEDIA_TYPE) + val request = Request.Builder() + .url(endpoint) + .headers(desktopGraphQlHeaders(token)) + .post(body) + .build() + val (code, text) = http.awaitText(request) + check(code in 200..299) { "GFN GraphQL failed ($code): ${text.take(400)}" } + return OpenNowJson.parseToJsonElement(text).jsonObject + } + + private suspend fun postGraphQlWithAppStoreFallback( + query: (includeAppStore: Boolean) -> String, + variables: JsonObject, + token: String, + errorLabel: String = "GFN GraphQL", + ): JsonObject { + try { + return postGraphQl(query(true), variables, token).checkGraphQlErrors(errorLabel) + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + if (!isAppStoreEnumSerializationError(error)) throw error + } + return postGraphQl(query(false), variables, token).checkGraphQlErrors(errorLabel) + } + + private fun catalogQuery(hasSearch: Boolean, includeAppStore: Boolean): String { + val appFields = """ + numberReturned + numberSupported + pageInfo { hasNextPage endCursor totalCount } + items { + id + title + shortDescription + longDescription + publisherName + images { KEY_ART GAME_BOX_ART TV_BANNER HERO_IMAGE SCREENSHOTS } + computedValues { paymentModels { __typename } } + variants { ${gfnVariantMetadataFields(includeAppStore)} } + gfn { playType playabilityState minimumMembershipTierLabel catalogSkuStrings { SKU_BASED_TAG } } + itemMetadata { campaignIds } + } + """.trimIndent() + return if (hasSearch) { + """ + query GetSearchFilterResults(${'$'}vpcId: String!, ${'$'}locale: String!, ${'$'}sortString: String!, ${'$'}fetchCount: Int!, ${'$'}cursor: String!, ${'$'}searchString: String!, ${'$'}filters: AppFilterFields!) { + apps(vpcId: ${'$'}vpcId, language: ${'$'}locale, orderBy: ${'$'}sortString, first: ${'$'}fetchCount, after: ${'$'}cursor, searchQuery: ${'$'}searchString, filters: ${'$'}filters) { + $appFields + } + } + """.trimIndent() + } else { + """ + query GetFilterBrowseResults(${'$'}vpcId: String!, ${'$'}locale: String!, ${'$'}sortString: String!, ${'$'}fetchCount: Int!, ${'$'}cursor: String!, ${'$'}filters: AppFilterFields!) { + apps(vpcId: ${'$'}vpcId, language: ${'$'}locale, orderBy: ${'$'}sortString, first: ${'$'}fetchCount, after: ${'$'}cursor, filters: ${'$'}filters) { + $appFields + } + } + """.trimIndent() + } + } + + private fun dedupeGames(games: List): List = + games.groupBy { game -> game.title.normalizedTitleKey().ifBlank { game.id } }.map { (_, bucket) -> + bucket.reduce(::mergeGameInfo) + } + + private fun mergePublicGameVariants(games: List, publicGames: List): List { + return mergeSupplementalPublicGameVariants(games, publicGames) + } + + private fun GameInfo.matchesSearch(query: String): Boolean { + val normalized = query.trim().lowercase() + if (normalized.isBlank()) return true + return (listOf(title, searchText) + availableStores + variants.map { it.store }) + .filterNotNull() + .any { it.lowercase().contains(normalized) } + } + + private fun extractLabels(array: JsonArray?): List = array?.mapNotNull { item -> + when (item) { + is JsonPrimitive -> item.contentOrNull + is JsonObject -> listOf("name", "label", "title", "displayName").firstNotNullOfOrNull { item.string(it) } + else -> null + }?.trim()?.takeIf { it.isNotBlank() } + }?.distinct().orEmpty() + + private fun randomHuId(): String = System.currentTimeMillis().toString(16) + UUID.randomUUID().toString().replace("-", "").take(8) + + private data class CatalogDefinitions( + val filterGroups: List, + val sortOptions: List, + val filterPayloadById: Map, + ) + + private data class CatalogAppsPage( + val apps: List, + val numberReturned: Int, + val numberSupported: Int, + val totalCount: Int, + val hasNextPage: Boolean, + val endCursor: String?, + ) +} + +class GfnSubscriptionRepository( + private val http: OkHttpClient = defaultHttpClient(), +) { + suspend fun fetchSubscription(token: String, userId: String, vpcId: String = "NP-AMS-08"): SubscriptionInfo { + val url = "$MES_URL?serviceName=gfn_pc&languageCode=en_US&vpcId=${encoded(vpcId)}&userId=${encoded(userId)}" + val request = Request.Builder() + .url(url) + .headers(Headers.Builder().putDesktopLcars(token).build()) + .build() + val (code, text) = http.awaitText(request) + if (code !in 200..299) return SubscriptionInfo() + val data = OpenNowJson.parseToJsonElement(text).jsonObject + val allotted = data.double("allottedTimeInMinutes") ?: 0.0 + val purchased = data.double("purchasedTimeInMinutes") ?: 0.0 + val rolled = data.double("rolledOverTimeInMinutes") ?: 0.0 + val total = data.double("totalTimeInMinutes") ?: (allotted + purchased + rolled) + val remaining = data.double("remainingTimeInMinutes") ?: 0.0 + val resolutions = data.obj("features")?.arr("resolutions")?.mapNotNull { raw -> + val obj = raw.asObject() ?: return@mapNotNull null + EntitledResolution( + width = obj.int("widthInPixels") ?: return@mapNotNull null, + height = obj.int("heightInPixels") ?: return@mapNotNull null, + fps = obj.int("framesPerSecond") ?: return@mapNotNull null, + ) + }?.sortedWith(compareByDescending { it.width }.thenByDescending { it.height }.thenByDescending { it.fps }).orEmpty() + val subscription = data.obj("subscription") ?: data + val storageAddon = subscription.arr("addons") + ?.mapNotNull { it.asObject() } + ?.firstOrNull(::isActivePersistentStorageAddon) + ?.let(::parseStorageAddon) + return SubscriptionInfo( + membershipTier = data.string("membershipTier") ?: "FREE", + subscriptionType = data.string("type"), + subscriptionSubType = data.string("subType"), + allottedHours = allotted / 60.0, + purchasedHours = purchased / 60.0, + rolledOverHours = rolled / 60.0, + usedHours = max(total - remaining, 0.0) / 60.0, + remainingHours = remaining / 60.0, + totalHours = total / 60.0, + state = data.obj("currentSubscriptionState")?.string("state"), + isGamePlayAllowed = data.obj("currentSubscriptionState")?.boolean("isGamePlayAllowed"), + isUnlimited = data.string("subType") == "UNLIMITED", + storageAddon = storageAddon, + entitledResolutions = resolutions, + ) + } + + private fun parseStorageAddon(addon: JsonObject): StorageAddon { + val attributes = addon.arr("attributes") + ?.mapNotNull { it.asObject() } + ?.associate { attribute -> + attribute.string("key").orEmpty() to attribute.string("textValue").orEmpty() + } + .orEmpty() + val total = attributes[TOTAL_STORAGE_SIZE_IN_GB]?.toDoubleOrNull() + val used = attributes[USED_STORAGE_SIZE_IN_GB]?.toDoubleOrNull() + return StorageAddon( + type = addon.string("type") ?: STORAGE_ADDON_TYPE, + sizeGb = total, + usedGb = used, + regionName = attributes[STORAGE_METRO_REGION_NAME], + regionCode = attributes[STORAGE_METRO_REGION], + status = addon.string("status"), + subType = addon.string("subType"), + autoPayEnabled = addon.boolean("autoPayEnabled"), + ) + } + + private fun isActivePersistentStorageAddon(addon: JsonObject): Boolean = + addon.string("type") == STORAGE_ADDON_TYPE && + addon.string("subType") == "PERMANENT_STORAGE" && + addon.string("status") == "OK" +} + +class GfnAccountConnectorRepository( + private val http: OkHttpClient = defaultHttpClient(), +) { + suspend fun fetchConnectors(token: String): List { + val query = """ + query GetAccountConnectors(${'$'}locale: String!, ${'$'}stringsKey: [String]!) { + appStoreDefinitions(language: ${'$'}locale) { + store + label + sortOrder + features { + __typename + ... on AccountLinkingSso { + supported + } + ... on AccountGamesSyncing { + supported + } + } + accountLinkingMetadata { + isSupported + isRequired + label + } + } + userAccount { + storesData { + store + accountLinkingData { + userDisplayName + expiresIn + userIdentifier + accountSyncingData { + totalNumberOfSyncedGfnGames + syncState + syncDate + } + } + } + } + clientStrings(language: ${'$'}locale, keys: ${'$'}stringsKey) + } + """.trimIndent() + val payload = postGraphQl( + query, + buildJsonObject { + put("locale", DEFAULT_LOCALE) + putJsonArray("stringsKey") {} + }, + token, + ).checkGraphQlErrors("Account connectors") + val data = payload.obj("data") ?: return emptyList() + val userStores = data.obj("userAccount")?.arr("storesData") + ?.mapNotNull { it.asObject() } + ?.associateBy { normalizeGameStore(it.string("store").orEmpty()) } + .orEmpty() + val connectors = data.arr("appStoreDefinitions") + ?.mapNotNull { raw -> + val store = raw.asObject() ?: return@mapNotNull null + val storeId = store.string("store")?.takeIf { it.isNotBlank() } ?: return@mapNotNull null + val metadata = store.obj("accountLinkingMetadata") + val featureSupported = store.arr("features") + ?.mapNotNull { it.asObject() } + ?.any { feature -> + feature.boolean("supported") == true && + feature.string("__typename") in setOf("AccountLinkingSso", "AccountGamesSyncing") + } == true + val supported = metadata?.boolean("isSupported") == true || featureSupported + val normalizedStoreId = normalizeGameStore(storeId) + if (!supported && normalizedStoreId !in userStores) return@mapNotNull null + val linked = userStores[normalizedStoreId]?.obj("accountLinkingData") + val sync = linked?.obj("accountSyncingData") + AccountConnector( + store = storeId, + label = metadata?.string("label") ?: store.string("label") ?: gameStoreDisplayName(storeId), + supported = supported, + required = metadata?.boolean("isRequired") ?: false, + userDisplayName = linked?.string("userDisplayName"), + userIdentifier = linked?.string("userIdentifier"), + expiresInSeconds = linked?.long("expiresIn"), + syncedGameCount = sync?.int("totalNumberOfSyncedGfnGames"), + syncState = sync?.string("syncState"), + syncDate = sync?.string("syncDate"), + ) + } + .orEmpty() + .ensureSteamConnector(userStores) + return connectors.sortedWith( + compareByDescending { it.isLinked } + .thenBy { accountConnectorSortRank(it.store) } + .thenBy { it.label.lowercase(Locale.US) }, + ) + } + + suspend fun loginUrl(store: String, accessToken: String): String { + val platform = accountLinkingPlatform(store) + val url = "$ACCOUNT_LINKING_BASE_URL/login_url" + .toHttpUrl() + .newBuilder() + .addQueryParameter("platform", platform) + .addQueryParameter("redirect_uri", ACCOUNT_LINKING_REDIRECT_URL) + .addQueryParameter("client_id", ACCOUNT_LINKING_CLIENT_ID) + .build() + val request = Request.Builder() + .url(url) + .headers(accountLinkingHeaders(accessToken)) + .build() + val (code, text) = http.awaitText(request) + check(code in 200..299) { "Store connection failed ($code): ${text.take(240)}" } + return OpenNowJson.parseToJsonElement(text).jsonObject.string("login_url") + ?: error("Store connection did not return a login URL") + } + + suspend fun disconnect(store: String, accessToken: String) { + val platform = accountLinkingPlatform(store) + val request = Request.Builder() + .url("$ACCOUNT_LINKING_BASE_URL/linking/${encoded(platform)}") + .headers(accountLinkingHeaders(accessToken)) + .delete() + .build() + val (code, text) = http.awaitText(request) + check(code in 200..299) { "Store disconnect failed ($code): ${text.take(240)}" } + } + + private suspend fun postGraphQl(query: String, variables: JsonObject, token: String): JsonObject { + val body = buildJsonObject { + put("query", query) + put("variables", variables) + }.toString().toRequestBody(JSON_MEDIA_TYPE) + val request = Request.Builder() + .url(GAMES_GRAPHQL_URL) + .headers(desktopGraphQlHeaders(token)) + .post(body) + .build() + val (code, text) = http.awaitText(request) + check(code in 200..299) { "Account connectors failed ($code): ${text.take(400)}" } + return OpenNowJson.parseToJsonElement(text).jsonObject + } + + private fun accountLinkingHeaders(accessToken: String): Headers = + Headers.Builder() + .add("Accept", "application/json, text/plain, */*") + .add("Authorization", bearerAuthorization(accessToken)) + .add("Origin", GFN_PLAY_ORIGIN) + .add("Referer", GFN_PLAY_REFERER) + .add("User-Agent", GFN_USER_AGENT) + .build() + + private fun List.ensureSteamConnector(userStores: Map): List { + if (any { normalizeGameStore(it.store) == "STEAM" }) return this + val linked = userStores["STEAM"]?.obj("accountLinkingData") + val sync = linked?.obj("accountSyncingData") + return this + AccountConnector( + store = "STEAM", + label = "Steam", + supported = true, + required = false, + userDisplayName = linked?.string("userDisplayName"), + userIdentifier = linked?.string("userIdentifier"), + expiresInSeconds = linked?.long("expiresIn"), + syncedGameCount = sync?.int("totalNumberOfSyncedGfnGames"), + syncState = sync?.string("syncState"), + syncDate = sync?.string("syncDate"), + ) + } + + private fun accountLinkingPlatform(store: String): String = + when (val normalized = normalizeGameStore(store).ifBlank { store }.uppercase(Locale.US)) { + "UBISOFT", "UBISOFT_CONNECT" -> "UPLAY" + "BATTLE_NET", "BLIZZARD" -> "BATTLENET" + "EPIC_GAMES", "EPIC_GAMES_STORE" -> "EPIC" + else -> normalized + } + + private fun accountConnectorSortRank(store: String): Int = + when (normalizeGameStore(store)) { + "STEAM" -> 0 + "EPIC", "EGS", "EPIC_GAMES_STORE" -> 1 + "XBOX", "XBOX_GAME_PASS", "GAME_PASS" -> 2 + "UBISOFT", "UBISOFT_CONNECT" -> 3 + else -> 10 + } +} + +class PrintedWasteRepository( + private val http: OkHttpClient = defaultHttpClient(), +) { + suspend fun fetchQueue(): Map { + val request = Request.Builder() + .url(PRINTEDWASTE_QUEUE_URL) + .header("User-Agent", "opennow-android") + .header("Accept", "application/json") + .build() + val (code, text) = http.awaitText(request) + check(code in 200..299) { "PrintedWaste queue returned HTTP $code" } + val payload = OpenNowJson.parseToJsonElement(text).jsonObject + check(payload.boolean("status") == true) { "PrintedWaste queue returned status:false" } + val data = payload.obj("data") ?: error("PrintedWaste queue missing data") + return data.mapNotNull { (zoneId, raw) -> + val zone = raw.asObject() ?: return@mapNotNull null + val queue = zone.int("QueuePosition") ?: return@mapNotNull null + val region = zone.string("Region")?.takeIf { it.isNotBlank() } ?: return@mapNotNull null + zoneId to PrintedWasteZone( + QueuePosition = queue, + LastUpdated = zone.long("Last Updated") ?: 0L, + Region = region, + eta = zone.long("eta"), + ) + }.toMap().also { + check(it.isNotEmpty()) { "PrintedWaste queue returned no usable zones" } + } + } + + suspend fun fetchServerMapping(): Map { + val request = Request.Builder() + .url(PRINTEDWASTE_SERVER_MAPPING_URL) + .header("User-Agent", "opennow-android") + .header("Accept", "application/json") + .build() + val (code, text) = http.awaitText(request) + check(code in 200..299) { "PrintedWaste mapping returned HTTP $code" } + val payload = OpenNowJson.parseToJsonElement(text).jsonObject + check(payload.boolean("status") == true) { "PrintedWaste mapping returned status:false" } + val data = payload.obj("data") ?: error("PrintedWaste mapping missing data") + return data.mapNotNull { (zoneId, raw) -> + val zone = raw.asObject() ?: return@mapNotNull null + zoneId to PrintedWasteServerMappingEntry( + title = zone.string("title"), + region = zone.string("region"), + is4080Server = zone.boolean("is4080Server"), + is5080Server = zone.boolean("is5080Server"), + nuked = zone.boolean("nuked"), + ) + }.toMap() + } + + suspend fun pingRegions(regions: List): List = coroutineScope { + regions.map { region -> + async(Dispatchers.IO) { + val url = region.url.toHttpUrlOrNull() + ?: return@async PingResult(region.url, error = "Invalid URL") + val hostname = url.host + val port = if (url.isHttps) 443 else 80 + val validPings = mutableListOf() + + // The selector waits for the slowest region, so multi-second probes make + // one unreachable edge look like a frozen queue screen. A short warm-up + // plus two samples is enough to rank playable streaming regions while + // bounding the entire parallel pass to roughly two seconds. + tcpPing(hostname, port, timeoutMs = 750) + repeat(2) { index -> + if (index > 0) delay(50) + tcpPing(hostname, port, timeoutMs = 750)?.let(validPings::add) + } + + if (validPings.isEmpty()) { + PingResult(region.url, error = "All ping tests failed") + } else { + PingResult(region.url, pingMs = validPings.average().toLong()) + } + } + }.map { it.await() } + } + + private fun tcpPing(hostname: String, port: Int, timeoutMs: Int): Long? = + runCatching { + Socket().use { socket -> + val start = System.nanoTime() + socket.connect(InetSocketAddress(hostname, port), timeoutMs) + TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start) + } + }.getOrNull() +} + +data class GfnSessionDiagnosticResponse( + val operation: String, + val method: String, + val url: String, + val statusCode: Int, + val requestBody: String, + val responseBody: String, +) + +class GfnSessionRepository( + private val authStore: AuthStore, + private val http: OkHttpClient = defaultHttpClient(), + private val physicalDisplayResolutionProvider: () -> Pair? = { null }, + private val diagnosticsSink: (GfnSessionDiagnosticResponse) -> Unit = {}, + private val isAndroidTv: Boolean = false, + private val useDesktopNativeTvIdentity: Boolean = usesDesktopNativeTvCloudMatchIdentity( + androidTvProfile = isAndroidTv, + manufacturer = Build.MANUFACTURER, + model = Build.MODEL, + ), +) { + suspend fun createSession( + token: String, + streamingBaseUrl: String?, + appId: String, + internalTitle: String, + zone: String, + settings: StreamSettings, + accountLinked: Boolean = true, + // Decided here and never again: the host provisions its virtual input devices from this, + // so a session created without it cannot be given a touchscreen later. + appLaunchMode: Int = GfnAppLaunchMode.GAMEPAD_FRIENDLY, + ): SessionInfo { + require(appId.all(Char::isDigit)) { "Invalid launch appId '$appId'." } + val clientId = UUID.randomUUID().toString() + val deviceId = authStore.stableDeviceId() + val base = resolveLaunchSessionBaseUrl(token, resolveStreamingBaseUrl(zone, streamingBaseUrl)) + val body = buildSessionRequestBody( + appId = appId, + internalTitle = internalTitle, + settings = settings, + accountLinked = accountLinked, + deviceId = deviceId, + physicalDisplayResolution = physicalDisplayResolutionProvider(), + streamingBaseUrl = base, + appLaunchMode = appLaunchMode, + ) + val url = cloudMatchSessionRequestUrl(base, settings) + val host = Uri.parse(base).host.orEmpty() + val requestHttp = if (isZoneHostname(host)) sessionProxyHttpClient(settings, http) else http + val request = Request.Builder() + .url(url) + .headers( + cloudMatchHeaders( + token = token, + clientId = clientId, + deviceId = deviceId, + includeOrigin = true, + streamingBaseUrl = base, + appLaunchMode = appLaunchMode, + preferNativeDesktopMode = if (appLaunchMode == GfnAppLaunchMode.TOUCH_FRIENDLY) false else settings.requiresNativeDesktopCloudMatchMode(), + isAndroidTv = isAndroidTv, + useDesktopNativeTvIdentity = useDesktopNativeTvIdentity, + ), + ) + .post(body.toString().toRequestBody(JSON_MEDIA_TYPE)) + .build() + val (code, text) = requestHttp.awaitText(request) + recordDiagnosticResponse("session.create", request, code, text) + val payload = OpenNowJson.parseToJsonElement(text).jsonObject + return toSessionInfo(zone, base, payload, clientId, deviceId) + } + + suspend fun pollSession( + token: String, + streamingBaseUrl: String?, + serverIp: String?, + zone: String, + sessionId: String, + clientId: String?, + deviceId: String?, + settings: StreamSettings, + diagnosticOperation: String = "session.poll", + ): SessionInfo { + val cid = clientId ?: UUID.randomUUID().toString() + val did = deviceId ?: authStore.stableDeviceId() + val base = resolvePollStopBase(zone, streamingBaseUrl, serverIp) + val host = Uri.parse(base).host.orEmpty() + val requestHttp = if (isZoneHostname(host)) sessionProxyHttpClient(settings, http) else http + val request = Request.Builder() + .url("$base/v2/session/$sessionId") + .headers(cloudMatchHeaders(token, cid, did, includeOrigin = false, streamingBaseUrl = base, isAndroidTv = isAndroidTv)) + .build() + val (code, text) = requestHttp.awaitText(request) + recordDiagnosticResponse(diagnosticOperation, request, code, text) + val payload = OpenNowJson.parseToJsonElement(text).jsonObject + val realServer = streamingServerIp(payload) + if (isZoneHostname(host) && realServer != null && !isZoneHostname(realServer) && READY_SESSION_STATUSES.contains(payload.obj("session")?.int("status"))) { + val directBase = "https://$realServer" + val directRequest = Request.Builder() + .url("$directBase/v2/session/$sessionId") + .headers(cloudMatchHeaders(token, cid, did, includeOrigin = false, streamingBaseUrl = directBase, isAndroidTv = isAndroidTv)) + .build() + val (code, directText) = http.awaitText(directRequest) + recordDiagnosticResponse("$diagnosticOperation.direct", directRequest, code, directText) + if (code in 200..299) { + val directPayload = OpenNowJson.parseToJsonElement(directText).jsonObject + if (directPayload.obj("requestStatus")?.int("statusCode") == 1) { + return toSessionInfo(zone, directBase, directPayload, cid, did) + } + } + } + return toSessionInfo(zone, base, payload, cid, did) + } + + suspend fun stopSession(token: String, input: SessionInfo, settings: StreamSettings) { + val base = resolvePollStopBase(input.zone, input.streamingBaseUrl, input.serverIp) + val host = Uri.parse(base).host.orEmpty() + val requestHttp = if (isZoneHostname(host)) sessionProxyHttpClient(settings, http) else http + val cid = input.clientId ?: UUID.randomUUID().toString() + val did = input.deviceId ?: authStore.stableDeviceId() + val request = Request.Builder() + .url("$base/v2/session/${input.sessionId}") + .headers(cloudMatchHeaders(token, cid, did, includeOrigin = false, streamingBaseUrl = base, isAndroidTv = isAndroidTv)) + .delete() + .build() + val (code, text) = requestHttp.awaitText(request) + recordDiagnosticResponse("session.stop", request, code, text) + } + + suspend fun getActiveSessions(token: String, streamingBaseUrl: String, settings: StreamSettings): List { + val base = streamingBaseUrl.trim().trimEnd('/') + val host = Uri.parse(base).host.orEmpty() + val requestHttp = if (isZoneHostname(host)) sessionProxyHttpClient(settings, http) else http + val request = Request.Builder() + .url("$base/v2/session") + .headers( + cloudMatchHeaders( + token, + UUID.randomUUID().toString(), + authStore.stableDeviceId(), + includeOrigin = false, + streamingBaseUrl = base, + isAndroidTv = isAndroidTv, + ), + ) + .build() + val (code, text) = requestHttp.awaitText(request) + recordDiagnosticResponse("session.active", request, code, text) + if (code !in 200..299) return emptyList() + val payload = runCatching { OpenNowJson.parseToJsonElement(text).jsonObject }.getOrNull() ?: return emptyList() + if (payload.obj("requestStatus")?.int("statusCode") != 1) return emptyList() + return payload.arr("sessions")?.mapNotNull { raw -> + val s = raw.asObject() ?: return@mapNotNull null + val status = s.int("status") ?: return@mapNotNull null + if (status !in setOf(1, 2, 3)) return@mapNotNull null + val connIp = streamingServerIpFromSession(s) + val controlIp = s.obj("sessionControlInfo")?.string("ip") + val monitor = activeSessionMonitorSettings(s) + ActiveSessionInfo( + sessionId = s.string("sessionId") ?: return@mapNotNull null, + appId = s.obj("sessionRequestData")?.string("appId")?.toIntOrNull() ?: 0, + gpuType = s.string("gpuType"), + status = status, + queuePosition = extractQueuePosition(s), + seatSetupStep = s.obj("seatSetupInfo")?.int("seatSetupStep"), + streamingBaseUrl = base, + serverIp = connIp ?: controlIp, + signalingUrl = s.arr("connectionInfo") + ?.mapNotNull { it.asObject() } + ?.firstOrNull { it.int("usage") == 14 } + ?.let { connection -> + val serverIp = connIp ?: controlIp + serverIp?.let { buildSignalingUrl(connection.string("resourcePath") ?: "/nvst/", it).first } + } + ?: (connIp ?: controlIp)?.let { "wss://$it:443/nvst/" }, + resolution = monitor?.let { "${it.int("widthInPixels") ?: 0}x${it.int("heightInPixels") ?: 0}" }, + fps = monitor?.int("framesPerSecond"), + settingsSignature = activeSessionSettingsSignature(s), + ) + }.orEmpty() + } + + suspend fun claimSession( + token: String, + active: ActiveSessionInfo, + settings: StreamSettings, + appLaunchMode: Int = GfnAppLaunchMode.GAMEPAD_FRIENDLY, + recoveryMode: Boolean = false, + ): SessionInfo { + val deviceId = authStore.stableDeviceId() + val clientId = UUID.randomUUID().toString() + val providerBase = normalizeStreamingServiceUrl(active.streamingBaseUrl.orEmpty())?.trimEnd('/') + val providerHost = providerBase?.let { Uri.parse(it).host.orEmpty() }.orEmpty() + val useProviderBaseForSessionOps = providerBase != null && !isZoneHostname(providerHost) + var effectiveServerIp = active.serverIp.orEmpty() + if (!useProviderBaseForSessionOps && effectiveServerIp.isBlank()) { + error("Missing server IP for session claim") + } + if (!useProviderBaseForSessionOps && isZoneHostname(effectiveServerIp)) { + val requestHttp = sessionProxyHttpClient(settings, http) + val prefetch = Request.Builder() + .url("https://$effectiveServerIp/v2/session/${active.sessionId}") + .headers(cloudMatchHeaders(token, clientId, deviceId, includeOrigin = false, streamingBaseUrl = active.streamingBaseUrl, isAndroidTv = isAndroidTv)) + .build() + val (code, text) = requestHttp.awaitText(prefetch) + recordDiagnosticResponse("session.claim.prefetch", prefetch, code, text) + if (code in 200..299) { + streamingServerIp(OpenNowJson.parseToJsonElement(text).jsonObject)?.let { effectiveServerIp = it } + } + } + val sessionBase = if (useProviderBaseForSessionOps) requireNotNull(providerBase) else "https://$effectiveServerIp" + val validationUrl = "$sessionBase/v2/session/${active.sessionId}" + val validationRequest = Request.Builder() + .url(validationUrl) + .headers(cloudMatchHeaders(token, clientId, deviceId, includeOrigin = false, streamingBaseUrl = active.streamingBaseUrl, isAndroidTv = isAndroidTv)) + .build() + val (validationCode, validationText) = http.awaitText(validationRequest) + recordDiagnosticResponse("session.claim.validation", validationRequest, validationCode, validationText) + val validation = runCatching { OpenNowJson.parseToJsonElement(validationText).jsonObject }.getOrNull() + val status = validation?.obj("session")?.int("status") + if (status != null && isTerminalSessionStatus(status)) { + val latestSession = runCatching { + toSessionInfo("", sessionBase, requireNotNull(validation), clientId, deviceId) + }.getOrNull() + throw TerminalSessionStatusException(status, latestSession) + } + // A recovery GET can already return a stream-ready session. Repeating RESUME in that case + // can rotate signaling hosts and move a healthy session back through transient setup. + if (shouldResumeClaimedSession(status, recoveryMode)) { + val claimBody = buildClaimRequestBody( + appId = active.appId.toString(), + deviceId = deviceId, + settings = settings, + physicalDisplayResolution = physicalDisplayResolutionProvider(), + streamingBaseUrl = active.streamingBaseUrl, + appLaunchMode = appLaunchMode, + ) + val claimRequest = Request.Builder() + .url(cloudMatchSessionRequestUrl(sessionBase, settings, active.sessionId)) + .headers( + cloudMatchHeaders( + token = token, + clientId = clientId, + deviceId = deviceId, + includeOrigin = true, + streamingBaseUrl = active.streamingBaseUrl, + appLaunchMode = appLaunchMode, + preferNativeDesktopMode = if (appLaunchMode == GfnAppLaunchMode.TOUCH_FRIENDLY) false else settings.requiresNativeDesktopCloudMatchMode(), + isAndroidTv = isAndroidTv, + useDesktopNativeTvIdentity = useDesktopNativeTvIdentity, + ), + ) + .put(claimBody.toString().toRequestBody(JSON_MEDIA_TYPE)) + .build() + val (claimCode, claimText) = http.awaitText(claimRequest) + recordDiagnosticResponse("session.claim.put", claimRequest, claimCode, claimText) + } + var latestSession: SessionInfo? = null + repeat(60) { attempt -> + if (attempt > 0) delay(1000) + val poll = Request.Builder() + .url(validationUrl) + .headers(cloudMatchHeaders(token, clientId, deviceId, includeOrigin = false, streamingBaseUrl = active.streamingBaseUrl, isAndroidTv = isAndroidTv)) + .build() + val (code, text) = http.awaitText(poll) + recordDiagnosticResponse("session.claim.poll", poll, code, text) + if (code in 200..299) { + val payload = OpenNowJson.parseToJsonElement(text).jsonObject + val pollStatus = payload.obj("session")?.int("status") + val polledSession = toSessionInfo("", sessionBase, payload, clientId, deviceId) + latestSession = polledSession + if (pollStatus in READY_SESSION_STATUSES) return polledSession + if (pollStatus != null && isTerminalSessionStatus(pollStatus)) { + throw TerminalSessionStatusException(pollStatus, polledSession) + } + } + } + throw SessionClaimNotReadyException(latestSession) + } + + suspend fun stopActiveSession(token: String, active: ActiveSessionInfo, settings: StreamSettings) { + stopSession( + token = token, + input = SessionInfo( + sessionId = active.sessionId, + status = active.status, + queuePosition = active.queuePosition, + seatSetupStep = active.seatSetupStep, + streamingBaseUrl = active.streamingBaseUrl, + serverIp = active.serverIp.orEmpty(), + signalingServer = active.serverIp.orEmpty(), + signalingUrl = active.signalingUrl.orEmpty(), + gpuType = active.gpuType, + clientId = UUID.randomUUID().toString(), + deviceId = authStore.stableDeviceId(), + ), + settings = settings, + ) + } + + suspend fun reportSessionAd( + token: String, + session: SessionInfo, + adId: String, + action: String, + settings: StreamSettings, + watchedTimeInMs: Long? = null, + pausedTimeInMs: Long? = null, + cancelReason: String? = null, + errorInfo: String? = null, + ): SessionInfo { + val base = resolvePollStopBase(session.zone, session.streamingBaseUrl, session.serverIp) + val host = Uri.parse(base).host.orEmpty() + val requestHttp = if (isZoneHostname(host)) sessionProxyHttpClient(settings, http) else http + val cid = session.clientId ?: UUID.randomUUID().toString() + val did = session.deviceId ?: authStore.stableDeviceId() + val actionCode = mapOf("start" to 1, "pause" to 2, "resume" to 3, "finish" to 4, "cancel" to 5)[action] ?: 5 + val body = buildJsonObject { + put("action", SESSION_MODIFY_ACTION_AD_UPDATE) + putJsonArray("adUpdates") { + add(buildJsonObject { + put("adId", adId) + put("adAction", actionCode) + put("clientTimestamp", System.currentTimeMillis() / 1000) + if (watchedTimeInMs != null) { + put("watchedTimeInMs", max(0L, watchedTimeInMs)) + } + if (pausedTimeInMs != null) { + put("pausedTimeInMs", max(0L, pausedTimeInMs)) + } + if (!cancelReason.isNullOrBlank()) { + put("cancelReason", cancelReason) + } + if (!errorInfo.isNullOrBlank()) { + put("errorInfo", errorInfo) + } + }) + } + } + val request = Request.Builder() + .url("$base/v2/session/${session.sessionId}") + .headers(cloudMatchHeaders(token, cid, did, includeOrigin = true, streamingBaseUrl = session.streamingBaseUrl, isAndroidTv = isAndroidTv)) + .put(body.toString().toRequestBody(JSON_MEDIA_TYPE)) + .build() + val (code, text) = requestHttp.awaitText(request) + recordDiagnosticResponse("session.adUpdate", request, code, text) + check(code in 200..299) { "Queue ad update failed ($code): ${text.take(400)}" } + return toSessionInfo(session.zone, base, OpenNowJson.parseToJsonElement(text).jsonObject, cid, did) + } + + private fun recordDiagnosticResponse(operation: String, request: Request, statusCode: Int, responseBody: String) { + runCatching { + diagnosticsSink( + GfnSessionDiagnosticResponse( + operation = operation, + method = request.method, + url = request.url.toString(), + statusCode = statusCode, + requestBody = OpenNowHttpDiagnostics.captureRequestBody(request), + responseBody = responseBody, + ), + ) + } + } + + private fun buildSessionRequestBody( + appId: String, + internalTitle: String, + settings: StreamSettings, + accountLinked: Boolean, + deviceId: String, + physicalDisplayResolution: Pair?, + streamingBaseUrl: String?, + appLaunchMode: Int, + ): JsonObject { + val identity = cloudMatchClientIdentity( + streamingBaseUrl = streamingBaseUrl, + appLaunchMode = appLaunchMode, + preferNativeDesktopMode = if (appLaunchMode == GfnAppLaunchMode.TOUCH_FRIENDLY) false else settings.requiresNativeDesktopCloudMatchMode(), + isAndroidTv = isAndroidTv, + useDesktopNativeTvIdentity = useDesktopNativeTvIdentity, + ) + val profile = settings.requestProfile() + val controllerCapabilities = gfnControllerCapabilities(appLaunchMode) + return buildJsonObject { + putJsonObject("sessionRequestData") { + put("appId", appId) + if (internalTitle.isBlank()) put("internalTitle", JsonNull) else put("internalTitle", internalTitle) + putJsonArray("availableSupportedControllers") { + controllerCapabilities.supportedControllerTypes.forEach { add(JsonPrimitive(it)) } + } + put("networkTestSessionId", JsonNull) + put("parentSessionId", JsonNull) + put("clientIdentification", "GFN-PC") + put("deviceHashId", deviceId) + put("clientVersion", "30.0") + put("sdkVersion", "1.0") + put("streamerVersion", 1) + put("clientPlatformName", if (appLaunchMode == GfnAppLaunchMode.TOUCH_FRIENDLY) "android" else identity.platformName) + putJsonArray("clientRequestMonitorSettings") { + add(monitorSettings(profile, settings.fps, identity)) + } + put("useOps", true) + put("audioMode", 2) + put("metaData", webRtcSessionMetadata(settings, profile, physicalDisplayResolution)) + put("sdrHdrMode", if (profile.hdrEnabled) 1 else 0) + put("clientDisplayHdrCapabilities", if (profile.hdrEnabled) hdrCapabilitiesJson() else JsonNull) + put("surroundAudioInfo", 0) + put("remoteControllersBitmap", controllerCapabilities.remoteControllersBitmap) + put("clientTimezoneOffset", java.util.TimeZone.getDefault().getOffset(System.currentTimeMillis())) + put("enhancedStreamMode", 1) + put("appLaunchMode", appLaunchMode) + put("secureRTSPSupported", false) + put("partnerCustomData", "") + put("accountLinked", accountLinked) + put("enablePersistingInGameSettings", identity.persistGameSettings) + put("userAge", 26) + put("requestedStreamingFeatures", requestedStreamingFeatures(settings, profile)) + } + } + } + + private fun buildClaimRequestBody( + appId: String, + deviceId: String, + settings: StreamSettings, + physicalDisplayResolution: Pair?, + streamingBaseUrl: String?, + appLaunchMode: Int, + ): JsonObject = + buildMinimalClaimRequestBody( + appId = appId, + deviceId = deviceId, + settings = settings, + physicalDisplayResolution = physicalDisplayResolution, + streamingBaseUrl = streamingBaseUrl, + appLaunchMode = appLaunchMode, + isAndroidTv = isAndroidTv, + useDesktopNativeTvIdentity = useDesktopNativeTvIdentity, + ) + + private suspend fun toSessionInfo(zone: String, base: String, payload: JsonObject, clientId: String, deviceId: String): SessionInfo { + val requestStatus = payload.obj("requestStatus") + val status = requestStatus?.int("statusCode") + if (status != 1) { + throw CloudMatchRequestStatusException( + statusCode = status, + statusDescription = requestStatus?.string("statusDescription"), + unifiedErrorCode = requestStatus?.string("unifiedErrorCode"), + ) + } + val session = payload.obj("session") ?: error("CloudMatch response missing session") + val sessionStatus = session.int("status") ?: 0 + val signaling = runCatching { resolveSignaling(payload) }.getOrElse { error -> + if (sessionStatus in READY_SESSION_STATUSES) { + throw error + } + null + } + return SessionInfo( + sessionId = session.string("sessionId") ?: error("Missing session id"), + status = sessionStatus, + queuePosition = extractQueuePosition(session), + seatSetupStep = session.obj("seatSetupInfo")?.int("seatSetupStep"), + adState = extractAdState(session), + zone = payload.obj("requestStatus")?.string("serverId")?.takeIf { it.isNotBlank() } ?: zone, + assignedZone = assignedSessionZoneFromControlHost(session.obj("sessionControlInfo")?.string("ip")), + streamingBaseUrl = base, + serverIp = signaling?.serverIp.orEmpty(), + signalingServer = signaling?.signalingServer.orEmpty(), + signalingUrl = signaling?.signalingUrl.orEmpty(), + gpuType = session.string("gpuType"), + iceServers = normalizeIceServers(payload), + mediaConnectionInfo = signaling?.mediaConnectionInfo, + negotiatedStreamProfile = extractNegotiatedStreamProfile(session), + monitorSnapshot = extractSessionMonitorSnapshot(session), + requestedStreamingFeatures = normalizeStreamingFeatures(session.obj("sessionRequestData")?.obj("requestedStreamingFeatures")), + finalizedStreamingFeatures = normalizeStreamingFeatures(session.obj("finalizedStreamingFeatures")), + clientId = clientId, + deviceId = deviceId, + ) + } + + private fun extractAdState(session: JsonObject): SessionAdState? { + val required = session.boolean("sessionAdsRequired") + ?: session.boolean("isAdsRequired") + ?: session.obj("sessionProgress")?.boolean("isAdsRequired") + ?: session.obj("progressInfo")?.boolean("isAdsRequired") + val ads = session.arr("sessionAds")?.mapIndexedNotNull { index, raw -> + val ad = raw.asObject() ?: return@mapIndexedNotNull null + val media = ad.arr("adMediaFiles")?.mapNotNull { + val m = it.asObject() ?: return@mapNotNull null + SessionAdMediaFile(m.string("mediaFileUrl"), m.string("encodingProfile")) + }?.sortedBy { + when (it.encodingProfile) { + "mp4deinterlaced720p" -> 0 + "webm" -> 1 + "hlsadaptive" -> 2 + else -> 99 + } + }.orEmpty() + val id = ad.string("adId") ?: "ad-${index + 1}" + if (media.isEmpty() && ad.string("adUrl") == null && ad.string("mediaUrl") == null && ad.string("title") == null) null else { + SessionAdInfo( + adId = id, + state = ad.int("adState"), + adState = ad.int("adState"), + adUrl = ad.string("adUrl"), + mediaUrl = ad.string("mediaUrl") ?: ad.string("videoUrl") ?: ad.string("url"), + adMediaFiles = media, + clickThroughUrl = ad.string("clickThroughUrl"), + adLengthInSeconds = ad.double("adLengthInSeconds"), + durationMs = ad.int("durationMs")?.toLong() ?: ad.int("durationInMs")?.toLong(), + title = ad.string("title"), + description = ad.string("description"), + ) + } + }.orEmpty() + val opportunityRaw = session.obj("opportunity") + val opportunity = opportunityRaw?.let { + SessionOpportunityInfo( + state = it.string("state"), + queuePaused = it.boolean("queuePaused"), + gracePeriodSeconds = it.int("gracePeriodSeconds"), + message = it.string("message"), + title = it.string("title"), + description = it.string("description"), + ) + } + val queuePaused = opportunity?.queuePaused ?: (opportunity?.state?.equals("graceperiodstart", true) == true) + val effectiveRequired = required ?: ads.isNotEmpty() + val message = opportunity?.message ?: opportunity?.description ?: if (queuePaused) "Resume ads to stay in queue." else if (effectiveRequired) "Finish ads to stay in queue." else null + if (!effectiveRequired && ads.isEmpty() && !queuePaused && message == null) return null + return SessionAdState( + isAdsRequired = effectiveRequired, + sessionAdsRequired = required, + isQueuePaused = queuePaused, + gracePeriodSeconds = opportunity?.gracePeriodSeconds, + message = message, + sessionAds = ads, + ads = ads, + opportunity = opportunity, + serverSentEmptyAds = session["sessionAds"] == null || session["sessionAds"] is JsonNull, + ) + } + + private fun extractQueuePosition(session: JsonObject): Int? = + session.int("queuePosition") + ?: session.obj("seatSetupInfo")?.int("queuePosition") + ?: session.obj("sessionProgress")?.int("queuePosition") + ?: session.obj("progressInfo")?.int("queuePosition") + + private fun normalizeStreamingFeatures(features: JsonObject?): StreamingFeatures? { + if (features == null) return null + val normalized = StreamingFeatures( + reflex = features.boolean("reflex"), + bitDepth = features.int("bitDepth"), + chromaFormat = features.int("chromaFormat"), + enabledL4S = features.boolean("enabledL4S"), + trueHdr = features.boolean("trueHdr"), + ) + return if (listOf(normalized.reflex, normalized.bitDepth, normalized.chromaFormat, normalized.enabledL4S, normalized.trueHdr).all { it == null }) null else normalized + } + + private fun extractNegotiatedStreamProfile(session: JsonObject): NegotiatedStreamProfile? { + val monitorSnapshot = extractSessionMonitorSnapshot(session) + val finalized = session.obj("finalizedStreamingFeatures") + val requested = session.obj("sessionRequestData")?.obj("requestedStreamingFeatures") + val resolution = monitorSnapshot?.returnedResolution + ?: monitorSnapshot?.finalSelectedResolution + ?: monitorSnapshot?.requestedResolution + val fps = monitorSnapshot?.returnedFps ?: monitorSnapshot?.requestedFps + val bitDepth = finalized?.int("bitDepth") ?: requested?.int("bitDepth") + val chroma = finalized?.int("chromaFormat") ?: requested?.int("chromaFormat") + val cq = when { + bitDepth == 10 && chroma == 2 -> ColorQuality.TenBit444 + bitDepth == 10 -> ColorQuality.TenBit420 + chroma == 2 -> ColorQuality.EightBit444 + bitDepth == 0 -> ColorQuality.EightBit420 + else -> null + } + return NegotiatedStreamProfile( + resolution = resolution, + fps = fps, + colorQuality = cq, + enableL4S = finalized?.boolean("enabledL4S") ?: requested?.boolean("enabledL4S"), + enableReflex = finalized?.boolean("reflex") ?: requested?.boolean("reflex"), + ).takeIf { + it.resolution != null || it.fps != null || it.colorQuality != null || it.enableL4S != null || it.enableReflex != null + } + } + + private fun normalizeIceServers(payload: JsonObject): List { + val servers = payload.obj("session") + ?.obj("iceServerConfiguration") + ?.arr("iceServers") + ?.mapNotNull { raw -> + val obj = raw.asObject() ?: return@mapNotNull null + val urlsElement = obj["urls"] + val urls = when (urlsElement) { + is JsonArray -> urlsElement.mapNotNull { it.asString() } + is JsonPrimitive -> listOfNotNull(urlsElement.contentOrNull) + else -> emptyList() + } + if (urls.isEmpty()) null else IceServer(urls, obj.string("username"), obj.string("credential")) + } + .orEmpty() + return servers.ifEmpty { + listOf( + IceServer(listOf("stun:s1.stun.gamestream.nvidia.com:19308")), + IceServer(listOf("stun:stun.l.google.com:19302")), + IceServer(listOf("stun:stun1.l.google.com:19302")), + ) + } + } + + private data class SignalingResolution( + val serverIp: String, + val signalingServer: String, + val signalingUrl: String, + val mediaConnectionInfo: MediaConnectionInfo?, + ) + + private fun resolveSignaling(payload: JsonObject): SignalingResolution { + val session = payload.obj("session") ?: error("Missing session") + val connections = session.arr("connectionInfo")?.mapNotNull { it.asObject() }.orEmpty() + val serverIp = streamingServerIp(payload) ?: error("CloudMatch response did not include a signaling host") + val signalingConnection = connections.firstOrNull { it.int("usage") == 14 && it.string("ip") != null } ?: connections.firstOrNull { it.string("ip") != null } + val resourcePath = signalingConnection?.string("resourcePath") ?: "/nvst/" + val (url, host) = buildSignalingUrl(resourcePath, serverIp) + val effectiveHost = host ?: serverIp + return SignalingResolution( + serverIp = serverIp, + signalingServer = if (effectiveHost.contains(":")) effectiveHost else "$effectiveHost:443", + signalingUrl = url, + mediaConnectionInfo = resolveMediaConnectionInfo(connections, serverIp), + ) + } + + private fun resolveMediaConnectionInfo(connections: List, serverIp: String): MediaConnectionInfo? { + fun extractIp(conn: JsonObject): String? = conn.string("ip")?.let(::usableSessionHost) ?: conn.string("resourcePath")?.let(::extractHostFromUrl) + fun extractPort(conn: JsonObject): Int = conn.int("port") ?: conn.string("resourcePath")?.let { Uri.parse(it.replace("rtsps://", "https://").replace("rtsp://", "http://")).port } ?: 0 + listOf(2, 17).forEach { usage -> + connections.firstOrNull { it.int("usage") == usage }?.let { + val ip = extractIp(it) + val port = extractPort(it) + if (ip != null && port > 0) return MediaConnectionInfo(ip, port) + } + } + connections.filter { it.int("usage") == 14 }.sortedByDescending { it.int("port") ?: 0 }.forEach { + val port = extractPort(it) + if (port > 0) return MediaConnectionInfo(extractIp(it) ?: serverIp, port) + } + return null + } + + private fun streamingServerIp(payload: JsonObject): String? { + val session = payload.obj("session") ?: return null + return streamingServerIpFromSession(session) + } + + private fun streamingServerIpFromSession(session: JsonObject): String? { + val conn = session.arr("connectionInfo")?.mapNotNull { it.asObject() }?.firstOrNull { it.int("usage") == 14 } + conn?.string("ip")?.let(::usableSessionHost)?.let { return it } + conn?.string("resourcePath")?.let(::extractHostFromUrl)?.let { return it } + return session.obj("sessionControlInfo")?.string("ip")?.let(::usableSessionHost) + } + + private fun buildSignalingUrl(raw: String, serverIp: String): Pair = + when { + raw.startsWith("rtsps://") || raw.startsWith("rtsp://") -> { + val host = raw.substringAfter("://").substringBefore(":").substringBefore("/") + usableSessionHost(host)?.let { "wss://$it/nvst/" to it } ?: ("wss://$serverIp:443/nvst/" to null) + } + raw.startsWith("wss://") -> extractHostFromUrl(raw)?.let { raw to it } ?: ("wss://$serverIp:443/nvst/" to null) + raw.startsWith("/") -> "wss://$serverIp:443$raw" to null + else -> "wss://$serverIp:443/nvst/" to null + } + + private fun extractHostFromUrl(raw: String): String? { + val after = listOf("rtsps://", "rtsp://", "wss://", "https://").firstOrNull { raw.startsWith(it) }?.let { raw.removePrefix(it) } ?: return null + val host = after.substringBefore(":").substringBefore("/") + return usableSessionHost(host) + } + + private fun isZoneHostname(value: String): Boolean = + value.contains("cloudmatchbeta.nvidiagrid.net") || value.contains("cloudmatch.nvidiagrid.net") + + private suspend fun resolveLaunchSessionBaseUrl(token: String, base: String): String { + if (!isProviderRootStreamingBase(base)) return base + val regions = fetchDynamicRegions(http, token, base).first + return providerLaunchBaseUrl(base, regions) + } + + private fun resolveStreamingBaseUrl(zone: String, provided: String?): String { + normalizeStreamingServiceUrl(provided.orEmpty())?.let { return it.trimEnd('/') } + val safeZone = zone.trim().takeIf { it.isNotBlank() && !it.startsWith(".") && !it.contains("/") && !it.contains(":") } + return if (safeZone != null) "https://$safeZone.cloudmatchbeta.nvidiagrid.net" else DEFAULT_STREAMING_SERVICE_URL.trimEnd('/') + } + + private fun resolvePollStopBase(zone: String, provided: String?, serverIp: String?): String { + val base = resolveStreamingBaseUrl(zone, provided) + val host = serverIp?.takeIf { it.isNotBlank() } + return if (host != null && base.contains("cloudmatchbeta.nvidiagrid.net") && !isZoneHostname(host)) "https://$host" else base + } + +} + +internal fun cloudMatchSessionRequestUrl( + base: String, + settings: StreamSettings, + sessionId: String? = null, +): String { + val path = if (sessionId.isNullOrBlank()) { + "${base.trimEnd('/')}/v2/session" + } else { + "${base.trimEnd('/')}/v2/session/${encoded(sessionId)}" + } + return "$path?keyboardLayout=${encoded(settings.keyboardLayout)}&languageCode=${encoded(settings.gameLanguage)}" +} + +internal fun usableSessionHost(value: String?): String? { + val host = value?.trim().orEmpty() + return host.takeIf { + it.isNotBlank() && + !it.startsWith(".") && + !it.endsWith(".") && + !it.contains("..") + } +} + +private fun isProviderRootStreamingBase(base: String): Boolean { + val url = base.toHttpUrlOrNull() ?: return false + val host = url.host.lowercase(Locale.US) + return host.startsWith("prod.") && host.endsWith(".geforcenow.nvidiagrid.net") +} + +internal fun providerLaunchBaseUrl(providerBase: String, regions: List): String { + val normalizedBase = normalizeStreamingServiceUrl(providerBase)?.trimEnd('/') ?: providerBase.trim().trimEnd('/') + if (!isProviderRootStreamingBase(normalizedBase)) return normalizedBase + val providerHost = normalizedBase.toHttpUrlOrNull()?.host?.lowercase(Locale.US) ?: return normalizedBase + val regionUrls = regions + .mapNotNull { normalizeStreamingServiceUrl(it.url)?.trimEnd('/') } + .distinct() + .filter { regionUrl -> + val regionHost = regionUrl.toHttpUrlOrNull()?.host?.lowercase(Locale.US) + regionHost != null && regionHost != providerHost + } + return if (regionUrls.size == 1) regionUrls.first() else normalizedBase +} + +suspend fun fetchDynamicRegions( + http: OkHttpClient, + token: String?, + streamingBaseUrl: String, +): Pair, String?> { + val base = normalizeStreamingServiceUrl(streamingBaseUrl) ?: return emptyList() to null + return runCatching { + val request = Request.Builder() + .url("${base}v2/serverInfo") + .headers( + Headers.Builder() + .putDesktopLcars(token, clientType = "BROWSER", clientStreamer = "WEBRTC") + .build(), + ) + .build() + val (code, text) = http.awaitText(request) + if (code !in 200..299) return@runCatching emptyList() to null + val data = OpenNowJson.parseToJsonElement(text).jsonObject + val vpcId = data.obj("requestStatus")?.string("serverId") + val regions = data.arr("metaData")?.mapNotNull { + val obj = it.asObject() ?: return@mapNotNull null + val key = obj.string("key") ?: return@mapNotNull null + val value = obj.string("value") ?: return@mapNotNull null + val regionUrl = normalizeStreamingServiceUrl(value) ?: return@mapNotNull null + if (key == "gfn-regions" || key.startsWith("gfn-")) null else StreamRegion(key, regionUrl) + }?.sortedBy { it.name }.orEmpty() + regions to vpcId + }.getOrDefault(emptyList() to null) +} + +private val NON_ALNUM_RUN = Regex("[^a-z0-9]+") + +// Runs once per catalogue entry during the merge; compiling the pattern per call showed up on +// large libraries. +private fun String.normalizedTitleKey(): String = + trim().lowercase(Locale.US).replace(NON_ALNUM_RUN, " ").trim() +private fun String.isNumeric(): Boolean = all(Char::isDigit) diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/Haptics.kt b/android/app/src/main/java/com/opencloudgaming/opennow/Haptics.kt new file mode 100644 index 000000000..b1fd64667 --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/Haptics.kt @@ -0,0 +1,236 @@ +package com.opencloudgaming.opennow + +import android.content.Context +import android.media.AudioAttributes +import android.os.Build +import android.os.SystemClock +import android.os.VibrationAttributes +import android.os.VibrationEffect +import android.os.Vibrator +import android.os.VibratorManager +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged +import androidx.annotation.RequiresApi +import androidx.compose.ui.platform.LocalContext + +/** + * App-chrome haptics. + * + * These deliberately do **not** go through `View.performHapticFeedback`. Detected gaming handhelds + * use their device-tuned haptic effects under [VibrationAttributes.USAGE_TOUCH]. In particular, + * Odin 2 Portal firmware reports successful medium-strength predefined effects that are not + * perceptible on the physical unit, so known handhelds use longer full-amplitude touch pulses. + * Other devices retain the shorter media/game-rumble route. + * + * The app's own vibration switch ([AppSettings.vibrationEnabled]) stays authoritative — see + * [enabled] — so nothing here overrides a reader who asked for silence. + */ +internal enum class HapticCue { + /** Focus landed on a new control. The lightest tick in the set; fires constantly. */ + FocusMove, + + /** A control was activated. */ + Activate, + + /** Backing out of a screen or dismissing a sheet. */ + Back, + + /** Focus tried to leave a container and could not, or an action was refused. */ + Boundary, +} + +internal data class HapticPulse(val durationMs: Long, val amplitude: Int) + +internal fun hapticPulseFor(cue: HapticCue): HapticPulse = when (cue) { + HapticCue.FocusMove -> HapticPulse(durationMs = 9L, amplitude = 64) + HapticCue.Activate -> HapticPulse(durationMs = 17L, amplitude = 150) + HapticCue.Back -> HapticPulse(durationMs = 13L, amplitude = 104) + HapticCue.Boundary -> HapticPulse(durationMs = 26L, amplitude = 196) +} + +internal fun handheldHapticPulseFor(cue: HapticCue): HapticPulse = when (cue) { + HapticCue.FocusMove -> HapticPulse(durationMs = 32L, amplitude = 255) + HapticCue.Activate -> HapticPulse(durationMs = 55L, amplitude = 255) + HapticCue.Back -> HapticPulse(durationMs = 45L, amplitude = 240) + HapticCue.Boundary -> HapticPulse(durationMs = 80L, amplitude = 255) +} + +/** + * Focus can move faster than a motor can settle — holding a stick left runs the grid at the key + * repeat rate — so ticks below this gap are dropped rather than queued into a buzz. + */ +internal const val FOCUS_HAPTIC_MIN_INTERVAL_MS = 45L + +internal fun shouldEmitFocusHaptic(lastAtMs: Long, nowMs: Long): Boolean = + lastAtMs == 0L || nowMs - lastAtMs >= FOCUS_HAPTIC_MIN_INTERVAL_MS + +/** + * Built-in-controller Android handhelds that should receive tactile D-pad focus ticks. + * + * These devices are not consistently exposed as Android TV and some firmware reports the + * integrated controls in ways that make input-device-only detection unreliable. Build identity is + * stable for this purpose and deliberately stays conservative so ordinary phones do not start + * buzzing merely because a Bluetooth pad was paired once. + */ +internal fun isGamingHandheldDevice( + manufacturer: String?, + brand: String?, + model: String?, + device: String?, + product: String?, +): Boolean { + val identity = listOf(manufacturer, brand, model, device, product) + .joinToString(" ") { it.orEmpty() } + .lowercase(java.util.Locale.US) + .replace(Regex("[^a-z0-9]+"), " ") + .trim() + val tokens = identity.split(' ').filterTo(mutableSetOf()) { it.isNotBlank() } + return tokens.contains("ayn") || + identity.contains("odin") || + identity.contains("retroid") || + identity.contains("anbernic") || + identity.contains("ayaneo") || + identity.contains("powkiddy") || + identity.contains("abxylute") || + tokens.contains("gpd") || + (tokens.contains("razer") && tokens.contains("edge")) || + (tokens.contains("logitech") && (identity.contains("g cloud") || identity.contains("gr0006"))) +} + +internal fun isGamingHandheldDevice(): Boolean = isGamingHandheldDevice( + manufacturer = Build.MANUFACTURER, + brand = Build.BRAND, + model = Build.MODEL, + device = Build.DEVICE, + product = Build.PRODUCT, +) + +internal class OpenNowHaptics(context: Context) { + private val appContext = context.applicationContext + + /** Mirrors [AppSettings.vibrationEnabled]; kept in sync by [rememberOpenNowHaptics]. */ + var enabled: Boolean = true + + /** Focus movement is useful on controller-led handhelds and noisy on touch-first phones. */ + var navigationEnabled: Boolean = false + + /** Uses strong touch-channel pulses instead of short media rumble on known handhelds. */ + var handheldFeedback: Boolean = false + + private var lastFocusAtMs = 0L + + private val vibrator: Vibrator? by lazy { + @Suppress("DEPRECATION") + val found = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + appContext.getSystemService(VibratorManager::class.java)?.defaultVibrator + } else { + appContext.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator + } + found?.takeIf { it.hasVibrator() } + } + + private val amplitudeControl: Boolean by lazy { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && vibrator?.hasAmplitudeControl() == true + } + + /** Throttled: safe to call from every `onFocusChanged` in a scrolling grid. */ + fun focusMoved() { + if (!enabled || !navigationEnabled) return + val now = SystemClock.uptimeMillis() + if (!shouldEmitFocusHaptic(lastFocusAtMs, now)) return + lastFocusAtMs = now + play(HapticCue.FocusMove) + } + + fun play(cue: HapticCue) { + if (!enabled) return + val target = vibrator ?: return + val pulse = if (handheldFeedback) handheldHapticPulseFor(cue) else hapticPulseFor(cue) + runCatching { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val amplitude = if (amplitudeControl || handheldFeedback) { + pulse.amplitude.coerceIn(1, 255) + } else { + VibrationEffect.DEFAULT_AMPLITUDE + } + val effect = VibrationEffect.createOneShot(pulse.durationMs, amplitude) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + target.vibrate( + effect, + if (handheldFeedback) touchVibrationAttributes() else mediaVibrationAttributes(), + ) + } else { + target.vibrate( + effect, + if (handheldFeedback) touchAudioAttributes() else gameAudioAttributes(), + ) + } + } else { + @Suppress("DEPRECATION") + target.vibrate( + pulse.durationMs, + if (handheldFeedback) touchAudioAttributes() else gameAudioAttributes(), + ) + } + } + } +} + +/** Media/game-rumble routing retained for devices without the handheld firmware workaround. */ +@RequiresApi(Build.VERSION_CODES.R) +internal fun mediaVibrationAttributes(): VibrationAttributes = + VibrationAttributes.Builder().setUsage(VibrationAttributes.USAGE_MEDIA).build() + +@RequiresApi(Build.VERSION_CODES.R) +internal fun touchVibrationAttributes(): VibrationAttributes = + VibrationAttributes.Builder().setUsage(VibrationAttributes.USAGE_TOUCH).build() + +/** + * The pre-API-33 spelling of the same intent: the platform maps `AudioAttributes.USAGE_GAME` onto + * `VibrationAttributes.USAGE_MEDIA`, so both paths land in the same bucket. + */ +internal fun gameAudioAttributes(): AudioAttributes = + AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_GAME) + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .build() + +internal fun touchAudioAttributes(): AudioAttributes = + AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_ASSISTANCE_SONIFICATION) + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .build() + +internal val LocalOpenNowHaptics = staticCompositionLocalOf { null } + +@Composable +internal fun rememberOpenNowHaptics( + enabled: Boolean, + navigationEnabled: Boolean, + handheldFeedback: Boolean, +): OpenNowHaptics { + val context = LocalContext.current + val haptics = remember(context) { OpenNowHaptics(context) } + SideEffect { + haptics.enabled = enabled + haptics.navigationEnabled = navigationEnabled + haptics.handheldFeedback = handheldFeedback + } + return haptics +} + +/** + * Ticks once whenever focus lands here. + * + * Place it next to the element's own `onFocusChanged` and before `focusable()`, otherwise the + * focus modifier downstream never reports to it. + */ +@Composable +internal fun Modifier.focusMoveHaptics(): Modifier { + val haptics = LocalOpenNowHaptics.current ?: return this + return this.onFocusChanged { if (it.isFocused) haptics.focusMoved() } +} diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/InputDiagnostics.kt b/android/app/src/main/java/com/opencloudgaming/opennow/InputDiagnostics.kt new file mode 100644 index 000000000..b420949d0 --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/InputDiagnostics.kt @@ -0,0 +1,161 @@ +package com.opencloudgaming.opennow + +import android.os.SystemClock +import android.util.Log + +internal class InputDiagnosticsBuffer( + private val maxRecentLines: Int, + private val maxRetainedLines: Int, + private val elapsedRealtime: () -> Long, +) { + private val recentLines = ArrayDeque() + private val retainedLines = linkedMapOf() + private val retainedUpdatedAtMs = mutableMapOf() + private val retainedCounts = mutableMapOf() + + init { + require(maxRecentLines > 0) + require(maxRetainedLines > 0) + } + + fun add(message: String): String { + val line = formatLine(elapsedRealtime(), message) + addRecentLine(line) + return line + } + + fun addRetained(key: String, message: String): String { + val now = elapsedRealtime() + val line = formatLine(now, message) + addRecentLine(line) + retainLine(key, now, line) + return line + } + + private fun addRecentLine(line: String) { + if (recentLines.size >= maxRecentLines) { + recentLines.removeFirst() + } + recentLines.addLast(line) + } + + fun retain(key: String, message: String): String = + retainAt(key, elapsedRealtime(), message) + + fun retainCounted(key: String, message: () -> String): String { + return retainCountedAt(key, elapsedRealtime(), message()) + } + + fun retainResult( + keyPrefix: String, + succeeded: Boolean, + message: () -> String, + ) { + val now = elapsedRealtime() + val detail = message() + retainCountedAt("$keyPrefix.last", now, "success=$succeeded $detail") + retainCountedAt("$keyPrefix.${if (succeeded) "success" else "failure"}", now, detail) + } + + fun retainThrottled( + key: String, + minimumIntervalMs: Long, + message: () -> String, + ): String? { + require(minimumIntervalMs >= 0) + val now = elapsedRealtime() + val lastUpdate = retainedUpdatedAtMs[key] + if (lastUpdate != null && now - lastUpdate in 0 until minimumIntervalMs) { + return null + } + return retainAt(key, now, message()) + } + + fun snapshot(): String { + if (retainedLines.isEmpty() && recentLines.isEmpty()) { + return "input.diagnostics=empty" + } + return buildString { + if (retainedLines.isNotEmpty()) { + appendLine("input.state:") + retainedLines.forEach { (key, line) -> appendLine("$key $line") } + } + if (recentLines.isNotEmpty()) { + appendLine("input.diagnostics:") + recentLines.forEach { appendLine(it) } + } + }.trimEnd() + } + + private fun retainAt(key: String, now: Long, message: String): String { + val line = formatLine(now, message) + retainLine(key, now, line) + return line + } + + private fun retainCountedAt(key: String, now: Long, message: String): String { + val count = (retainedCounts[key] ?: 0L) + 1L + retainedCounts[key] = count + return retainAt(key, now, "count=$count $message") + } + + private fun retainLine(key: String, now: Long, line: String) { + if (key !in retainedLines && retainedLines.size >= maxRetainedLines) { + retainedLines.keys.firstOrNull()?.let { oldestKey -> + retainedLines.remove(oldestKey) + retainedUpdatedAtMs.remove(oldestKey) + retainedCounts.remove(oldestKey) + } + } + retainedLines[key] = line + retainedUpdatedAtMs[key] = now + } + + private fun formatLine(now: Long, message: String): String = "$now $message" +} + +object NativeInputDiagnostics { + private const val MAX_RECENT_LINES = 240 + private const val MAX_RETAINED_LINES = 48 + private const val TAG = "OpenNOWInput" + private val buffer = InputDiagnosticsBuffer( + maxRecentLines = MAX_RECENT_LINES, + maxRetainedLines = MAX_RETAINED_LINES, + elapsedRealtime = SystemClock::elapsedRealtime, + ) + + @Synchronized + fun add(message: String) { + buffer.add(message) + Log.d(TAG, message) + } + + @Synchronized + fun addRetained(key: String, message: String) { + buffer.addRetained(key, message) + Log.d(TAG, message) + } + + @Synchronized + fun retain(key: String, message: String) { + buffer.retain(key, message) + } + + @Synchronized + fun retainThrottled(key: String, minimumIntervalMs: Long, message: () -> String) { + buffer.retainThrottled(key, minimumIntervalMs, message) + } + + @Synchronized + fun retainResult(keyPrefix: String, succeeded: Boolean, message: () -> String) { + buffer.retainResult(keyPrefix, succeeded, message) + } + + @Synchronized + fun retainTouchRoute(key: String, message: () -> String) { + buffer.retainCounted("touch-route.$key", message) + } + + @Synchronized + fun snapshot(): String = buffer.snapshot() +} diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/LaunchErrors.kt b/android/app/src/main/java/com/opencloudgaming/opennow/LaunchErrors.kt new file mode 100644 index 000000000..e06cd12f5 --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/LaunchErrors.kt @@ -0,0 +1,94 @@ +package com.opencloudgaming.opennow + +private val LaunchErrorWhitespaceRegex = Regex("""\s+""") + +internal class CloudMatchRequestStatusException( + val statusCode: Int?, + val statusDescription: String?, + val unifiedErrorCode: String?, +) : IllegalStateException( + buildString { + append("CloudMatch returned status ") + append(statusCode?.toString() ?: "unknown") + statusDescription?.trim()?.takeIf { it.isNotEmpty() }?.let { + append(": ") + append(it) + } + unifiedErrorCode?.trim()?.takeIf { it.isNotEmpty() }?.let { + append(" (unified error ") + append(it) + append(')') + } + }, +) + +internal fun normalizeLaunchErrorMessage(error: Throwable, gameTitle: String? = null): String { + val text = error.message ?: return "Launch failed" + val cloudMatchFailure = error.cloudMatchRequestStatusException() + val terminalSession = error.terminalSessionStatusException() + return when { + terminalSession != null -> + "The cloud provider ended this session (status ${terminalSession.status}). " + + "OpenNOW did not stop it or start a replacement queue." + cloudMatchFailure?.isFreeTierEntitlementError() == true -> + "Your GeForce NOW account is on the Free tier. This game requires a Priority or Ultimate membership." + cloudMatchFailure?.isLimitedModeStreamingError() == true -> limitedModeStreamingMessage(gameTitle) + text.contains("patch", ignoreCase = true) || text.contains("maintenance", ignoreCase = true) -> + "Game is patching or under maintenance. Try again when NVIDIA finishes updating it." + else -> text + } +} + +private fun Throwable.terminalSessionStatusException(): TerminalSessionStatusException? { + var current: Throwable? = this + while (current != null) { + if (current is TerminalSessionStatusException) return current + current = current.cause?.takeUnless { it === current } + } + return null +} + +private fun Throwable.cloudMatchRequestStatusException(): CloudMatchRequestStatusException? { + var current: Throwable? = this + while (current != null) { + if (current is CloudMatchRequestStatusException) return current + current = current.cause?.takeUnless { it === current } + } + return null +} + +private fun CloudMatchRequestStatusException.isFreeTierEntitlementError(): Boolean = + statusDescriptionToken().equals("ENTITLEMENT_FAILURE_STATUS", ignoreCase = true) || + normalizedUnifiedErrorCode() == "8A910006" + +private fun CloudMatchRequestStatusException.isLimitedModeStreamingError(): Boolean = + statusDescriptionToken().equals("STREAMING_NOT_ALLOWED_IN_LIMITED_MODE", ignoreCase = true) || + normalizedUnifiedErrorCode() == "8A91000D" + +private fun CloudMatchRequestStatusException.statusDescriptionToken(): String? = + statusDescription + ?.trim() + ?.takeWhile { !it.isWhitespace() } + ?.takeIf { it.isNotEmpty() } + +private fun CloudMatchRequestStatusException.normalizedUnifiedErrorCode(): String? { + val raw = unifiedErrorCode?.trim()?.takeIf { it.isNotEmpty() } ?: return null + val numeric = raw.toLongOrNull() + return if (numeric != null) { + (numeric and 0xFFFF_FFFFL).toString(16).uppercase() + } else { + raw.removePrefix("0x").removePrefix("0X").uppercase() + } +} + +private fun limitedModeStreamingMessage(gameTitle: String?): String { + val title = gameTitle + ?.replace(LaunchErrorWhitespaceRegex, " ") + ?.trim() + .orEmpty() + return if (title.isNotBlank()) { + "$title is only available for Priority or Ultimate members" + } else { + "This game is only available for Priority or Ultimate members" + } +} diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/LocalTvConnector.kt b/android/app/src/main/java/com/opencloudgaming/opennow/LocalTvConnector.kt new file mode 100644 index 000000000..f090ecf23 --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/LocalTvConnector.kt @@ -0,0 +1,801 @@ +package com.opencloudgaming.opennow + +import android.net.Uri +import android.os.Build +import android.util.Base64 +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.serialization.encodeToString +import kotlinx.serialization.decodeFromString +import java.io.BufferedInputStream +import java.io.BufferedOutputStream +import java.io.DataInputStream +import java.io.DataOutputStream +import java.io.IOException +import java.net.Inet4Address +import java.net.InetAddress +import java.net.DatagramPacket +import java.net.DatagramSocket +import java.net.InetSocketAddress +import java.net.NetworkInterface +import java.net.ServerSocket +import java.net.Socket +import java.net.SocketException +import java.net.SocketTimeoutException +import java.security.KeyFactory +import java.security.KeyPair +import java.security.KeyPairGenerator +import java.security.MessageDigest +import java.security.SecureRandom +import java.security.spec.ECGenParameterSpec +import java.security.spec.X509EncodedKeySpec +import java.util.Collections +import java.util.UUID +import javax.crypto.Cipher +import javax.crypto.KeyAgreement +import javax.crypto.Mac +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.SecretKeySpec + +data class DiscoveredLocalTv( + val name: String, + /** Pair URI without the short code. The person confirms physical access by entering it. */ + val pairUri: String, +) + +data class LocalTvConnectorState( + val hosting: Boolean = false, + val pairUri: String? = null, + val pairingCode: String? = null, + val pairedDeviceName: String? = null, + val pairedDeviceTrusted: Boolean = false, + val trustRequestedByDevice: Boolean = false, + val connectedTvName: String? = null, + val discoveredTvs: List = emptyList(), + val discovering: Boolean = false, + val discoveryCompleted: Boolean = false, + val requestTrustedAccess: Boolean = true, + val busy: Boolean = false, + val error: String? = null, + val message: String? = null, +) { + val phoneConnected: Boolean get() = connectedTvName != null +} + +internal data class LocalTvLaunchRequest( + val gameId: String, + val title: String?, +) + +internal data class LocalTvRemoteRequest( + val action: String, + val value: String?, +) + +/** + * Ephemeral local-only pairing for handing a launch from an Android phone to an Android TV. + * The QR pins the TV's ECDH public key. Pairing and launch bodies are encrypted with AES-GCM; + * no account tokens, GFN credentials, or remote/cloud relay are involved. + */ +internal class LocalTvConnector( + private val discoveryPort: Int = DISCOVERY_PORT, +) { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val random = SecureRandom() + private val _state = MutableStateFlow(LocalTvConnectorState()) + val state: StateFlow = _state.asStateFlow() + private val _launchRequests = MutableSharedFlow(extraBufferCapacity = 4) + val launchRequests: SharedFlow = _launchRequests.asSharedFlow() + private val _signInRequests = MutableSharedFlow(extraBufferCapacity = 2) + val signInRequests: SharedFlow = _signInRequests.asSharedFlow() + private val _remoteRequests = MutableSharedFlow(extraBufferCapacity = 8) + val remoteRequests: SharedFlow = _remoteRequests.asSharedFlow() + + @Volatile private var serverSocket: ServerSocket? = null + @Volatile private var discoveryResponderSocket: DatagramSocket? = null + @Volatile private var phoneDiscoverySocket: DatagramSocket? = null + @Volatile private var phoneDiscoveryGeneration: Long = 0L + @Volatile private var hostKeyPair: KeyPair? = null + @Volatile private var pairingCode: String? = null + @Volatile private var pairingExpiresAtMs: Long = 0L + @Volatile private var pairingAttempts: Int = 0 + @Volatile private var pairedClientPublicKey: ByteArray? = null + @Volatile private var pairedSharedKey: ByteArray? = null + private val recentRequestIds = Collections.synchronizedSet(LinkedHashSet()) + @Volatile private var phoneTarget: PhoneTarget? = null + + fun startHosting() { + if (serverSocket != null) return + _state.value = _state.value.copy(busy = true, error = null, connectedTvName = null) + scope.launch { + runCatching { + val address = privateLanAddress() ?: error("Connect the TV to a private Wi-Fi or Ethernet network first") + val keyPair = generateEcKeyPair() + val code = (random.nextInt(9_000) + 1_000).toString() + val server = ServerSocket(0, 8, address).apply { reuseAddress = true } + hostKeyPair = keyPair + pairingCode = code + pairingExpiresAtMs = System.currentTimeMillis() + PAIRING_LIFETIME_MS + pairingAttempts = 0 + pairedClientPublicKey = null + pairedSharedKey = null + serverSocket = server + val pairUri = Uri.Builder() + .scheme("opennow") + .authority("pair") + .appendQueryParameter("h", address.hostAddress) + .appendQueryParameter("p", server.localPort.toString()) + .appendQueryParameter("c", code) + .appendQueryParameter("k", base64Url(keyPair.public.encoded)) + .build() + .toString() + _state.value = LocalTvConnectorState( + hosting = true, + pairUri = pairUri, + pairingCode = code, + requestTrustedAccess = _state.value.requestTrustedAccess, + ) + scope.launch { + try { + respondToDiscovery( + address = address, + port = server.localPort, + publicKey = keyPair.public.encoded, + ) + } catch (_: IOException) { + // Discovery is optional: direct QR pairing still works. A port bind, + // network transition, or concurrent shutdown must not crash the process. + if (serverSocket === server && !server.isClosed) { + _state.value = _state.value.copy( + message = "Automatic TV discovery is unavailable; scan the pairing QR instead", + ) + } + } + } + acceptLoop(server, address) + }.onFailure { error -> + closeHost() + _state.value = LocalTvConnectorState( + error = error.message ?: "Could not start TV connector", + requestTrustedAccess = _state.value.requestTrustedAccess, + ) + } + } + } + + fun stopHosting() { + closeHost() + _state.value = LocalTvConnectorState( + connectedTvName = _state.value.connectedTvName, + requestTrustedAccess = _state.value.requestTrustedAccess, + ) + } + + fun refreshPairingCode() { + closeHost() + _state.value = LocalTvConnectorState(requestTrustedAccess = _state.value.requestTrustedAccess) + startHosting() + } + + fun setPhoneTrustRequest(enabled: Boolean) { + _state.value = _state.value.copy(requestTrustedAccess = enabled, error = null, message = null) + } + + fun setPairedDeviceTrusted(trusted: Boolean) { + if (_state.value.pairedDeviceName == null) return + _state.value = _state.value.copy( + pairedDeviceTrusted = trusted, + message = if (trusted) "Trusted remote access enabled" else "Sensitive remote controls disabled", + ) + } + + fun forgetPhoneTarget() { + phoneTarget = null + _state.value = _state.value.copy(connectedTvName = null, error = null) + } + + /** Finds OpenNOW TVs on the local network. The TV never broadcasts its pairing code. */ + fun discoverTvs() { + val generation = phoneDiscoveryGeneration + 1L + phoneDiscoveryGeneration = generation + runCatching { phoneDiscoverySocket?.close() } + _state.value = _state.value.copy( + discovering = true, + discoveryCompleted = false, + discoveredTvs = emptyList(), + error = null, + message = null, + ) + scope.launch { + val discovered = linkedMapOf() + runCatching { + val localAddress = privateLanAddress() + ?: error("Connect this phone to the same private Wi-Fi as the TV") + DatagramSocket().use { socket -> + phoneDiscoverySocket = socket + socket.broadcast = true + socket.soTimeout = DISCOVERY_POLL_TIMEOUT_MS + val request = DISCOVERY_REQUEST.toByteArray(Charsets.UTF_8) + discoveryBroadcastAddresses(localAddress).forEach { target -> + socket.send(DatagramPacket(request, request.size, target, discoveryPort)) + } + val deadline = System.currentTimeMillis() + DISCOVERY_WINDOW_MS + val responseBuffer = ByteArray(MAX_DISCOVERY_PACKET_BYTES) + while (System.currentTimeMillis() < deadline) { + val packet = DatagramPacket(responseBuffer, responseBuffer.size) + try { + socket.receive(packet) + } catch (_: SocketTimeoutException) { + continue + } + if (!isSamePrivateLan(packet.address, localAddress)) continue + parseDiscoveryResponse(packet.data.copyOf(packet.length))?.let { tv -> + discovered[tv.pairUri] = tv + if (phoneDiscoveryGeneration == generation) { + _state.value = _state.value.copy(discoveredTvs = discovered.values.toList()) + } + } + } + } + }.onFailure { error -> + if (phoneDiscoveryGeneration == generation && + (error !is java.net.SocketException || phoneDiscoverySocket?.isClosed != true) + ) { + _state.value = _state.value.copy(error = error.message ?: "Could not search for TVs") + } + } + if (phoneDiscoveryGeneration == generation) { + phoneDiscoverySocket = null + _state.value = _state.value.copy( + discovering = false, + discoveryCompleted = true, + ) + } + } + } + + fun pairDiscoveredTv(tv: DiscoveredLocalTv, code: String) { + val normalizedCode = normalizeLocalTvPairingCode(code) + if (normalizedCode == null) { + _state.value = _state.value.copy(error = "Enter the 4-digit code shown on the TV") + return + } + val uri = Uri.parse(tv.pairUri).buildUpon() + .appendQueryParameter("c", normalizedCode) + .build() + pairPhone(uri) + } + + fun reportPairingError(message: String) { + _state.value = _state.value.copy(error = message, busy = false) + } + + fun isPairUri(uri: Uri?): Boolean = isLocalTvPairUri(uri) + + fun pairPhone(uri: Uri) { + if (!isPairUri(uri)) return + phoneDiscoveryGeneration += 1L + runCatching { phoneDiscoverySocket?.close() } + phoneDiscoverySocket = null + _state.value = _state.value.copy(busy = true, error = null) + scope.launch { + runCatching { + val host = uri.getQueryParameter("h")?.takeIf(::isPrivateIpv4Text) + ?: error("Pairing QR has no valid private TV address") + val port = uri.getQueryParameter("p")?.toIntOrNull()?.takeIf { it in 1..65535 } + ?: error("Pairing QR has no valid TV port") + val code = uri.getQueryParameter("c")?.takeIf { it.length == 4 && it.all(Char::isDigit) } + ?: error("Pairing QR has no valid code") + val hostPublic = decodePublicKey(uri.getQueryParameter("k") ?: error("Pairing QR has no security key")) + val phoneKeys = generateEcKeyPair() + val shared = deriveAesKey(phoneKeys, hostPublic.encoded) + val requestTrustedAccess = _state.value.requestTrustedAccess + val encrypted = encrypt(shared, "$code\n${safeDeviceName()}\n${if (requestTrustedAccess) 1 else 0}") + val response = sendFrame( + host = host, + port = port, + command = COMMAND_PAIR, + publicKey = phoneKeys.public.encoded, + iv = encrypted.iv, + ciphertext = encrypted.ciphertext, + ) + if (response.first != STATUS_OK) error(response.second.ifBlank { "TV rejected pairing" }) + phoneTarget = PhoneTarget(host, port, phoneKeys, shared) + _state.value = LocalTvConnectorState( + connectedTvName = response.second.ifBlank { "OpenNOW TV" }, + requestTrustedAccess = requestTrustedAccess, + ) + }.onFailure { error -> + phoneTarget = null + _state.value = LocalTvConnectorState( + error = error.message ?: "Could not pair with TV", + requestTrustedAccess = _state.value.requestTrustedAccess, + ) + } + } + } + + fun sendLaunch(gameId: String, title: String?) { + val target = phoneTarget + if (target == null) { + _state.value = _state.value.copy(error = "Pair with a TV first") + return + } + _state.value = _state.value.copy(busy = true, error = null) + scope.launch { + runCatching { + val requestId = UUID.randomUUID().toString() + val safeTitle = title.orEmpty().replace('\n', ' ').take(160) + val plaintext = "${System.currentTimeMillis()}\n$requestId\n${gameId.replace('\n', ' ').take(200)}\n$safeTitle" + val encrypted = encrypt(target.sharedKey, plaintext) + val response = sendFrame( + host = target.host, + port = target.port, + command = COMMAND_LAUNCH, + publicKey = target.phoneKeys.public.encoded, + iv = encrypted.iv, + ciphertext = encrypted.ciphertext, + ) + if (response.first != STATUS_OK) error(response.second.ifBlank { "TV rejected launch" }) + _state.value = _state.value.copy(busy = false, error = null) + }.onFailure { error -> + _state.value = _state.value.copy(busy = false, error = error.message ?: "Could not send launch to TV") + } + } + } + + fun sendSignIn(session: AuthSession) { + val target = phoneTarget + if (target == null) { + _state.value = _state.value.copy(error = "Pair with a TV first") + return + } + _state.value = _state.value.copy(busy = true, error = null, message = null) + scope.launch { + runCatching { + val requestId = UUID.randomUUID().toString() + val sessionJson = OpenNowJson.encodeToString(session) + val plaintext = "${System.currentTimeMillis()}\n$requestId\n$sessionJson" + val encrypted = encrypt(target.sharedKey, plaintext) + val response = sendFrame( + host = target.host, + port = target.port, + command = COMMAND_SIGN_IN, + publicKey = target.phoneKeys.public.encoded, + iv = encrypted.iv, + ciphertext = encrypted.ciphertext, + ) + if (response.first != STATUS_OK) error(response.second.ifBlank { "TV rejected sign-in" }) + _state.value = _state.value.copy(busy = false, error = null, message = "Sign-in sent securely to TV") + }.onFailure { error -> + _state.value = _state.value.copy(busy = false, error = error.message ?: "Could not sign in TV") + } + } + } + + fun sendRemoteAction(action: String, value: String? = null) { + val target = phoneTarget + if (target == null) { + _state.value = _state.value.copy(error = "Pair with a TV first") + return + } + val safeAction = action.trim().takeIf { it.matches(Regex("[a-z0-9_]{1,48}")) } ?: run { + _state.value = _state.value.copy(error = "Remote action is invalid") + return + } + val safeValue = value.orEmpty().replace('\n', ' ').take(240) + _state.value = _state.value.copy(busy = true, error = null, message = null) + scope.launch { + runCatching { + val plaintext = "${System.currentTimeMillis()}\n${UUID.randomUUID()}\n$safeAction\n$safeValue" + val encrypted = encrypt(target.sharedKey, plaintext) + val response = sendFrame( + host = target.host, + port = target.port, + command = COMMAND_REMOTE, + publicKey = target.phoneKeys.public.encoded, + iv = encrypted.iv, + ciphertext = encrypted.ciphertext, + ) + if (response.first != STATUS_OK) error(response.second.ifBlank { "TV rejected remote command" }) + _state.value = _state.value.copy(busy = false, error = null, message = response.second) + }.onFailure { error -> + _state.value = _state.value.copy(busy = false, error = error.message ?: "Could not control TV") + } + } + } + + private fun acceptLoop(server: ServerSocket, hostAddress: InetAddress) { + while (!server.isClosed) { + val socket = runCatching { server.accept() }.getOrElse { error -> + if (server.isClosed) return + throw error + } + scope.launch { + socket.use { client -> + client.soTimeout = SOCKET_TIMEOUT_MS + if (!isSamePrivateLan(client.inetAddress, hostAddress)) return@use + handleClient(client) + } + } + } + } + + private fun respondToDiscovery(address: Inet4Address, port: Int, publicKey: ByteArray) { + val socket = DatagramSocket(null) + try { + socket.reuseAddress = true + socket.bind(InetSocketAddress(discoveryPort)) + socket.soTimeout = DISCOVERY_POLL_TIMEOUT_MS + discoveryResponderSocket = socket + val pairUri = Uri.Builder() + .scheme("opennow") + .authority("pair") + .appendQueryParameter("h", address.hostAddress) + .appendQueryParameter("p", port.toString()) + .appendQueryParameter("k", base64Url(publicKey)) + .build() + .toString() + val response = listOf(DISCOVERY_RESPONSE, safeDeviceName(), pairUri) + .joinToString("\n") + .toByteArray(Charsets.UTF_8) + val requestBuffer = ByteArray(128) + while (!socket.isClosed && serverSocket != null) { + val packet = DatagramPacket(requestBuffer, requestBuffer.size) + try { + socket.receive(packet) + } catch (_: SocketTimeoutException) { + continue + } catch (error: SocketException) { + // closeHost/close deliberately interrupts this blocking receive. Android + // reports that wake-up as EBADF; it is a normal shutdown, not a process error. + if (socket.isClosed || discoveryResponderSocket !== socket) break + throw error + } + if (!isSamePrivateLan(packet.address, address)) continue + val request = packet.data.copyOf(packet.length).toString(Charsets.UTF_8) + if (request != DISCOVERY_REQUEST) continue + socket.send(DatagramPacket(response, response.size, packet.address, packet.port)) + } + } finally { + socket.close() + if (discoveryResponderSocket === socket) discoveryResponderSocket = null + } + } + + private fun parseDiscoveryResponse(bytes: ByteArray): DiscoveredLocalTv? { + val lines = bytes.toString(Charsets.UTF_8).lines() + if (lines.getOrNull(0) != DISCOVERY_RESPONSE) return null + val name = lines.getOrNull(1)?.trim()?.take(80)?.ifBlank { "OpenNOW TV" } ?: return null + val pairUri = lines.getOrNull(2)?.trim()?.takeIf { raw -> + val uri = runCatching { Uri.parse(raw) }.getOrNull() ?: return@takeIf false + isPairUri(uri) && uri.getQueryParameter("c") == null && + uri.getQueryParameter("h")?.let(::isPrivateIpv4Text) == true && + uri.getQueryParameter("p")?.toIntOrNull() in 1..65535 && + !uri.getQueryParameter("k").isNullOrBlank() + } ?: return null + return DiscoveredLocalTv(name = name, pairUri = pairUri) + } + + private fun discoveryBroadcastAddresses(localAddress: Inet4Address): List { + val bytes = localAddress.address + return listOfNotNull( + runCatching { InetAddress.getByName("255.255.255.255") }.getOrNull(), + runCatching { + InetAddress.getByAddress(byteArrayOf(bytes[0], bytes[1], 0xff.toByte(), 0xff.toByte())) + }.getOrNull(), + ).distinctBy(InetAddress::getHostAddress) + } + + private fun handleClient(socket: Socket) { + val input = DataInputStream(BufferedInputStream(socket.getInputStream())) + val output = DataOutputStream(BufferedOutputStream(socket.getOutputStream())) + val response = runCatching { + if (input.readInt() != PROTOCOL_MAGIC) error("Invalid connector request") + if (input.readUnsignedByte() != PROTOCOL_VERSION) error("Unsupported connector version") + val command = input.readUnsignedByte() + val publicKey = input.readSizedBytes(MAX_PUBLIC_KEY_BYTES) + val iv = input.readSizedBytes(MAX_IV_BYTES) + val ciphertext = input.readSizedBytes(MAX_CIPHERTEXT_BYTES) + when (command) { + COMMAND_PAIR -> handlePair(publicKey, iv, ciphertext) + COMMAND_LAUNCH -> handleLaunch(publicKey, iv, ciphertext) + COMMAND_SIGN_IN -> handleSignIn(publicKey, iv, ciphertext) + COMMAND_REMOTE -> handleRemote(publicKey, iv, ciphertext) + else -> STATUS_BAD_REQUEST to "Unknown connector command" + } + }.getOrElse { error -> STATUS_BAD_REQUEST to (error.message ?: "Invalid connector request") } + output.writeInt(response.first) + output.writeUTF(response.second.take(240)) + output.flush() + } + + @Synchronized + private fun handlePair(clientPublicKey: ByteArray, iv: ByteArray, ciphertext: ByteArray): Pair { + val keyPair = hostKeyPair ?: return STATUS_UNAVAILABLE to "TV pairing is no longer active" + val expectedCode = pairingCode ?: return STATUS_UNAVAILABLE to "TV pairing is no longer active" + if (System.currentTimeMillis() > pairingExpiresAtMs) return STATUS_FORBIDDEN to "Pairing QR expired; create a new one" + if (pairingAttempts >= MAX_PAIRING_ATTEMPTS) return STATUS_FORBIDDEN to "Too many pairing attempts; create a new QR" + pairingAttempts += 1 + val shared = deriveAesKey(keyPair, clientPublicKey) + val lines = decrypt(shared, iv, ciphertext).lines() + if (!MessageDigest.isEqual(lines.firstOrNull().orEmpty().toByteArray(), expectedCode.toByteArray())) { + return STATUS_FORBIDDEN to "Pairing code did not match" + } + pairedClientPublicKey = clientPublicKey.copyOf() + pairedSharedKey = shared + pairingCode = null + val deviceName = lines.getOrNull(1)?.take(80)?.ifBlank { "Android phone" } ?: "Android phone" + val trustRequested = lines.getOrNull(2) == "1" + _state.value = _state.value.copy( + pairedDeviceName = deviceName, + pairedDeviceTrusted = false, + trustRequestedByDevice = trustRequested, + busy = false, + error = null, + message = if (trustRequested) "$deviceName requested trusted remote access" else "$deviceName paired for game launching", + ) + return STATUS_OK to safeDeviceName() + } + + private fun handleLaunch(clientPublicKey: ByteArray, iv: ByteArray, ciphertext: ByteArray): Pair { + val expectedClient = pairedClientPublicKey ?: return STATUS_FORBIDDEN to "Pair the phone again" + val shared = pairedSharedKey ?: return STATUS_FORBIDDEN to "Pair the phone again" + if (!MessageDigest.isEqual(clientPublicKey, expectedClient)) return STATUS_FORBIDDEN to "Phone is not paired" + val lines = decrypt(shared, iv, ciphertext).lines() + val timestamp = lines.getOrNull(0)?.toLongOrNull() ?: return STATUS_BAD_REQUEST to "Launch has no timestamp" + if (kotlin.math.abs(System.currentTimeMillis() - timestamp) > LAUNCH_MAX_AGE_MS) { + return STATUS_FORBIDDEN to "Launch request expired" + } + val requestId = lines.getOrNull(1).orEmpty() + if (requestId.isBlank() || !recentRequestIds.add(requestId)) return STATUS_FORBIDDEN to "Launch request was already used" + synchronized(recentRequestIds) { + while (recentRequestIds.size > MAX_RECENT_REQUEST_IDS) { + recentRequestIds.iterator().run { next(); remove() } + } + } + val gameId = lines.getOrNull(2)?.takeIf { it.isNotBlank() } ?: return STATUS_BAD_REQUEST to "Launch has no game" + _launchRequests.tryEmit(LocalTvLaunchRequest(gameId = gameId, title = lines.getOrNull(3)?.takeIf { it.isNotBlank() })) + return STATUS_OK to "Launch sent" + } + + private fun handleSignIn(clientPublicKey: ByteArray, iv: ByteArray, ciphertext: ByteArray): Pair { + val expectedClient = pairedClientPublicKey ?: return STATUS_FORBIDDEN to "Pair the phone again" + val shared = pairedSharedKey ?: return STATUS_FORBIDDEN to "Pair the phone again" + if (!MessageDigest.isEqual(clientPublicKey, expectedClient)) return STATUS_FORBIDDEN to "Phone is not paired" + if (!_state.value.pairedDeviceTrusted) return STATUS_FORBIDDEN to "Trust this phone on the TV before switching accounts" + val plaintext = decrypt(shared, iv, ciphertext) + val firstBreak = plaintext.indexOf('\n') + val secondBreak = plaintext.indexOf('\n', firstBreak + 1) + if (firstBreak <= 0 || secondBreak <= firstBreak) return STATUS_BAD_REQUEST to "Sign-in request is malformed" + val timestamp = plaintext.substring(0, firstBreak).toLongOrNull() ?: return STATUS_BAD_REQUEST to "Sign-in has no timestamp" + if (kotlin.math.abs(System.currentTimeMillis() - timestamp) > SIGN_IN_MAX_AGE_MS) { + return STATUS_FORBIDDEN to "Sign-in request expired" + } + val requestId = plaintext.substring(firstBreak + 1, secondBreak) + if (requestId.isBlank() || !recentRequestIds.add(requestId)) return STATUS_FORBIDDEN to "Sign-in request was already used" + val session = runCatching { OpenNowJson.decodeFromString(plaintext.substring(secondBreak + 1)) } + .getOrElse { return STATUS_BAD_REQUEST to "Sign-in data could not be read" } + if (session.tokens.accessToken.isBlank() || session.user.userId.isBlank() || session.provider.code.isBlank()) { + return STATUS_BAD_REQUEST to "Sign-in data is incomplete" + } + _signInRequests.tryEmit(session) + return STATUS_OK to "Sign-in received" + } + + private fun handleRemote(clientPublicKey: ByteArray, iv: ByteArray, ciphertext: ByteArray): Pair { + val expectedClient = pairedClientPublicKey ?: return STATUS_FORBIDDEN to "Pair the phone again" + val shared = pairedSharedKey ?: return STATUS_FORBIDDEN to "Pair the phone again" + if (!MessageDigest.isEqual(clientPublicKey, expectedClient)) return STATUS_FORBIDDEN to "Phone is not paired" + if (!_state.value.pairedDeviceTrusted) return STATUS_FORBIDDEN to "Trust this phone on the TV to use remote controls" + val lines = decrypt(shared, iv, ciphertext).lines() + val timestamp = lines.getOrNull(0)?.toLongOrNull() ?: return STATUS_BAD_REQUEST to "Remote command has no timestamp" + if (kotlin.math.abs(System.currentTimeMillis() - timestamp) > REMOTE_MAX_AGE_MS) { + return STATUS_FORBIDDEN to "Remote command expired" + } + val requestId = lines.getOrNull(1).orEmpty() + if (!rememberRequestId(requestId)) return STATUS_FORBIDDEN to "Remote command was already used" + val action = lines.getOrNull(2)?.takeIf { it.matches(Regex("[a-z0-9_]{1,48}")) } + ?: return STATUS_BAD_REQUEST to "Remote command is invalid" + _remoteRequests.tryEmit(LocalTvRemoteRequest(action, lines.getOrNull(3)?.takeIf(String::isNotBlank))) + return STATUS_OK to "TV command sent" + } + + private fun rememberRequestId(requestId: String): Boolean { + if (requestId.isBlank() || !recentRequestIds.add(requestId)) return false + synchronized(recentRequestIds) { + while (recentRequestIds.size > MAX_RECENT_REQUEST_IDS) { + recentRequestIds.iterator().run { next(); remove() } + } + } + return true + } + + private fun sendFrame( + host: String, + port: Int, + command: Int, + publicKey: ByteArray, + iv: ByteArray, + ciphertext: ByteArray, + ): Pair = Socket().use { socket -> + socket.connect(java.net.InetSocketAddress(host, port), SOCKET_TIMEOUT_MS) + socket.soTimeout = SOCKET_TIMEOUT_MS + val output = DataOutputStream(BufferedOutputStream(socket.getOutputStream())) + output.writeInt(PROTOCOL_MAGIC) + output.writeByte(PROTOCOL_VERSION) + output.writeByte(command) + output.writeSizedBytes(publicKey) + output.writeSizedBytes(iv) + output.writeSizedBytes(ciphertext) + output.flush() + val input = DataInputStream(BufferedInputStream(socket.getInputStream())) + input.readInt() to input.readUTF() + } + + private fun closeHost() { + runCatching { serverSocket?.close() } + runCatching { discoveryResponderSocket?.close() } + serverSocket = null + discoveryResponderSocket = null + hostKeyPair = null + pairingCode = null + pairingExpiresAtMs = 0L + pairingAttempts = 0 + pairedClientPublicKey = null + pairedSharedKey = null + recentRequestIds.clear() + } + + fun close() { + closeHost() + phoneDiscoveryGeneration += 1L + runCatching { phoneDiscoverySocket?.close() } + phoneDiscoverySocket = null + scope.cancel() + } + + private data class PhoneTarget( + val host: String, + val port: Int, + val phoneKeys: KeyPair, + val sharedKey: ByteArray, + ) + + private data class EncryptedPayload(val iv: ByteArray, val ciphertext: ByteArray) + + private fun encrypt(key: ByteArray, plaintext: String): EncryptedPayload { + val iv = ByteArray(12).also(random::nextBytes) + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(128, iv)) + return EncryptedPayload(iv, cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8))) + } + + private fun decrypt(key: ByteArray, iv: ByteArray, ciphertext: ByteArray): String { + require(iv.size == 12) { "Invalid encrypted request" } + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(128, iv)) + return cipher.doFinal(ciphertext).toString(Charsets.UTF_8) + } + + private fun generateEcKeyPair(): KeyPair = KeyPairGenerator.getInstance("EC").run { + initialize(ECGenParameterSpec("secp256r1"), random) + generateKeyPair() + } + + private fun decodePublicKey(encoded: String) = + KeyFactory.getInstance("EC").generatePublic(X509EncodedKeySpec(base64UrlDecode(encoded))) + + private fun deriveAesKey(ownKeys: KeyPair, peerPublicBytes: ByteArray): ByteArray { + val peer = KeyFactory.getInstance("EC").generatePublic(X509EncodedKeySpec(peerPublicBytes)) + val sharedSecret = KeyAgreement.getInstance("ECDH").run { + init(ownKeys.private) + doPhase(peer, true) + generateSecret() + } + val salt = "OpenNOW-local-tv-v1".toByteArray() + val prk = Mac.getInstance("HmacSHA256").run { + init(SecretKeySpec(salt, "HmacSHA256")) + doFinal(sharedSecret) + } + return Mac.getInstance("HmacSHA256").run { + init(SecretKeySpec(prk, "HmacSHA256")) + doFinal("launch-key\u0001".toByteArray()).copyOf(32) + } + } + + private fun privateLanAddress(): Inet4Address? = + NetworkInterface.getNetworkInterfaces()?.toList().orEmpty() + .asSequence() + .filter { it.isUp && !it.isLoopback } + .flatMap { it.inetAddresses.toList().asSequence() } + .filterIsInstance() + .firstOrNull { it.isSiteLocalAddress && !it.isLoopbackAddress } + + private fun isSamePrivateLan(remote: InetAddress, local: InetAddress): Boolean { + if (remote.isLoopbackAddress && local.isLoopbackAddress) return true + val remote4 = remote as? Inet4Address ?: return false + val local4 = local as? Inet4Address ?: return false + if (!remote4.isSiteLocalAddress || !local4.isSiteLocalAddress) return false + val remoteBytes = remote4.address + val localBytes = local4.address + return remoteBytes[0] == localBytes[0] && remoteBytes[1] == localBytes[1] + } + + private fun isPrivateIpv4Text(value: String): Boolean = + runCatching { InetAddress.getByName(value) as? Inet4Address } + .getOrNull() + ?.isSiteLocalAddress == true + + private fun safeDeviceName(): String = + listOf(Build.MANUFACTURER, Build.MODEL) + .map(String::trim) + .filter(String::isNotBlank) + .distinct() + .joinToString(" ") + .take(80) + .ifBlank { "OpenNOW Android" } + + private fun base64Url(bytes: ByteArray): String = + Base64.encodeToString(bytes, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING) + + private fun base64UrlDecode(value: String): ByteArray = + Base64.decode(value, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING) + + private fun DataOutputStream.writeSizedBytes(bytes: ByteArray) { + writeInt(bytes.size) + write(bytes) + } + + private fun DataInputStream.readSizedBytes(maxBytes: Int): ByteArray { + val size = readInt() + require(size in 1..maxBytes) { "Connector request is too large" } + return ByteArray(size).also(::readFully) + } + + private fun java.util.Enumeration.toList(): List = Collections.list(this) + + private companion object { + const val PROTOCOL_MAGIC = 0x4f4e5456 + const val PROTOCOL_VERSION = 1 + const val COMMAND_PAIR = 1 + const val COMMAND_LAUNCH = 2 + const val COMMAND_SIGN_IN = 3 + const val COMMAND_REMOTE = 4 + const val STATUS_OK = 200 + const val STATUS_BAD_REQUEST = 400 + const val STATUS_FORBIDDEN = 403 + const val STATUS_UNAVAILABLE = 503 + const val SOCKET_TIMEOUT_MS = 5_000 + const val LAUNCH_MAX_AGE_MS = 60_000L + const val SIGN_IN_MAX_AGE_MS = 60_000L + const val REMOTE_MAX_AGE_MS = 60_000L + const val PAIRING_LIFETIME_MS = 5L * 60L * 1000L + const val MAX_PAIRING_ATTEMPTS = 5 + const val MAX_RECENT_REQUEST_IDS = 64 + const val MAX_PUBLIC_KEY_BYTES = 512 + const val MAX_IV_BYTES = 32 + const val MAX_CIPHERTEXT_BYTES = 65_536 + const val DISCOVERY_PORT = 39_047 + const val DISCOVERY_REQUEST = "OPENNOW_TV_DISCOVERY_V1" + const val DISCOVERY_RESPONSE = "OPENNOW_TV_RESPONSE_V1" + const val DISCOVERY_WINDOW_MS = 1_800L + const val DISCOVERY_POLL_TIMEOUT_MS = 180 + const val MAX_DISCOVERY_PACKET_BYTES = 2_048 + } +} + +internal fun isLocalTvPairUri(uri: Uri?): Boolean = + uri?.scheme.equals("opennow", ignoreCase = true) && uri?.host.equals("pair", ignoreCase = true) + +internal fun normalizeLocalTvPairingCode(value: String): String? = + value.trim().takeIf { it.matches(Regex("[0-9]{4}")) } diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/LowLatencyVideoDecoder.kt b/android/app/src/main/java/com/opencloudgaming/opennow/LowLatencyVideoDecoder.kt new file mode 100644 index 000000000..f4572ebd0 --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/LowLatencyVideoDecoder.kt @@ -0,0 +1,468 @@ +package com.opencloudgaming.opennow + +import android.media.MediaFormat +import android.os.Build +import android.util.Log +import org.webrtc.EncodedImage +import org.webrtc.VideoCodecStatus +import org.webrtc.VideoDecoder +import java.lang.reflect.Field +import java.lang.reflect.InvocationHandler +import java.lang.reflect.InvocationTargetException +import java.lang.reflect.Method +import java.lang.reflect.Proxy +import java.util.Locale + +class LowLatencyVideoDecoder( + private val delegate: VideoDecoder, + private val requestedFps: Int, + private val lowLatencyEnabled: Boolean, + private val standardLowLatencyEnabled: Boolean = false, +) : VideoDecoder { + + private var patched = false + + override fun initDecode(settings: VideoDecoder.Settings?, decodeCallback: VideoDecoder.Callback?): VideoCodecStatus { + NativeInputDiagnostics.add( + "MediaCodecVideoDecoder initDecode delegate=${delegate.javaClass.name} " + + "requestedFps=$requestedFps lowLatency=$lowLatencyEnabled " + + "standardLowLatency=$standardLowLatencyEnabled", + ) + patchMediaCodecWrapperFactory() + return delegate.initDecode(settings, decodeCallback) + } + + override fun release(): VideoCodecStatus { + return delegate.release() + } + + override fun decode(frame: EncodedImage?, info: VideoDecoder.DecodeInfo?): VideoCodecStatus { + return delegate.decode(frame, info) + } + + override fun getImplementationName(): String { + val suffix = when { + lowLatencyEnabled -> "low-latency" + standardLowLatencyEnabled -> "platform-low-latency" + else -> "performance" + } + return delegate.implementationName + "+opennow-$suffix" + } + + private fun patchMediaCodecWrapperFactory() { + if (patched) { + return + } + patched = true + + try { + val factoryField = findMediaCodecWrapperFactoryField(delegate.javaClass) + if (factoryField == null) { + val msg = "MediaCodecWrapperFactory field not found on ${delegate.javaClass.name}" + Log.w(TAG, msg) + NativeInputDiagnostics.add("LowLatencyVideoDecoder: $msg") + return + } + + factoryField.isAccessible = true + val originalFactory = factoryField.get(delegate) + if (originalFactory == null) { + val msg = "MediaCodecWrapperFactory is null on ${delegate.javaClass.name}" + Log.w(TAG, msg) + NativeInputDiagnostics.add("LowLatencyVideoDecoder: $msg") + return + } + + val factoryInterface = factoryField.type + val proxyFactory = Proxy.newProxyInstance( + factoryInterface.classLoader, + arrayOf(factoryInterface), + MediaCodecWrapperFactoryHandler( + delegateFactory = originalFactory, + requestedFps = requestedFps, + lowLatencyEnabled = lowLatencyEnabled, + standardLowLatencyEnabled = standardLowLatencyEnabled, + ) + ) + factoryField.set(delegate, proxyFactory) + val msg = "Successfully patched MediaCodecWrapperFactory on ${delegate.javaClass.name}" + Log.i(TAG, msg) + NativeInputDiagnostics.add("LowLatencyVideoDecoder: $msg") + } catch (tr: Throwable) { + val msg = "Failed to install low latency MediaCodec wrapper: ${tr.message}" + Log.w(TAG, msg, tr) + NativeInputDiagnostics.add("LowLatencyVideoDecoder: $msg") + } + } + + private fun findMediaCodecWrapperFactoryField(clazz: Class<*>?): Field? { + var current = clazz + while (current != null) { + for (field in current.declaredFields) { + if ("org.webrtc.MediaCodecWrapperFactory" == field.type.name || + field.name.lowercase(Locale.US).contains("mediacodecwrapperfactory") + ) { + return field + } + } + current = current.superclass + } + return null + } + + private class MediaCodecWrapperFactoryHandler( + private val delegateFactory: Any, + private val requestedFps: Int, + private val lowLatencyEnabled: Boolean, + private val standardLowLatencyEnabled: Boolean, + ) : InvocationHandler { + override fun invoke(proxy: Any?, method: Method, args: Array?): Any? { + val originalCodecName = if ("createByCodecName" == method.name && args != null && args.isNotEmpty() && args[0] is String) { + args[0] as String + } else { + "" + } + + val modifiedCodecName = if (lowLatencyEnabled && originalCodecName.isNotEmpty()) { + getLowLatencyCodecNameIfApplicable(originalCodecName) + } else { + originalCodecName + } + + val finalArgs = if (modifiedCodecName != originalCodecName && args != null) { + Array(args.size) { i -> + if (i == 0) modifiedCodecName else args[i] + } + } else { + args + } + + val result = invokeDelegate(delegateFactory, method, finalArgs) + if ("createByCodecName" != method.name || result == null) { + return result + } + + val codecName = modifiedCodecName + NativeInputDiagnostics.add("LowLatencyVideoDecoder: createByCodecName called for codecName=$codecName") + + var codecWrapperInterface: Class<*>? = if (result.javaClass.interfaces.isNotEmpty()) { + result.javaClass.interfaces[0] + } else { + null + } + + if (codecWrapperInterface == null || "org.webrtc.MediaCodecWrapper" != codecWrapperInterface.name) { + codecWrapperInterface = findInterface(result.javaClass, "org.webrtc.MediaCodecWrapper") + } + + if (codecWrapperInterface == null) { + NativeInputDiagnostics.add("LowLatencyVideoDecoder: MediaCodecWrapper interface not found on ${result.javaClass.name}") + return result + } + + NativeInputDiagnostics.add("LowLatencyVideoDecoder: Successfully wrapping MediaCodecWrapper of class ${result.javaClass.name}") + return Proxy.newProxyInstance( + codecWrapperInterface.classLoader, + arrayOf(codecWrapperInterface), + MediaCodecWrapperHandler( + delegateCodec = result, + codecName = codecName, + requestedFps = requestedFps, + lowLatencyEnabled = lowLatencyEnabled, + standardLowLatencyEnabled = standardLowLatencyEnabled, + ) + ) + } + } + + private class MediaCodecWrapperHandler( + private val delegateCodec: Any, + private val codecName: String, + private val requestedFps: Int, + private val lowLatencyEnabled: Boolean, + private val standardLowLatencyEnabled: Boolean, + ) : InvocationHandler { + override fun invoke(proxy: Any?, method: Method, args: Array?): Any? { + if ("configure" == method.name && args != null && args.isNotEmpty() && args[0] is MediaFormat) { + val format = args[0] as MediaFormat + NativeInputDiagnostics.add( + "MediaCodecVideoDecoder: configure codec=$codecName requestedFps=$requestedFps " + + "lowLatency=$lowLatencyEnabled standardLowLatency=$standardLowLatencyEnabled before=$format", + ) + applyDecoderPerformanceFormat( + format = format, + requestedFps = requestedFps, + lowLatencyEnabled = lowLatencyEnabled, + standardLowLatencyEnabled = standardLowLatencyEnabled, + ) + if (lowLatencyEnabled) applyLowLatencyFormat(format, codecName) + NativeInputDiagnostics.add("MediaCodecVideoDecoder: configured format=$format") + } + val result = invokeDelegate(delegateCodec, method, args) + if ("start" == method.name && (lowLatencyEnabled || standardLowLatencyEnabled)) { + NativeInputDiagnostics.add("LowLatencyVideoDecoder: Intercepted start() for codec=$codecName") + applyLowLatencyParameters( + delegateCodec = delegateCodec, + standardLowLatencyEnabled = standardLowLatencyEnabled || lowLatencyEnabled, + vendorLowLatencyEnabled = lowLatencyEnabled, + ) + } + return result + } + } + + companion object { + private const val TAG = "LowLatencyDecoder" + private const val OPERATING_RATE = 0x7FFF + + private fun applyDecoderPerformanceFormat( + format: MediaFormat, + requestedFps: Int, + lowLatencyEnabled: Boolean, + standardLowLatencyEnabled: Boolean, + ) { + val exactTargetFps = mediaCodecPerformanceTargetFps(requestedFps) + if (exactTargetFps != null) { + putInt(format, MediaFormat.KEY_FRAME_RATE, exactTargetFps) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && (exactTargetFps != null || lowLatencyEnabled)) { + putInt(format, MediaFormat.KEY_PRIORITY, 0) + putInt( + format, + MediaFormat.KEY_OPERATING_RATE, + if (lowLatencyEnabled) OPERATING_RATE else exactTargetFps!!, + ) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && standardLowLatencyEnabled) { + putInt(format, MediaFormat.KEY_LOW_LATENCY, 1) + } + } + + private fun findInterface(clazz: Class<*>?, interfaceName: String): Class<*>? { + var current = clazz + while (current != null) { + for (item in current.interfaces) { + if (interfaceName == item.name) { + return item + } + } + current = current.superclass + } + return null + } + + private fun invokeDelegate(target: Any, method: Method, args: Array?): Any? { + return try { + method.isAccessible = true + if (args == null) { + method.invoke(target) + } else { + method.invoke(target, *args) + } + } catch (ex: InvocationTargetException) { + throw ex.cause ?: ex + } catch (ex: SecurityException) { + if (args == null) { + method.invoke(target) + } else { + method.invoke(target, *args) + } + } + } + + private fun applyLowLatencyFormat(format: MediaFormat, codecName: String) { + putInt(format, "low-latency", 1) + + val normalizedCodecName = codecName.lowercase(Locale.US) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + putInt(format, "priority", 0) + // Use Short.MAX_VALUE (0x7FFF) for non-Snapdragon decoders; for Qualcomm, + // forcing 32767 fps operating rate forces Adreno GPU/VPU clocks to maximum state, + // causing extreme power drain and overheating. Use 120 (or target FPS) instead. + val operatingRate = if (isQualcommDecoder(normalizedCodecName)) 120 else OPERATING_RATE + putInt(format, "operating-rate", operatingRate) + } + putInt(format, "allow-frame-drop", 1) + putInt(format, "vdec-lowlatency", 1) + putInt(format, "vendor.low-latency.enable", 1) + + if (isQualcommDecoder(normalizedCodecName)) { + putInt(format, "vendor.qti-ext-dec-picture-order.enable", 1) + putInt(format, "vendor.qti-ext-dec-low-latency.enable", 1) + putInt(format, "vendor.rtc-ext-dec-low-latency.enable", 1) + } + + if (isHiSiliconDecoder(normalizedCodecName)) { + putInt(format, "vendor.hisi-ext-low-latency-video-dec.video-scene-for-low-latency-req", 1) + putInt(format, "vendor.hisi-ext-low-latency-video-dec.video-scene-for-low-latency-rdy", -1) + } + + if (isMediaTekDecoder(normalizedCodecName)) { + putInt(format, "vendor.mtk-dec-low-latency", 1) + putInt(format, "vendor.mtk-dec-lowlatency", 1) + putInt(format, "vendor.mtk-ext-dec-low-latency.enable", 1) + putInt(format, "vendor.mtk-ext-dec-lowlatency.enable", 1) + putInt(format, "vendor.mtk-vdec-lowlatency", 1) + putInt(format, "vendor.mtk-vdec-low-latency", 1) + putInt(format, "vendor.mtk.vdec.lowlatency", 1) + putInt(format, "vendor.mtk.vdec.low-latency", 1) + putInt(format, "vendor.mtk.dec.lowlatency", 1) + putInt(format, "vendor.mtk.dec.low-latency", 1) + putInt(format, "vendor.mtk.ext.dec.lowlatency.enable", 1) + } + + Log.i(TAG, "Applied low latency decoder format for codec=$codecName") + } + + private fun isQualcommDecoder(codecName: String): Boolean { + return isQualcommMediaCodecDecoder(codecName) + } + + private fun isHiSiliconDecoder(codecName: String): Boolean { + val hardware = (Build.HARDWARE ?: "").lowercase(Locale.US) + val board = (Build.BOARD ?: "").lowercase(Locale.US) + val manufacturer = (Build.MANUFACTURER ?: "").lowercase(Locale.US) + return codecName.contains("hisi") || + codecName.contains("kirin") || + hardware.contains("hisi") || + hardware.contains("kirin") || + board.contains("hisi") || + board.contains("kirin") || + manufacturer.contains("huawei") + } + + private fun isMediaTekDecoder(codecName: String): Boolean { + val hardware = (Build.HARDWARE ?: "").lowercase(Locale.US) + val board = (Build.BOARD ?: "").lowercase(Locale.US) + val manufacturer = (Build.MANUFACTURER ?: "").lowercase(Locale.US) + return codecName.contains("mtk") || + codecName.contains("mediatek") || + hardware.contains("mtk") || + hardware.contains("mediatek") || + board.contains("mtk") || + board.contains("mediatek") || + manufacturer.contains("mediatek") + } + + private fun getLowLatencyCodecNameIfApplicable(codecName: String): String { + val normalized = codecName.lowercase(Locale.US) + if (normalized.startsWith("c2.mtk.") && normalized.endsWith(".decoder")) { + val lowLatencyName = "$codecName.lowlatency" + if (isCodecSupported(lowLatencyName)) { + Log.i(TAG, "LowLatencyVideoDecoder: Found MediaTek low latency variant: $lowLatencyName") + return lowLatencyName + } + } + return codecName + } + + private fun isCodecSupported(name: String): Boolean { + try { + val list = android.media.MediaCodecList(android.media.MediaCodecList.ALL_CODECS) + for (info in list.codecInfos) { + if (info.name.equals(name, ignoreCase = true)) { + return true + } + } + } catch (tr: Throwable) { + Log.w(TAG, "Failed to check if codec is supported", tr) + } + return false + } + + private fun putInt(format: MediaFormat, key: String, value: Int) { + try { + format.setInteger(key, value) + } catch (tr: Throwable) { + Log.w(TAG, "Failed to set MediaFormat key $key", tr) + } + } + + private fun applyLowLatencyParameters( + delegateCodec: Any, + standardLowLatencyEnabled: Boolean, + vendorLowLatencyEnabled: Boolean, + ) { + try { + val field = findMediaCodecField(delegateCodec.javaClass) ?: return + field.isAccessible = true + val mediaCodec = field.get(delegateCodec) as? android.media.MediaCodec ?: return + + val bundle = android.os.Bundle() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && standardLowLatencyEnabled) { + bundle.putInt(android.media.MediaCodec.PARAMETER_KEY_LOW_LATENCY, 1) + } + if (vendorLowLatencyEnabled) { + bundle.putInt("vendor.mtk-dec-low-latency", 1) + bundle.putInt("vendor.mtk-dec-lowlatency", 1) + bundle.putInt("vendor.mtk-ext-dec-low-latency.enable", 1) + bundle.putInt("vendor.mtk-ext-dec-lowlatency.enable", 1) + bundle.putInt("vendor.mtk-vdec-lowlatency", 1) + bundle.putInt("vendor.mtk-vdec-low-latency", 1) + bundle.putInt("vendor.mtk.vdec.lowlatency", 1) + bundle.putInt("vendor.mtk.vdec.low-latency", 1) + bundle.putInt("vendor.mtk.dec.lowlatency", 1) + bundle.putInt("vendor.mtk.dec.low-latency", 1) + bundle.putInt("vendor.mtk.ext.dec.lowlatency.enable", 1) + } + + mediaCodec.setParameters(bundle) + Log.i(TAG, "LowLatencyVideoDecoder: Successfully set MediaCodec parameters: $bundle") + NativeInputDiagnostics.add("LowLatencyVideoDecoder: Successfully set MediaCodec parameters: $bundle") + } catch (tr: Throwable) { + Log.w(TAG, "Failed to apply dynamic MediaCodec parameters", tr) + NativeInputDiagnostics.add("LowLatencyVideoDecoder: Failed to apply dynamic MediaCodec parameters: ${tr.message}") + } + } + + private fun findMediaCodecField(clazz: Class<*>?): Field? { + var current = clazz + while (current != null) { + for (field in current.declaredFields) { + if (field.type == android.media.MediaCodec::class.java) { + return field + } + } + current = current.superclass + } + return null + } + } +} + +internal fun mediaCodecPerformanceTargetFps(requestedFps: Int): Int? = + requestedFps.takeIf { it >= 60 } + +internal fun isQualcommMediaCodecDecoder(codecName: String?): Boolean { + val normalized = codecName?.lowercase(Locale.US).orEmpty() + return normalized.contains("qcom") || normalized.contains("qti") +} + +internal fun shouldBypassMediaCodecPerformanceTuning( + codec: VideoCodec?, + decoderImplementationName: String?, + requestedFps: Int, + lowLatencyEnabled: Boolean, +): Boolean = + !lowLatencyEnabled && + codec == VideoCodec.H264 && + requestedFps == 60 && + isQualcommMediaCodecDecoder(decoderImplementationName) + +internal fun shouldUseMediaCodecDecoderTuning( + selectedDecoder: VideoDecoder?, + approvedHardwareDecoder: VideoDecoder?, + requestedFps: Int, + lowLatencyEnabled: Boolean, + codec: VideoCodec? = null, + decoderImplementationName: String? = null, +): Boolean = + selectedDecoder != null && + selectedDecoder === approvedHardwareDecoder && + (lowLatencyEnabled || mediaCodecPerformanceTargetFps(requestedFps) != null) && + !shouldBypassMediaCodecPerformanceTuning( + codec = codec, + decoderImplementationName = decoderImplementationName, + requestedFps = requestedFps, + lowLatencyEnabled = lowLatencyEnabled, + ) diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/MainActivity.kt b/android/app/src/main/java/com/opencloudgaming/opennow/MainActivity.kt new file mode 100644 index 000000000..e9102c57c --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/MainActivity.kt @@ -0,0 +1,832 @@ +package com.opencloudgaming.opennow + +import android.Manifest +import android.app.PictureInPictureParams +import android.content.Context +import android.content.Intent +import android.content.pm.ActivityInfo +import android.content.pm.PackageManager +import android.content.res.Configuration +import android.media.AudioManager +import android.os.Build +import android.os.Bundle +import android.os.SystemClock +import android.util.Log +import androidx.annotation.RequiresApi +import android.util.Rational +import android.view.Display +import android.view.InputDevice +import android.view.KeyCharacterMap +import android.view.KeyEvent +import android.view.MotionEvent +import android.view.PointerIcon +import android.view.View +import android.view.ViewGroup +import android.view.WindowInsets +import android.view.WindowInsetsController +import android.view.WindowManager +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.viewModels +import androidx.core.view.WindowCompat +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +class MainActivity : ComponentActivity() { + private val viewModel: OpenNowViewModel by viewModels() + private val queueStatusNotifier by lazy { AndroidQueueStatusNotifier(this) } + private val streamKeepAliveNotifier by lazy { AndroidStreamKeepAliveNotifier(this) } + private var notificationPermissionRequested = false + private var lastHatXKeyCode: Int? = null + private var lastHatYKeyCode: Int? = null + private var streamSystemUiActive = false + private var streamDisplayRefreshActive = false + private var streamDisplayRefreshFps = 60 + private var streamSystemUiEnforcerJob: Job? = null + private var lastStreamSystemUiInputReapplyMs = 0L + private var externalMousePointerCaptureRequestPending = false + private var defaultRequestedOrientation = ActivityInfo.SCREEN_ORIENTATION_FULL_SENSOR + private var phoneStreamOrientationLocked = false + private var streamPictureInPictureReady = false + private var streamPictureInPictureAspectRatio = Rational(16, 9) + private var startupDataReady = false + private var pendingExternalLaunchIntent: Intent? = null + private var pendingLocalNetworkIntent: Intent? = null + private val localNetworkPermissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { granted -> + val pendingIntent = pendingLocalNetworkIntent + pendingLocalNetworkIntent = null + if (granted && startupDataReady && pendingIntent != null) { + viewModel.handleExternalLaunchIntent(pendingIntent) + } + } + + override fun attachBaseContext(newBase: Context) { + super.attachBaseContext(localizedAndroidContext(newBase)) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + defaultRequestedOrientation = requestedOrientation + volumeControlStream = AudioManager.STREAM_MUSIC + val openNowApplication = application as OpenNowApplication + pendingExternalLaunchIntent = intent + setContent { + var ready by remember { mutableStateOf(false) } + LaunchedEffect(openNowApplication) { + openNowApplication.awaitStartupData() + ready = true + } + if (ready) { + OpenNowApp( + viewModel = viewModel, + onMicrophoneCaptureActiveChange = streamKeepAliveNotifier::setMicrophoneCaptureActive, + ) + } else { + Box( + modifier = Modifier.fillMaxSize().background(Color(0xFF05070B)), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator(color = Color(0xFF69E6FF)) + } + } + } + lifecycleScope.launch { + openNowApplication.awaitStartupData() + startupDataReady = true + viewModel.setAndroidPictureInPictureActive(isAndroidPictureInPictureActive()) + pendingExternalLaunchIntent?.let(::handleExternalLaunchIntent) + pendingExternalLaunchIntent = null + viewModel.state.collect { state -> + requestQueueNotificationPermissionIfNeeded(state) + queueStatusNotifier.update(state) + streamKeepAliveNotifier.update(state) + val streamActive = state.page == AppPage.Stream && state.streamStatus != "idle" + applyPhoneStreamOrientationLock( + shouldLockPhoneStreamLandscape(state, resources.configuration.smallestScreenWidthDp), + ) + updateStreamPictureInPicture( + ready = state.page == AppPage.Stream && + state.streamStatus == "streaming" && + state.streamSession?.isReadyForStream() == true, + settings = state.activeStreamSettings ?: state.settings.stream, + ) + applyStreamSystemUi(streamActive) + applyStreamDisplayRefreshRate(streamActive, state.activeStreamSettings?.fps ?: state.settings.stream.fps) + } + } + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + if (startupDataReady) { + handleExternalLaunchIntent(intent) + } else { + pendingExternalLaunchIntent = intent + } + } + + private fun handleExternalLaunchIntent(intent: Intent) { + if (isLocalTvPairUri(intent.data) && !hasAndroidLocalNetworkAccess()) { + pendingLocalNetworkIntent = intent + localNetworkPermissionLauncher.launch(Manifest.permission.ACCESS_LOCAL_NETWORK) + return + } + viewModel.handleExternalLaunchIntent(intent) + } + + override fun onResume() { + super.onResume() + if (startupDataReady) { + viewModel.setAndroidPictureInPictureActive(isAndroidPictureInPictureActive()) + // A process that was frozen or killed by an aggressive OEM memory manager comes back + // with dead sockets and possibly stale tokens. The catalogue is fetched once at + // startup and never again, so without this the Store stays empty until a manual pull. + viewModel.onAppForegrounded() + } + if (streamSystemUiActive) { + applyStreamSystemUi(true, force = true) + applyStreamDisplayRefreshRate(streamDisplayRefreshActive, streamDisplayRefreshFps, force = true) + } + if (phoneStreamOrientationLocked) { + applyPhoneStreamOrientationLock(true, force = true) + } + } + + override fun dispatchKeyEvent(event: KeyEvent): Boolean { + if (streamSystemUiActive && event.action == KeyEvent.ACTION_DOWN && event.shouldReapplyStreamSystemUi()) { + enforceStreamSystemUiFromInput() + } + if (streamSystemUiActive && event.isAndroidVolumeKey()) { + return super.dispatchKeyEvent(event) + } + if (NativeStreamInputRouter.dispatchKey(event)) { + return true + } + val normalizedStreamUiKeyCode = NativeStreamInputRouter.normalizedStreamUiKeyCode(event) + if (normalizedStreamUiKeyCode != null && normalizedStreamUiKeyCode != event.keyCode) { + return dispatchSyntheticStreamUiKey(normalizedStreamUiKeyCode, event) + } + if (NativeStreamInputRouter.isControllerAppBackKey(event)) { + // Invoke ComponentActivity's Back dispatcher so Compose's nearest BackHandler gets + // first refusal. Synthesizing KEYCODE_BACK through the Window can finish the Activity + // without visiting the nested settings handler on some Android builds. + if (event.action == KeyEvent.ACTION_UP) { + onBackPressedDispatcher.onBackPressed() + } + return true + } + if (event.shouldVirtualizeControllerUiNavigation()) { + // Android TV keyboards reliably accept virtual D-pad events (the same shape remotes + // emit), while several IMEs ignore navigation events from a physical gamepad device. + return dispatchSyntheticStreamUiKey(event.keyCode, event) + } + val normalizedAppUiKeyCode = NativeStreamInputRouter.normalizedAppUiKeyCode(event) + if (normalizedAppUiKeyCode != null && normalizedAppUiKeyCode != event.keyCode) { + return dispatchSyntheticStreamUiKey(normalizedAppUiKeyCode, event) + } + return super.dispatchKeyEvent(event) + } + + override fun onUserLeaveHint() { + super.onUserLeaveHint() + enterStreamPictureInPictureIfReady() + } + + override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: Configuration) { + super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig) + if (startupDataReady) { + viewModel.setAndroidPictureInPictureActive(isInPictureInPictureMode) + } + NativeStreamInputRouter.releaseInputForLifecycle("picture-in-picture-changed") + } + + private fun isAndroidPictureInPictureActive(): Boolean = + Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && isInPictureInPictureMode + + override fun onStop() { + super.onStop() + // Backgrounding mid-tap gives us no UP or CANCEL, so without this the host keeps the mouse + // button held down. PiP does not stop the activity, hence the separate call above. + NativeStreamInputRouter.releaseInputForLifecycle("activity-stopped") + } + + private fun KeyEvent.isAndroidVolumeKey(): Boolean = + keyCode == KeyEvent.KEYCODE_VOLUME_UP || + keyCode == KeyEvent.KEYCODE_VOLUME_DOWN || + keyCode == KeyEvent.KEYCODE_VOLUME_MUTE + + private fun KeyEvent.shouldVirtualizeControllerUiNavigation(): Boolean { + if (!AndroidControllerInput.isControllerEvent(source, deviceId)) return false + return when (keyCode) { + KeyEvent.KEYCODE_DPAD_UP, + KeyEvent.KEYCODE_DPAD_DOWN, + KeyEvent.KEYCODE_DPAD_LEFT, + KeyEvent.KEYCODE_DPAD_RIGHT, + KeyEvent.KEYCODE_DPAD_CENTER, + KeyEvent.KEYCODE_ENTER, + KeyEvent.KEYCODE_NUMPAD_ENTER, + -> true + else -> false + } + } + + override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean { + val mouseLikePointer = event.isMouseLikePointerEvent() + if (streamSystemUiActive && (mouseLikePointer || event.isControllerMotionEvent())) { + enforceStreamSystemUiFromInput() + if (mouseLikePointer) { + requestExternalMousePointerCaptureIfNeeded(event) + } + } + return NativeStreamInputRouter.dispatchMotion(event) || + dispatchGamepadHatNavigation(event) || + super.dispatchGenericMotionEvent(event) + } + + private fun requestExternalMousePointerCaptureIfNeeded(event: MotionEvent) = + requestExternalMousePointerCaptureIfNeeded(source = event.source, deviceId = event.deviceId) + + private fun requestExternalMousePointerCaptureIfNeeded(source: Int? = null, deviceId: Int? = null) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + val decorView = window?.decorView ?: return + if ( + !shouldRequestAndroidMousePointerCapture( + streamActive = streamSystemUiActive, + captureEnabled = NativeStreamInputRouter.isExternalMousePointerCaptureEnabled(), + windowFocused = decorView.hasWindowFocus(), + hasPointerCapture = decorView.hasPointerCapture(), + mouseLikePointer = true, + ) || externalMousePointerCaptureRequestPending + ) { + return + } + externalMousePointerCaptureRequestPending = true + val requestOrigin = if (source != null && deviceId != null) { + "source=$source device=$deviceId" + } else { + "window-focus" + } + decorView.post { + externalMousePointerCaptureRequestPending = false + if ( + !shouldRequestAndroidMousePointerCapture( + streamActive = streamSystemUiActive, + captureEnabled = NativeStreamInputRouter.isExternalMousePointerCaptureEnabled(), + windowFocused = decorView.hasWindowFocus(), + hasPointerCapture = decorView.hasPointerCapture(), + mouseLikePointer = true, + ) + ) { + return@post + } + decorView.isFocusable = true + decorView.isFocusableInTouchMode = true + decorView.requestFocus() + // Compose/SurfaceView can move focus to a descendant after capture starts. Refresh the + // listener across the current tree so the focused child keeps forwarding deltas. + decorView.applyCapturedPointerListenerRecursive(streamCapturedPointerListener) + runCatching { decorView.requestPointerCapture() } + .onSuccess { + NativeInputDiagnostics.addRetained( + key = "mouse.pointer-capture", + message = "external mouse pointer capture requested origin=$requestOrigin", + ) + } + .onFailure { error -> + NativeInputDiagnostics.addRetained( + key = "mouse.pointer-capture", + message = "external mouse pointer capture request failed origin=$requestOrigin error=${error.javaClass.simpleName}", + ) + } + } + } + + override fun dispatchTouchEvent(event: MotionEvent): Boolean { + try { + val decorView = window?.decorView + if (streamSystemUiActive && event.isMouseLikePointerEvent()) { + enforceStreamSystemUiFromInput() + requestExternalMousePointerCaptureIfNeeded(event) + } + if (NativeStreamInputRouter.shouldConsumeUiTransitionTouchBeforeViews(event)) return true + if (decorView != null && NativeStreamInputRouter.dispatchExternalMouseTouch(event, decorView.width, decorView.height)) return true + if (decorView != null && NativeStreamInputRouter.shouldForwardTouchBeforeViews(event, decorView.width, decorView.height)) { + if (event.actionMasked == MotionEvent.ACTION_DOWN) { + NativeInputDiagnostics.retainTouchRoute("activity.forward-before-views") { + "activity touch forwardBeforeViews size=${decorView.width}x${decorView.height}" + } + } + val forwarded = NativeStreamInputRouter.dispatchTouch(event, decorView.width, decorView.height) + if (NativeStreamInputRouter.shouldCaptureTouchBeforeViews(event, decorView.width, decorView.height) && forwarded) { + return true + } + } + val handled = super.dispatchTouchEvent(event) + if (handled) { + if (event.actionMasked == MotionEvent.ACTION_DOWN) { + NativeInputDiagnostics.retainTouchRoute("activity.consumed-by-view") { + "activity touch consumedByView action=${event.actionMasked}" + } + } + return true + } + return if (decorView != null) { + if (event.actionMasked == MotionEvent.ACTION_DOWN) { + NativeInputDiagnostics.retainTouchRoute("activity.fallback") { + "activity touch fallback size=${decorView.width}x${decorView.height}" + } + } + NativeStreamInputRouter.dispatchTouch(event, decorView.width, decorView.height) + } else { + false + } + } finally { + NativeStreamInputRouter.postDispatchTouch(event) + } + } + + override fun onDestroy() { + if (isFinishing) { + queueStatusNotifier.cancel() + // Keep the foreground service alive long enough for onTaskRemoved() + // to end the exact cloud session. Normal in-app exits already move + // stream state to idle and cancel the service through update(). + if (!startupDataReady || !shouldKeepAndroidStreamAlive(viewModel.state.value)) { + streamKeepAliveNotifier.cancel() + } + } + super.onDestroy() + } + + override fun onWindowFocusChanged(hasFocus: Boolean) { + super.onWindowFocusChanged(hasFocus) + if (!hasFocus) { + // Pixel desktop and other freeform environments can intercept a shortcut after its + // DOWN event (for example Alt+Tab), so no matching UP reaches this window. + NativeStreamInputRouter.releaseInputForLifecycle("window-focus-lost") + } else if (streamSystemUiActive) { + applyStreamSystemUi(true, force = true) + applyStreamDisplayRefreshRate(streamDisplayRefreshActive, streamDisplayRefreshFps, force = true) + requestExternalMousePointerCaptureIfNeeded() + } + } + + override fun onPointerCaptureChanged(hasCapture: Boolean) { + super.onPointerCaptureChanged(hasCapture) + if (!streamSystemUiActive && !hasCapture) return + val decorView = window?.decorView + NativeInputDiagnostics.addRetained( + key = "mouse.pointer-capture-state", + message = "external mouse pointer capture changed granted=$hasCapture " + + "streamActive=$streamSystemUiActive " + + "enabled=${NativeStreamInputRouter.isExternalMousePointerCaptureEnabled()} " + + "windowFocused=${decorView?.hasWindowFocus() == true}", + ) + } + + fun enforceStreamSystemUiFromInput() { + if (!streamSystemUiActive) return + val now = SystemClock.uptimeMillis() + if (now - lastStreamSystemUiInputReapplyMs < STREAM_SYSTEM_UI_INPUT_REAPPLY_MS) return + lastStreamSystemUiInputReapplyMs = now + applyStreamSystemBars(active = true) + } + + private fun applyStreamSystemUi(active: Boolean, force: Boolean = false) { + if (!force && streamSystemUiActive == active) { + applyStreamKeepAwake(active) + updateStreamSystemUiEnforcer(active) + return + } + streamSystemUiActive = active + applyStreamPointerIcon(active && NativeStreamInputRouter.isExternalMousePointerCaptureEnabled()) + applyStreamKeepAwake(active) + updateStreamSystemUiEnforcer(active) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val decorView = window.decorView + decorView.applyCapturedPointerListenerRecursive( + if (active) streamCapturedPointerListener else null, + ) + if (!active) { + runCatching { decorView.releasePointerCapture() } + } + } + + applyStreamSystemBars(active) + } + + /** + * Pointer capture is delivered to the focused view. The Activity retry path focuses and asks + * the decor view for capture, so that same view must own a forwarding listener; otherwise a + * Bluetooth mouse can be captured successfully while its events never reach the stream view. + */ + private fun dispatchCapturedStreamPointer(event: MotionEvent): Boolean { + if (!shouldRouteCapturedAndroidMousePointer(streamSystemUiActive, event.isMouseLikePointerEvent())) { + return false + } + NativeInputDiagnostics.retainThrottled( + key = "mouse.captured-route", + minimumIntervalMs = 250L, + ) { + "captured mouse routed source=${event.source} device=${event.deviceId} " + + "relativeX=${event.getAxisValue(MotionEvent.AXIS_RELATIVE_X)} " + + "relativeY=${event.getAxisValue(MotionEvent.AXIS_RELATIVE_Y)}" + } + enforceStreamSystemUiFromInput() + return NativeStreamInputRouter.dispatchMotion(event) + } + + private val streamCapturedPointerListener = View.OnCapturedPointerListener { _, event -> + dispatchCapturedStreamPointer(event) + } + + /** Reapplies only immersive bars; pointer-icon traversal and window flags are state changes. */ + private fun applyStreamSystemBars(active: Boolean) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + WindowCompat.setDecorFitsSystemWindows(window, false) + window.insetsController?.let { controller -> + if (active) { + controller.systemBarsBehavior = WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + controller.hide(WindowInsets.Type.statusBars() or WindowInsets.Type.navigationBars()) + } else { + controller.show(WindowInsets.Type.statusBars() or WindowInsets.Type.navigationBars()) + } + } + } else { + @Suppress("DEPRECATION") + window.decorView.systemUiVisibility = if (active) { + View.SYSTEM_UI_FLAG_FULLSCREEN or + View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or + View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY or + View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or + View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or + View.SYSTEM_UI_FLAG_LAYOUT_STABLE + } else { + 0 + } + } + } + + private fun updateStreamPictureInPicture(ready: Boolean, settings: StreamSettings) { + val aspectRatio = pictureInPictureAspectRatioFor(settings) + val shouldUpdateParams = streamPictureInPictureReady != ready || + streamPictureInPictureAspectRatio != aspectRatio + streamPictureInPictureReady = ready + streamPictureInPictureAspectRatio = aspectRatio + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && shouldUpdateParams) { + runCatching { + setPictureInPictureParams(buildStreamPictureInPictureParams()) + }.onFailure { error -> + Log.w(MAIN_ACTIVITY_LOG_TAG, "Unable to update stream picture-in-picture params", error) + } + } + } + + private fun enterStreamPictureInPictureIfReady() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + if (!streamPictureInPictureReady || isInPictureInPictureMode) return + runCatching { + enterPictureInPictureMode(buildStreamPictureInPictureParams()) + }.onFailure { error -> + Log.w(MAIN_ACTIVITY_LOG_TAG, "Unable to enter stream picture-in-picture", error) + } + } + + // Both callers gate on SDK_INT >= O; the annotation is what lets lint see that. + @RequiresApi(Build.VERSION_CODES.O) + private fun buildStreamPictureInPictureParams(): PictureInPictureParams = + PictureInPictureParams.Builder() + .setAspectRatio(streamPictureInPictureAspectRatio) + .apply { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + setAutoEnterEnabled(streamPictureInPictureReady) + setSeamlessResizeEnabled(true) + } + } + .build() + + private fun pictureInPictureAspectRatioFor(settings: StreamSettings): Rational { + val (width, height) = streamResolutionPixels(settings) + if (width <= 0 || height <= 0) return Rational(16, 9) + val ratio = width.toFloat() / height.toFloat() + return if (ratio in MIN_PIP_ASPECT_RATIO..MAX_PIP_ASPECT_RATIO) { + Rational(width, height) + } else { + Rational(16, 9) + } + } + + private fun applyStreamKeepAwake(active: Boolean) { + if (active) { + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } else { + window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + } + + private fun applyPhoneStreamOrientationLock(active: Boolean, force: Boolean = false) { + if (!force && phoneStreamOrientationLocked == active) return + phoneStreamOrientationLocked = active + val nextOrientation = if (active) { + ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE + } else { + defaultRequestedOrientation + } + if (requestedOrientation != nextOrientation) { + requestedOrientation = nextOrientation + } + } + + private fun updateStreamSystemUiEnforcer(active: Boolean) { + if (!active) { + streamSystemUiEnforcerJob?.cancel() + streamSystemUiEnforcerJob = null + return + } + if (streamSystemUiEnforcerJob?.isActive == true) return + streamSystemUiEnforcerJob = lifecycleScope.launch { + while (streamSystemUiActive) { + delay(STREAM_SYSTEM_UI_ENFORCE_INTERVAL_MS) + val navigationBarsVisible = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + window.decorView.rootWindowInsets?.isVisible(WindowInsets.Type.navigationBars()) == true + } else { + false + } + if ( + shouldPeriodicallyEnforceStreamSystemUi( + streamActive = streamSystemUiActive, + navigationBarsVisible = navigationBarsVisible, + pointerLockEnabled = NativeStreamInputRouter.isExternalMousePointerCaptureEnabled(), + ) + ) { + // Keep the immersive fallback without repeatedly walking the complete View + // hierarchy to reapply pointer icons or rewriting unchanged window flags. + applyStreamSystemBars(active = true) + } + } + } + } + + private fun applyStreamPointerIcon(active: Boolean) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return + runCatching { + val icon = if (active) PointerIcon.getSystemIcon(this, PointerIcon.TYPE_NULL) else null + window.decorView.applyPointerIconRecursive(icon) + }.onFailure { error -> + Log.w(MAIN_ACTIVITY_LOG_TAG, "Unable to apply stream pointer icon", error) + } + } + + private fun applyStreamDisplayRefreshRate(active: Boolean, requestedFps: Int, force: Boolean = false) { + streamDisplayRefreshActive = active + streamDisplayRefreshFps = requestedFps + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + DisplayRefreshDiagnostics.update( + active = active, + requestedFps = requestedFps, + currentMode = null, + selectedMode = null, + supportedModes = emptyList(), + preferredModeId = 0, + preferredRefreshRate = 0f, + applied = false, + ) + return + } + + val display = window.decorView.display + val supportedModes = display?.supportedModes.orEmpty().map { it.toDisplayRefreshMode() } + val currentMode = display?.mode?.toDisplayRefreshMode() + val selectedMode = if (active) { + selectStreamDisplayMode( + supportedModes = supportedModes, + currentMode = currentMode, + requestedFps = requestedFps, + ) + } else { + null + } + val preferredModeId = selectedMode?.id ?: 0 + val preferredRefreshRate = selectedMode?.refreshRate ?: if (active) normalizedStreamDisplayFps(requestedFps) else 0f + val attributes = window.attributes + if (!force && + attributes.preferredDisplayModeId == preferredModeId && + kotlin.math.abs(attributes.preferredRefreshRate - preferredRefreshRate) < 0.01f + ) { + DisplayRefreshDiagnostics.update( + active = active, + requestedFps = requestedFps, + currentMode = currentMode, + selectedMode = selectedMode, + supportedModes = supportedModes, + preferredModeId = preferredModeId, + preferredRefreshRate = preferredRefreshRate, + applied = true, + ) + return + } + var applied = false + var failure: Throwable? = null + runCatching { + window.attributes = attributes.apply { + preferredDisplayModeId = preferredModeId + this.preferredRefreshRate = preferredRefreshRate + } + applied = true + }.onFailure { error -> + failure = error + Log.w(MAIN_ACTIVITY_LOG_TAG, "Unable to apply stream display refresh preference", error) + } + DisplayRefreshDiagnostics.update( + active = active, + requestedFps = requestedFps, + currentMode = currentMode, + selectedMode = selectedMode, + supportedModes = supportedModes, + preferredModeId = preferredModeId, + preferredRefreshRate = preferredRefreshRate, + applied = applied, + error = failure, + ) + } + + private fun Display.Mode.toDisplayRefreshMode(): DisplayRefreshMode = + DisplayRefreshMode( + id = modeId, + refreshRate = refreshRate, + physicalWidth = physicalWidth, + physicalHeight = physicalHeight, + ) + + @Suppress("DEPRECATION", "OVERRIDE_DEPRECATION") + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + if (requestCode == 4210 && grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED) { + queueStatusNotifier.update(viewModel.state.value) + } + } + + private fun requestQueueNotificationPermissionIfNeeded(state: OpenNowUiState) { + if (notificationPermissionRequested) return + if (!shouldShowQueueLaunchStatus(state)) return + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return + if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) return + notificationPermissionRequested = true + requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), 4210) + } + + private fun dispatchGamepadHatNavigation(event: MotionEvent): Boolean { + if (event.actionMasked != MotionEvent.ACTION_MOVE) return false + if ((event.source and InputDevice.SOURCE_JOYSTICK) != InputDevice.SOURCE_JOYSTICK) return false + + val nextX = when { + event.getAxisValue(MotionEvent.AXIS_HAT_X) <= -0.5f -> KeyEvent.KEYCODE_DPAD_LEFT + event.getAxisValue(MotionEvent.AXIS_HAT_X) >= 0.5f -> KeyEvent.KEYCODE_DPAD_RIGHT + else -> null + } + val nextY = when { + event.getAxisValue(MotionEvent.AXIS_HAT_Y) <= -0.5f -> KeyEvent.KEYCODE_DPAD_UP + event.getAxisValue(MotionEvent.AXIS_HAT_Y) >= 0.5f -> KeyEvent.KEYCODE_DPAD_DOWN + else -> null + } + + val handledX = updateSyntheticDpadKey(lastHatXKeyCode, nextX, event) + val handledY = updateSyntheticDpadKey(lastHatYKeyCode, nextY, event) + lastHatXKeyCode = nextX + lastHatYKeyCode = nextY + return handledX || handledY + } + + private fun updateSyntheticDpadKey(previous: Int?, next: Int?, sourceEvent: MotionEvent): Boolean { + var handled = false + if (previous != null && previous != next) { + handled = dispatchSyntheticDpadKey(previous, KeyEvent.ACTION_UP, sourceEvent) || handled + } + if (next != null && previous != next) { + handled = dispatchSyntheticDpadKey(next, KeyEvent.ACTION_DOWN, sourceEvent) || handled + } + return handled + } + + private fun dispatchSyntheticDpadKey(keyCode: Int, action: Int, sourceEvent: MotionEvent): Boolean { + val event = KeyEvent( + sourceEvent.downTime, + sourceEvent.eventTime, + action, + keyCode, + 0, + sourceEvent.metaState, + KeyCharacterMap.VIRTUAL_KEYBOARD, + 0, + 0, + InputDevice.SOURCE_DPAD, + ) + return super.dispatchKeyEvent(event) + } + + private fun dispatchSyntheticStreamUiKey(keyCode: Int, sourceEvent: KeyEvent): Boolean { + val event = KeyEvent( + sourceEvent.downTime, + sourceEvent.eventTime, + sourceEvent.action, + keyCode, + sourceEvent.repeatCount, + sourceEvent.metaState, + KeyCharacterMap.VIRTUAL_KEYBOARD, + sourceEvent.scanCode, + sourceEvent.flags, + InputDevice.SOURCE_DPAD, + ) + return super.dispatchKeyEvent(event) + } + + private fun View.applyPointerIconRecursive(icon: PointerIcon?) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return + pointerIcon = icon + if (this is ViewGroup) { + for (index in 0 until childCount) { + getChildAt(index).applyPointerIconRecursive(icon) + } + } + } + + private fun View.applyCapturedPointerListenerRecursive(listener: View.OnCapturedPointerListener?) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + setOnCapturedPointerListener(listener) + if (this is ViewGroup) { + for (index in 0 until childCount) { + getChildAt(index).applyCapturedPointerListenerRecursive(listener) + } + } + } + + private fun MotionEvent.isMouseLikePointerEvent(): Boolean { + val controllerSource = + AndroidControllerInput.hasControllerSource(source) || + AndroidControllerInput.isControllerEvent(source, deviceId) + return (source and InputDevice.SOURCE_MOUSE) == InputDevice.SOURCE_MOUSE || + (source and InputDevice.SOURCE_MOUSE_RELATIVE) == InputDevice.SOURCE_MOUSE_RELATIVE || + ((source and InputDevice.SOURCE_TOUCHPAD) == InputDevice.SOURCE_TOUCHPAD && !controllerSource) + } + + private fun MotionEvent.isControllerMotionEvent(): Boolean = + AndroidControllerInput.isControllerEvent(source, deviceId) + + private fun KeyEvent.shouldReapplyStreamSystemUi(): Boolean = + keyCode == KeyEvent.KEYCODE_MENU || + AndroidControllerInput.isControllerEvent(source, deviceId) || + keyCode in KeyEvent.KEYCODE_BUTTON_A..KeyEvent.KEYCODE_BUTTON_MODE + + private companion object { + private const val MAIN_ACTIVITY_LOG_TAG = "OpenNOWMainActivity" + private const val STREAM_SYSTEM_UI_ENFORCE_INTERVAL_MS = 500L + private const val STREAM_SYSTEM_UI_INPUT_REAPPLY_MS = 250L + private const val MIN_PIP_ASPECT_RATIO = 1f / 2.39f + private const val MAX_PIP_ASPECT_RATIO = 2.39f + } +} + +internal fun shouldPeriodicallyEnforceStreamSystemUi( + streamActive: Boolean, + navigationBarsVisible: Boolean, + pointerLockEnabled: Boolean, +): Boolean = streamActive && (pointerLockEnabled || !navigationBarsVisible) + +internal fun shouldRouteCapturedAndroidMousePointer( + streamActive: Boolean, + mouseLikePointer: Boolean, +): Boolean = streamActive && mouseLikePointer + +internal fun shouldRequestAndroidMousePointerCapture( + streamActive: Boolean, + captureEnabled: Boolean, + windowFocused: Boolean, + hasPointerCapture: Boolean, + mouseLikePointer: Boolean, +): Boolean = + streamActive && + captureEnabled && + windowFocused && + !hasPointerCapture && + mouseLikePointer diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/MobileGyroscope.kt b/android/app/src/main/java/com/opencloudgaming/opennow/MobileGyroscope.kt new file mode 100644 index 000000000..abce91d7f --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/MobileGyroscope.kt @@ -0,0 +1,157 @@ +package com.opencloudgaming.opennow + +import android.content.Context +import android.hardware.Sensor +import android.hardware.SensorEvent +import android.hardware.SensorEventListener +import android.hardware.SensorManager +import android.view.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.platform.LocalView +import kotlin.math.sqrt + +private const val GYROSCOPE_DEAD_ZONE_RANGE_RAD_PER_SECOND = 2.5f +private const val GYROSCOPE_MOUSE_PIXELS_PER_RADIAN = 500f +private const val GYROSCOPE_MAX_SAMPLE_INTERVAL_SECONDS = 0.05f + +internal fun hasMobileGyroscope(context: Context): Boolean = + (context.getSystemService(Context.SENSOR_SERVICE) as? SensorManager) + ?.getDefaultSensor(Sensor.TYPE_GYROSCOPE) != null + +/** + * Maps Android's device-relative angular velocity into screen-relative mouse-look velocity. + * + * The display rotation matters on phones because the sensor coordinate system never rotates with + * the UI. Keeping this pure makes all four rotations testable without a physical sensor. + */ +internal fun gyroscopeAimForScreen( + rotation: Int, + angularVelocityX: Float, + angularVelocityY: Float, + sensitivity: Float, + deadZone: Float, + invertHorizontal: Boolean, + invertVertical: Boolean, +): Offset { + if (!angularVelocityX.isFinite() || !angularVelocityY.isFinite()) return Offset.Zero + val (screenHorizontal, screenVertical) = when (rotation) { + Surface.ROTATION_90 -> angularVelocityX to -angularVelocityY + Surface.ROTATION_180 -> angularVelocityY to angularVelocityX + Surface.ROTATION_270 -> -angularVelocityX to angularVelocityY + else -> -angularVelocityY to -angularVelocityX + } + var x = screenHorizontal + var y = screenVertical + if (invertHorizontal) x = -x + if (invertVertical) y = -y + + val magnitude = sqrt(x * x + y * y) + val threshold = deadZone.coerceIn(0f, 0.2f) * GYROSCOPE_DEAD_ZONE_RANGE_RAD_PER_SECOND + if (magnitude <= threshold || magnitude == 0f) return Offset.Zero + val adjustedMagnitude = magnitude - threshold + val scale = adjustedMagnitude / magnitude * sensitivity.coerceIn(0.25f, 3f) + return Offset(x * scale, y * scale) +} + +internal fun gyroscopeMouseDelta(angularVelocity: Offset, elapsedSeconds: Float): Offset { + if ( + !angularVelocity.x.isFinite() || + !angularVelocity.y.isFinite() || + !elapsedSeconds.isFinite() || + elapsedSeconds <= 0f + ) { + return Offset.Zero + } + val boundedElapsed = elapsedSeconds.coerceAtMost(GYROSCOPE_MAX_SAMPLE_INTERVAL_SECONDS) + return angularVelocity * (boundedElapsed * GYROSCOPE_MOUSE_PIXELS_PER_RADIAN) +} + +/** + * Registers only while motion aiming can reach the game. Angular velocity is integrated into + * ordered relative mouse deltas, so camera motion stops naturally with the sensor instead of + * behaving like a held right stick. + */ +@Composable +internal fun MobileGyroscopeAim( + client: NativeStreamClient, + settings: AndroidTouchSettings, + active: Boolean, +) { + val view = LocalView.current + val sensorManager = remember(view.context) { + view.context.getSystemService(Context.SENSOR_SERVICE) as? SensorManager + } + val sensor = remember(sensorManager) { sensorManager?.getDefaultSensor(Sensor.TYPE_GYROSCOPE) } + val currentRotation = rememberUpdatedState(view.display?.rotation ?: Surface.ROTATION_0) + + DisposableEffect( + client, + sensorManager, + sensor, + active, + settings.gyroscopeEnabled, + settings.gyroscopeSensitivity, + settings.gyroscopeDeadZone, + settings.gyroscopeSmoothing, + settings.gyroscopeInvertHorizontal, + settings.gyroscopeInvertVertical, + ) { + if (!active || !settings.gyroscopeEnabled || sensorManager == null || sensor == null) { + client.endGyroscopeMouseAim(android.os.SystemClock.uptimeMillis()) + return@DisposableEffect onDispose { + client.endGyroscopeMouseAim(android.os.SystemClock.uptimeMillis()) + } + } + + var filtered = Offset.Zero + var previousTimestampNs = 0L + val smoothing = settings.gyroscopeSmoothing.coerceIn(0f, 0.9f) + client.beginGyroscopeMouseAim() + val listener = object : SensorEventListener { + override fun onSensorChanged(event: SensorEvent) { + if (event.values.size < 2) return + val sample = gyroscopeAimForScreen( + rotation = currentRotation.value, + angularVelocityX = event.values[0], + angularVelocityY = event.values[1], + sensitivity = settings.gyroscopeSensitivity, + deadZone = settings.gyroscopeDeadZone, + invertHorizontal = settings.gyroscopeInvertHorizontal, + invertVertical = settings.gyroscopeInvertVertical, + ) + filtered = Offset( + x = filtered.x * smoothing + sample.x * (1f - smoothing), + y = filtered.y * smoothing + sample.y * (1f - smoothing), + ) + val previous = previousTimestampNs + previousTimestampNs = event.timestamp + if (previous <= 0L || event.timestamp <= previous) return + val delta = gyroscopeMouseDelta( + angularVelocity = filtered, + elapsedSeconds = (event.timestamp - previous) / 1_000_000_000f, + ) + client.sendGyroscopeMouseMove( + dx = delta.x, + dy = delta.y, + eventTimeMs = event.timestamp / 1_000_000L, + ) + } + + override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit + } + val registered = sensorManager.registerListener(listener, sensor, SensorManager.SENSOR_DELAY_GAME) + if (registered) { + NativeInputDiagnostics.add("Mobile gyroscope mouse aiming active") + } else { + client.endGyroscopeMouseAim(android.os.SystemClock.uptimeMillis()) + } + onDispose { + sensorManager.unregisterListener(listener) + client.endGyroscopeMouseAim(android.os.SystemClock.uptimeMillis()) + } + } +} diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/Models.kt b/android/app/src/main/java/com/opencloudgaming/opennow/Models.kt new file mode 100644 index 000000000..f285fc60c --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/Models.kt @@ -0,0 +1,2264 @@ +package com.opencloudgaming.opennow + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import androidx.compose.runtime.Immutable +import java.util.Locale +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.roundToInt + + +// Hoisted out of the per-game/per-store helpers below: these run across the whole catalogue, and +// compiling the pattern on each call was measurable on low-end devices. +private val NON_ALNUM_UPPER_RUN = Regex("[^A-Z0-9]+") +private val STORE_SEPARATOR_RUN = Regex("[\\s-]+") +private val WHITESPACE_RUN = Regex("\\s+") + +@Serializable +enum class VideoCodec { + H264, + H265, + AV1, +} + +@Serializable +enum class ColorQuality { + @kotlinx.serialization.SerialName("8bit_420") + EightBit420, + + @kotlinx.serialization.SerialName("8bit_444") + EightBit444, + + @kotlinx.serialization.SerialName("10bit_420") + TenBit420, + + @kotlinx.serialization.SerialName("10bit_444") + TenBit444, +} + +@Serializable +enum class StreamPreset { + @kotlinx.serialization.SerialName("recommended") + Recommended, + + @kotlinx.serialization.SerialName("custom") + Custom, + + @kotlinx.serialization.SerialName("low_data_saver") + LowDataSaver, + + @kotlinx.serialization.SerialName("medium") + Medium, + + @kotlinx.serialization.SerialName("high") + High, +} + +@Serializable +enum class MicrophoneMode { + @kotlinx.serialization.SerialName("disabled") + Disabled, + + @kotlinx.serialization.SerialName("push-to-talk") + PushToTalk, + + @kotlinx.serialization.SerialName("voice-activity") + VoiceActivity, +} + +@Serializable +enum class UiAccent { + OpenNow, + Pixel, + HotPink, + Lime, + Coral, + Violet, + /** Reads the removed Orange preference so existing settings can migrate without being reset. */ + @SerialName("Orange") + LegacyOrange, + /** Magenta/blue Absolute Cinema accent, selectable independently from its optional effects. */ + AbsoluteCinema, + Switch, +} + +@Serializable +enum class StreamStatsStyle { + Compact, + Detailed, +} + +@Serializable +enum class StreamStatsPosition { + Left, + Center, + Right, +} + +@Serializable +enum class CatalogBackgroundPreset { + @kotlinx.serialization.SerialName("colorful-abstract") + ColorfulAbstract, + + @kotlinx.serialization.SerialName("original") + Original, + + @kotlinx.serialization.SerialName("absolute-cinema") + AbsoluteCinema, +} + +@Serializable +data class StreamStatsMetrics( + val fps: Boolean = true, + val ping: Boolean = true, + val bitrate: Boolean = false, + val battery: Boolean = true, + val connection: Boolean = true, + val resolution: Boolean = false, + val codec: Boolean = false, + val location: Boolean = false, + val latency: Boolean = false, + val packetLoss: Boolean = false, +) { + fun enabledCount(): Int = listOf(fps, ping, bitrate, battery, connection, resolution, codec, location, latency, packetLoss).count { it } +} + +@Serializable +data class StreamKeyboardButtonPosition( + val horizontalFraction: Float = 1f, + val verticalFraction: Float = 0.5f, +) { + internal fun normalized(): StreamKeyboardButtonPosition = StreamKeyboardButtonPosition( + horizontalFraction = horizontalFraction.unitFractionOr(1f), + verticalFraction = verticalFraction.unitFractionOr(0.5f), + ) +} + +private fun Float.unitFractionOr(fallback: Float): Float = + if (isFinite()) coerceIn(0f, 1f) else fallback + +@Serializable +enum class IntroMusicStartMode { + @kotlinx.serialization.SerialName("muted") + Muted, + + @kotlinx.serialization.SerialName("playing") + Playing, +} + +@Serializable +enum class AppLaunchPage { + @kotlinx.serialization.SerialName("store") + Store, + + @kotlinx.serialization.SerialName("library") + Library, +} + +enum class SessionTimerMode { + Countdown, + Stopwatch, +} + +data class SmartSessionLimit( + val tierLabel: String, + val limitHours: Int, + val mode: SessionTimerMode, +) + +internal val SESSION_WARNING_THRESHOLDS_SECONDS = listOf( + 30 * 60, + 10 * 60, + 5 * 60, + 3 * 60, + 60, +) + +internal fun sessionElapsedSeconds(startedAtMs: Long, nowMs: Long): Int = + ((nowMs - startedAtMs).coerceAtLeast(0L) / 1000L).toInt() + +internal fun sessionRemainingSeconds(limit: SmartSessionLimit, startedAtMs: Long, nowMs: Long): Int { + val limitSeconds = limit.limitHours * 60 * 60 + return (limitSeconds - sessionElapsedSeconds(startedAtMs, nowMs)).coerceAtLeast(0) +} + +internal fun sessionWarningThresholdCrossed(previousRemainingSeconds: Int?, remainingSeconds: Int): Int? { + val previous = previousRemainingSeconds ?: return null + return SESSION_WARNING_THRESHOLDS_SECONDS + .filter { threshold -> previous > threshold && remainingSeconds <= threshold } + .minOrNull() +} + +@Serializable +data class StreamSettings( + val resolution: String = "1920x1080", + val aspectRatio: String = "16:9", + val fps: Int = 60, + val maxBitrateMbps: Int = 75, + val codec: VideoCodec = VideoCodec.H264, + val colorQuality: ColorQuality = ColorQuality.TenBit420, + val hdrEnabled: Boolean = false, + val region: String = "", + val keyboardLayout: String = "en-US", + val gameLanguage: String = "en_US", + val sessionProxyEnabled: Boolean = false, + val sessionProxyUrl: String = "", + val enableL4S: Boolean = false, + val mouseSensitivity: Float = 1f, + val mouseAcceleration: Int = 1, + val streamSharpeningEnabled: Boolean = false, + val streamSharpeningAmount: Float = 0.25f, + val microphoneMode: MicrophoneMode = MicrophoneMode.Disabled, + val microphoneDeviceId: String = "", + val mouseScrollSensitivity: Int = 30, +) + +internal fun StreamSettings.withMicrophoneSettingsFrom(source: StreamSettings): StreamSettings = + copy( + microphoneMode = source.microphoneMode, + microphoneDeviceId = source.microphoneDeviceId, + ) + +/** + * Where in-stream rumble is sent. + * + * [Auto] prefers the controller and falls back to the device, which is right for a phone plus a + * separate pad. It is wrong on handhelds whose built-in pad advertises a vibrator that drives + * nothing — the motor is wired to the *device* vibrator there — and no amount of probing + * distinguishes "advertised and silent" from "advertised and working", so this is a choice. + */ +@Serializable +enum class HapticsOutputPreference { + Auto, + Controller, + Device, +} + +/** + * A skin for the on-screen controller. + * + * A skin is a silhouette as well as a palette: the shapes come from `touchSkinForm` and the paint + * from `touchSkinColors`, so two skins never differ by colour alone. Adding one is an entry here + * plus a branch in each of those two functions. + * + * [V1] and [V2] keep their original names because they are already on disk in saved settings; the + * labels people see come from `touchControllerStyleLabel`. + */ +@Serializable +enum class TouchControllerStyle { + /** Classic: round filled caps, a one-piece d-pad cross, a ring stick. */ + V1, + + /** A heads-up display — unfilled caps, a grooved round pad, a crosshair stick. */ + V2, + + /** Hexagonal caps, blade d-pad and a hex gate, all bloomed in the accent colour. */ + Neon, + + /** Frosted squircle caps, a soft round pad and a stick sunk into a bowl. */ + Frost, + + /** Rimmed blocks and separated d-pad keys, for bright or busy games. */ + Contrast, + + /** A pre-analogue handheld: square keys, a restrictor gate, a monospaced legend. */ + Retro, + + /** A cabinet panel: domed plungers on chrome rims, round keys, a ball top on a shaft. */ + Arcade, +} + +@Serializable +enum class TouchJoystickMode { + Fixed, + Dynamic, +} + +@Serializable +enum class TouchAimMode { + LockJoystick, + LockZone, +} + +/** Independently hideable pieces of the on-screen controller. */ +@Serializable +enum class TouchControlGroup { + FaceButtons, + Dpad, + LeftStick, + RightStick, + ShoulderButtons, + ThumbButtons, + MenuButtons, +} + +/** + * Actions available to the four movable accessibility buttons. + * + * These are deliberately single, momentary gamepad actions rather than macros. A duplicate action + * is useful when a player cannot comfortably reach the original control, while avoiding recorded + * sequences keeps releases predictable when a stream reconnects or the overlay is removed. + */ +@Serializable +enum class TouchExtraButtonAction { + None, + Guide, + A, + B, + X, + Y, + DpadUp, + DpadDown, + DpadLeft, + DpadRight, + LeftBumper, + RightBumper, + LeftTrigger, + RightTrigger, + LeftStickClick, + RightStickClick, + Start, + Select, +} + +@Serializable +data class TouchOffset(val x: Float = 0f, val y: Float = 0f) + +/** + * Whether fingers are forwarded to the host as real touch rather than turned into a cursor. + * + * [Auto] limits it to games known to react to a touch device, which is the whole feature for most + * people. The other two exist because that list is maintained by hand and will lag reality. + */ +@Serializable +enum class NativeTouchMode { + Auto, + Off, + Always, +} + +@Serializable +data class AndroidTouchSettings( + val enabled: Boolean = true, + val mousePad: Boolean = true, + val opacity: Float = 0.82f, + val scale: Float = 1f, + val buttonScale: Float = 1.102468f, + val stickScale: Float = 1f, + /** Fine-grained multipliers applied after the two global size controls above. */ + val faceButtonScale: Float = 1f, + val dpadScale: Float = 1f, + val shoulderButtonScale: Float = 1f, + val centerButtonScale: Float = 1f, + val leftStickScale: Float = 1f, + val rightStickScale: Float = 1f, + /** Diameter of the movable stick cap as a fraction of its track. */ + val stickKnobScale: Float = 0.44f, + /** Built-in clusters can be removed without giving up the rest of the overlay. */ + val visibleControlGroups: Set = TouchControlGroup.entries.toSet(), + /** Up to four independently mapped and positioned accessibility buttons. */ + val extraButtonActions: List = listOf( + TouchExtraButtonAction.Guide, + TouchExtraButtonAction.None, + TouchExtraButtonAction.None, + TouchExtraButtonAction.None, + ), + val extraButtonScale: Float = 1f, + val joystickMode: TouchJoystickMode = TouchJoystickMode.Fixed, + val aimMode: TouchAimMode = TouchAimMode.LockJoystick, + /** Multiplies the visible and interactive Lock Zone footprint without changing its response. */ + val aimZoneScale: Float = 1f, + /** Multiplies Lock Zone right-stick response without changing the zone footprint. */ + val aimZoneSensitivity: Float = 1f, + val joystickDeadZone: Float = 0f, + /** Opt-in motion aiming. Kept off for compatibility and to avoid unexpected camera motion. */ + val gyroscopeEnabled: Boolean = false, + val gyroscopeSensitivity: Float = 1f, + val gyroscopeDeadZone: Float = 0.035f, + val gyroscopeSmoothing: Float = 0.35f, + val gyroscopeInvertHorizontal: Boolean = false, + val gyroscopeInvertVertical: Boolean = false, + val edgePaddingDp: Float = 14f, + val bottomPaddingDp: Float = 10f, + val leftOffsetXDp: Float = 0f, + val leftOffsetYDp: Float = 0f, + val rightOffsetXDp: Float = 0f, + val rightOffsetYDp: Float = 0f, + val mouseDirectClick: Boolean = false, + val nativeTouchMode: NativeTouchMode = NativeTouchMode.Auto, + /** + * Records an explicit Native Touch choice for compatibility with older saved settings. Auto is + * the default and only activates for catalog variants that advertise TOUCHSCREEN support. + */ + val nativeTouchOptedIn: Boolean = true, + /** + * Scales the velocity of touch movement in native touch mode. Values below 1.0 slow down + * scroll/swipe gestures; values above 1.0 speed them up. Default 1.0 = no scaling. + */ + val nativeTouchScrollScale: Float = 1.0f, + /** + * Minimum movement in dp before a MOVE event is forwarded in native touch mode. + * Suppresses small sensor jitter that can look like a micro-swipe instead of a tap. + * Default 8dp matches ViewConfiguration.getScaledTouchSlop() on most devices. + */ + val nativeTouchJitterThresholdDp: Float = 8f, + val offsets: Map = mapOf( + "lstick_landscape" to TouchOffset(-67.02336f, 1.4236208f), + "l3_landscape" to TouchOffset(-159.65048f, 119.79623f), + "lt_landscape" to TouchOffset(32.63997f, -52.50644f), + "dpad_landscape" to TouchOffset(47.38254f, -131.70163f), + "rb_landscape" to TouchOffset(-124.94213f, -107.18774f), + "lb_landscape" to TouchOffset(119.051155f, -100.54266f), + "face_landscape" to TouchOffset(-20.225464f, -132.01855f), + "rstick_landscape" to TouchOffset(96.44574f, -7.9870353f), + "r3_landscape" to TouchOffset(191.65938f, 125.07891f), + "rt_landscape" to TouchOffset(-30.344517f, -57.420998f), + "extra1_landscape" to TouchOffset(-84f, 0f), + "extra2_landscape" to TouchOffset(-28f, 0f), + "extra3_landscape" to TouchOffset(28f, 0f), + "extra4_landscape" to TouchOffset(84f, 0f), + "extra1_portrait" to TouchOffset(-84f, 0f), + "extra2_portrait" to TouchOffset(-28f, 0f), + "extra3_portrait" to TouchOffset(28f, 0f), + "extra4_portrait" to TouchOffset(84f, 0f), + ), + val touchControllerStyle: TouchControllerStyle = TouchControllerStyle.V1, + /** Overrides the skin's own accent. Null keeps whatever the chosen skin ships with. */ + val touchSkinTint: ControllerThemeRgb? = null, + /** Off leaves the caps blank — the layout is muscle memory once it is learned. */ + val touchButtonLabels: Boolean = true, +) { + fun getOffset(key: String): TouchOffset = offsets[key] ?: TouchOffset() + + fun withOffset(key: String, x: Float, y: Float): AndroidTouchSettings { + val newOffsets = offsets.toMutableMap() + newOffsets[key] = TouchOffset(x, y) + return this.copy(offsets = newOffsets) + } + + fun isControlVisible(group: TouchControlGroup): Boolean = group in visibleControlGroups + + fun withControlVisible(group: TouchControlGroup, visible: Boolean): AndroidTouchSettings = copy( + visibleControlGroups = if (visible) visibleControlGroups + group else visibleControlGroups - group, + ) + + fun extraButtonAction(index: Int): TouchExtraButtonAction = + extraButtonActions.getOrNull(index) ?: TouchExtraButtonAction.None + + fun withExtraButtonAction(index: Int, action: TouchExtraButtonAction): AndroidTouchSettings { + if (index !in 0 until TOUCH_EXTRA_BUTTON_COUNT) return this + val actions = List(TOUCH_EXTRA_BUTTON_COUNT) { extraButtonAction(it) }.toMutableList() + actions[index] = action + return copy(extraButtonActions = actions) + } + + fun withResetOffsets(): AndroidTouchSettings { + val defaultSettings = AndroidTouchSettings() + return this.copy( + opacity = defaultSettings.opacity, + scale = defaultSettings.scale, + buttonScale = defaultSettings.buttonScale, + stickScale = defaultSettings.stickScale, + faceButtonScale = defaultSettings.faceButtonScale, + dpadScale = defaultSettings.dpadScale, + shoulderButtonScale = defaultSettings.shoulderButtonScale, + centerButtonScale = defaultSettings.centerButtonScale, + leftStickScale = defaultSettings.leftStickScale, + rightStickScale = defaultSettings.rightStickScale, + stickKnobScale = defaultSettings.stickKnobScale, + extraButtonScale = defaultSettings.extraButtonScale, + edgePaddingDp = defaultSettings.edgePaddingDp, + bottomPaddingDp = defaultSettings.bottomPaddingDp, + leftOffsetXDp = defaultSettings.leftOffsetXDp, + leftOffsetYDp = defaultSettings.leftOffsetYDp, + rightOffsetXDp = defaultSettings.rightOffsetXDp, + rightOffsetYDp = defaultSettings.rightOffsetYDp, + offsets = defaultSettings.offsets + ) + } +} + +internal const val TOUCH_EXTRA_BUTTON_COUNT = 4 + +internal const val DEFAULT_CATALOG_SORT_ID = "most_popular" +internal const val NEWLY_ADDED_CATALOG_SORT_ID = "last_added" +internal const val CATALOG_SORT_DEFAULT_VERSION = 1 + +@Serializable +data class AppSettings( + val stream: StreamSettings = StreamSettings(), + val streamPreset: StreamPreset = StreamPreset.Recommended, + val posterSizeScale: Float = 1f, + val compactGameCards: Boolean = true, + val handheldLandscapeFourColumnGrid: Boolean = false, + val handheldLandscapeSquareCards: Boolean = false, + val nerdCatalogBackground: Boolean = false, + /** The subtle accent-colour wash used when no catalogue wallpaper is selected. */ + val ambientBackgroundEnabled: Boolean = true, + val catalogBackgroundPreset: CatalogBackgroundPreset = CatalogBackgroundPreset.ColorfulAbstract, + val nerdCatalogBackgroundUri: String? = null, + val tvSafeAreaPaddingDp: Float = 16f, + val tvLayoutProfileVersion: Int = 0, + val localTvRemoteEnabled: Boolean = false, + /** Game titles under the poster in the catalog grid. Off makes the grid pure box art. */ + val showCardTitles: Boolean = true, + /** Optional favorite affordance over catalogue artwork on mobile, handheld, and TV layouts. */ + val showFavoriteIconOnGameCards: Boolean = false, + val expressiveUi: Boolean = true, + /** Static outlines around game artwork, independent from optional animated border effects. */ + val liveSelectedOutlines: Boolean = false, + /** Animated focus energy using the selected interface accent; never changes the accent itself. */ + val absoluteCinemaEffects: Boolean = false, + /** Extends Absolute Cinema to pointer hover and non-controller focus surfaces throughout the UI. */ + val absoluteCinemaEverywhere: Boolean = false, + val dynamicColor: Boolean = false, + val uiAccent: UiAccent = UiAccent.OpenNow, + /** Shows a user-managed shelf of installed Android apps above the cloud Library. */ + val localAppsEnabled: Boolean = false, + /** Package names are stable across app updates and avoid persisting labels or icons. */ + val localAppPackageNames: List = emptyList(), + /** + * Whether the "My own apps" shelf is folded away. + * + * Persisted rather than screen state: someone who keeps a dozen Android apps pinned but browses + * the cloud catalogue most of the time wants that fold to survive leaving the Library. + */ + val localAppsCollapsed: Boolean = false, + /** The New games added hero in handheld landscape; dismissible from the hero header. */ + val landscapeNewGamesHero: Boolean = true, + /** Catalogue presentation is user preference, not disposable screen state. */ + val catalogSortId: String = DEFAULT_CATALOG_SORT_ID, + /** Lets the old relevance default migrate once without overriding a later explicit choice. */ + val catalogSortDefaultVersion: Int = 0, + val catalogFilterIds: List = emptyList(), + val librarySortId: String = "library", + val libraryFilterIds: List = emptyList(), + val launchPage: AppLaunchPage = AppLaunchPage.Store, + val nerdMode: Boolean = false, + val hideStreamButtons: Boolean = false, + val streamKeyboardClearConfirmationDisabled: Boolean = false, + val streamKeyboardButtonPosition: StreamKeyboardButtonPosition = StreamKeyboardButtonPosition(), + val showAntiAfkIndicator: Boolean = true, + val showStatsOnLaunch: Boolean = true, + val streamStatsStyle: StreamStatsStyle = StreamStatsStyle.Compact, + val streamStatsPosition: StreamStatsPosition = StreamStatsPosition.Right, + val streamStatsMetrics: StreamStatsMetrics = StreamStatsMetrics(), + /** Controller rumble when available, with device haptics as the fallback output. */ + @SerialName("phoneRumbleFallback") + val vibrationEnabled: Boolean = true, + val hapticsOutput: HapticsOutputPreference = HapticsOutputPreference.Auto, + val hideServerSelector: Boolean = false, + val controllerMode: Boolean = false, + val controllerUiSounds: Boolean = true, + val controllerMouseEmulation: Boolean = false, + /** Capture an external mouse during gameplay so Android system edges cannot steal it. */ + val externalMousePointerLock: Boolean = true, + val controllerBackgroundAnimations: Boolean = true, + val controllerThemeStyle: String = "aurora", + val controllerThemeColor: ControllerThemeRgb = ControllerThemeRgb(), + val controllerLibraryGameBackdrop: Boolean = true, + val autoLoadControllerLibrary: Boolean = false, + val autoFullScreen: Boolean = true, + val streamIntroMusic: Boolean = false, + val streamIntroStartMode: IntroMusicStartMode = IntroMusicStartMode.Muted, + val queueReadyMusic: Boolean = false, + @SerialName("stretchStreamToFill") + val legacyCropStreamToFill: Boolean = false, + /** + * Fills the display instead of letterboxing. + * + * Off by default so streams keep their exact geometry. Enabling this fills a mismatched display + * by stretching only the mismatched axis; it never crops the stream. + */ + @SerialName("stretchStreamToZoom") + val stretchStreamToFit: Boolean = false, + val streamPresentationProfileVersion: Int = 0, + val favoriteGameIds: List = emptyList(), + val defaultGameVariantIds: Map = emptyMap(), + val sessionCounterEnabled: Boolean = true, + val showSessionReportAfterStream: Boolean = false, + /** One-time migration that makes post-stream reports opt-in instead of upgrade-persistent. */ + val sessionReportDefaultVersion: Int = 0, + val sessionClockShowEveryMinutes: Int = 60, + val sessionClockShowDurationSeconds: Int = 30, + val clipboardPaste: Boolean = true, + val androidTouch: AndroidTouchSettings = AndroidTouchSettings(), + val androidStreamGuideDismissed: Boolean = false, + val androidPhysicalControllerPromptDismissed: Boolean = false, + val discordRichPresence: Boolean = false, + val autoCheckForUpdates: Boolean = true, + val analyticsOptOut: Boolean = true, + val analyticsConsentAsked: Boolean = false, + val allowEscapeToExitFullscreen: Boolean = false, + val nativeLowLatencyDecoder: Boolean = false, + /** + * Highest [SETUP_FLOW_VERSION] whose first-run setup this install has been through. Versioned + * rather than a boolean so a later release that adds a step can show the flow again, and so + * "run setup again" is expressible as resetting the field. + */ + val setupFlowCompletedVersion: Int = 0, + /** + * Whether the About > build-number gesture has revealed Settings > Developer options. + * + * Persisted so the section survives a restart, and clearable from inside it. See + * `AndroidDeveloperOptions.kt`. + */ + val developerOptionsUnlocked: Boolean = false, +) + +internal const val MIN_GAME_CARD_SCALE = 0.75f +internal const val MAX_GAME_CARD_SCALE = 1.4f +internal const val STREAM_PRESENTATION_PROFILE_VERSION = 3 +internal const val SESSION_REPORT_DEFAULT_VERSION = 1 + +/** + * Re-asserts the stream presentation defaults once per profile version. + * + * Version 3 makes exact geometry the default for new profiles. Existing profiles keep their saved + * stretch preference, including the version 2 phone default, so an app update does not silently + * override a user's current presentation. + */ +internal fun AppSettings.withCurrentStreamPresentationDefaults(): AppSettings { + if (streamPresentationProfileVersion >= STREAM_PRESENTATION_PROFILE_VERSION) return this + return copy( + legacyCropStreamToFill = false, + streamPresentationProfileVersion = STREAM_PRESENTATION_PROFILE_VERSION, + ) +} + +internal val AppSettings.analyticsSharingEnabled: Boolean + get() = analyticsConsentAsked && !analyticsOptOut + +internal fun streamResolutionPixels(settings: StreamSettings): Pair { + if (!isKnownStreamResolution(settings.resolution)) { + parseResolutionPixelsOrNull(settings.resolution)?.let { return it } + } + return parseResolutionPixels(normalizeStreamResolutionForAspect(settings.resolution, settings.aspectRatio)) +} + +internal fun StreamSettings.requiresNativeDesktopCloudMatchMode(): Boolean { + val (width, height) = streamResolutionPixels(this) + // CloudMatch's browser allocation rejects HDR even at 1080p and caps the high-resolution + // matrix. The caller selects a platform-appropriate native identity (including Android TV). + return hdrEnabled || fps > 60 || width > 1920 || height > 1200 +} + +internal data class StreamResolutionMismatch( + val actualResolution: String, + val expectedResolution: String, + val serverNegotiatedResolution: String? = null, +) + +internal enum class StreamResolutionChangeSource { + ServerNegotiatedFallback, + ProviderOrGameModeChange, +} + +internal data class ActiveStreamTransportProfile( + val resolution: String, + val aspectRatio: String, + val fps: Int, + val maxBitrateMbps: Int, + val codec: VideoCodec, + val colorQuality: ColorQuality, + val hdrEnabled: Boolean, + val enableL4S: Boolean, + val streamSharpeningEnabled: Boolean, +) + +internal fun StreamSettings.toActiveStreamTransportProfile(): ActiveStreamTransportProfile = + ActiveStreamTransportProfile( + resolution = resolution, + aspectRatio = aspectRatio, + fps = fps, + maxBitrateMbps = maxBitrateMbps, + codec = codec, + colorQuality = colorQuality, + hdrEnabled = hdrEnabled, + enableL4S = enableL4S, + streamSharpeningEnabled = streamSharpeningEnabled, + ) + +internal data class ActiveStreamModeStatus( + val requestedResolution: String, + val displayedResolution: String, + val serverNegotiatedResolution: String? = null, + val serverFinalSelectedResolution: String? = null, + val resolutionSource: StreamResolutionChangeSource? = null, + val safeVideoRecoveryActive: Boolean = false, + val requestedProfile: ActiveStreamTransportProfile, + val transportProfile: ActiveStreamTransportProfile, +) { + val transportCodec: VideoCodec + get() = transportProfile.codec +} + +internal val StreamResolutionMismatch.isServerNegotiatedFallback: Boolean + get() = serverNegotiatedResolution == actualResolution + +internal fun streamRuntimeResolutionMismatch( + settings: StreamSettings, + actualResolution: String?, + serverNegotiatedResolution: String? = null, +): StreamResolutionMismatch? { + val actualPixels = parseResolutionPixelsOrNull(actualResolution) + ?.takeIf { (width, height) -> width >= 320 && height >= 180 } + ?: return null + val expectedPixels = streamResolutionPixels(settings) + if (actualPixels == expectedPixels) return null + val negotiatedPixels = parseResolutionPixelsOrNull(serverNegotiatedResolution) + return StreamResolutionMismatch( + actualResolution = "${actualPixels.first}x${actualPixels.second}", + expectedResolution = "${expectedPixels.first}x${expectedPixels.second}", + serverNegotiatedResolution = negotiatedPixels + ?.takeIf { it == actualPixels } + ?.let { "${it.first}x${it.second}" }, + ) +} + +internal fun activeStreamModeStatus( + requestedSettings: StreamSettings, + transportSettings: StreamSettings, + decodedResolution: String?, + serverNegotiatedResolution: String? = null, + serverFinalSelectedResolution: String? = null, +): ActiveStreamModeStatus? { + val requestedPixels = streamResolutionPixels(requestedSettings) + val requestedResolution = "${requestedPixels.first}x${requestedPixels.second}" + val decodedPixels = parseResolutionPixelsOrNull(decodedResolution) + ?.takeIf { (width, height) -> width >= 320 && height >= 180 } + val negotiatedPixels = parseResolutionPixelsOrNull(serverNegotiatedResolution) + ?.takeIf { (width, height) -> width >= 320 && height >= 180 } + val finalSelectedPixels = parseResolutionPixelsOrNull(serverFinalSelectedResolution) + ?.takeIf { (width, height) -> width >= 320 && height >= 180 } + // CloudMatch can publish an intermediate monitor profile before video arrives, then the actual + // encoder emits the requested geometry. Do not turn that provisional snapshot into a user + // notification or bug report. A decoded frame (or explicit final selection) is authoritative. + val displayedPixels = decodedPixels ?: finalSelectedPixels ?: requestedPixels + val resolutionSource = when { + displayedPixels == requestedPixels -> null + finalSelectedPixels == displayedPixels || + (decodedPixels != null && negotiatedPixels == displayedPixels) -> + StreamResolutionChangeSource.ServerNegotiatedFallback + decodedPixels != null -> StreamResolutionChangeSource.ProviderOrGameModeChange + else -> null + } + val requestedProfile = requestedSettings.toActiveStreamTransportProfile() + val transportProfile = transportSettings.toActiveStreamTransportProfile() + val safeVideoRecoveryActive = requestedProfile != transportProfile + if (resolutionSource == null && !safeVideoRecoveryActive) return null + return ActiveStreamModeStatus( + requestedResolution = requestedResolution, + displayedResolution = "${displayedPixels.first}x${displayedPixels.second}", + serverNegotiatedResolution = negotiatedPixels?.let { "${it.first}x${it.second}" }, + serverFinalSelectedResolution = finalSelectedPixels?.let { "${it.first}x${it.second}" }, + resolutionSource = resolutionSource, + safeVideoRecoveryActive = safeVideoRecoveryActive, + requestedProfile = requestedProfile, + transportProfile = transportProfile, + ) +} + +internal fun streamResolutionOptionsForAspect(aspectRatio: String): List = + STREAM_RESOLUTION_OPTIONS.filter { it.aspectRatio == aspectRatio }.map { it.value } + +internal fun streamResolutionChoicesForAspect(aspectRatio: String): List = + STREAM_RESOLUTION_OPTIONS.filter { it.aspectRatio == aspectRatio }.map { it.toChoice() } + +internal fun streamAspectRatioOptions(): List = + STREAM_RESOLUTION_OPTIONS.map { it.aspectRatio }.distinct() + +internal fun streamAspectRatioForResolution(resolution: String): String? = + STREAM_RESOLUTION_OPTIONS.firstOrNull { it.value == resolution }?.aspectRatio + +internal fun normalizeStreamResolutionForAspect(resolution: String, aspectRatio: String): String { + val normalizedAspect = aspectRatio.trim() + val options = streamResolutionOptionsForAspect(normalizedAspect) + if (options.isEmpty()) return resolution + if (STREAM_RESOLUTION_OPTIONS.any { it.value == resolution && it.aspectRatio == normalizedAspect }) { + return resolution + } + + val tier = STREAM_RESOLUTION_OPTIONS.firstOrNull { it.value == resolution }?.tier + ?: resolutionTierForHeight(parseResolutionPixels(resolution).second) + PREFERRED_RESOLUTION_BY_TIER_AND_ASPECT[tier]?.get(normalizedAspect)?.let { preferred -> + if (preferred in options) return preferred + } + + val requestedPixels = parseResolutionPixels(resolution).let { it.first * it.second } + return options.minWithOrNull( + compareBy { option -> + val pixels = parseResolutionPixels(option).let { it.first * it.second } + abs(pixels - requestedPixels) + }.thenBy { option -> + parseResolutionPixels(option).first * parseResolutionPixels(option).second + }, + ) ?: options.first() +} + +internal fun normalizeStreamResolutionForAspectAndPlan( + resolution: String, + aspectRatio: String, + subscriptionInfo: SubscriptionInfo?, + fallbackMembershipTier: String?, +): String { + val customResolution = customStreamResolutionOrNull(resolution) + if (customResolution != null && customResolutionAllowedForPlan(customResolution, subscriptionInfo, fallbackMembershipTier)) { + return "${customResolution.first}x${customResolution.second}" + } + + val normalized = normalizeStreamResolutionForAspect(resolution, aspectRatio) + val choices = streamResolutionChoicesForAspect(aspectRatio) + val current = choices.firstOrNull { it.value == normalized } + if (current?.isAvailableFor(subscriptionInfo, fallbackMembershipTier) == true) return normalized + + val availableChoices = choices.filter { it.isAvailableFor(subscriptionInfo, fallbackMembershipTier) } + .ifEmpty { + streamResolutionChoicesForAspect("16:9").filter { it.isAvailableFor(subscriptionInfo, fallbackMembershipTier) } + } + if (availableChoices.isEmpty()) return normalized + + val requestedPixels = parseResolutionPixels(normalized).let { it.first * it.second } + return availableChoices + .filter { it.width * it.height <= requestedPixels } + .maxByOrNull { it.width * it.height } + ?.value + ?: availableChoices.minByOrNull { it.width * it.height }?.value + ?: normalized +} + +internal fun parseResolutionPixels(value: String): Pair { + val parts = value.split("x") + val width = parts.getOrNull(0)?.toIntOrNull() + val height = parts.getOrNull(1)?.toIntOrNull() + return if (width != null && height != null && width > 0 && height > 0) width to height else 1920 to 1080 +} + +internal fun streamSettingsSessionSignature(settings: StreamSettings): String { + val compatible = settings.withCodecColorCompatibility() + val (width, height) = streamResolutionPixels(compatible) + return listOf( + "opennow-android-stream-v1", + "res=${width}x$height", + "fps=${compatible.fps}", + "bitrate=${compatible.maxBitrateMbps}", + "codec=${compatible.codec.name}", + "color=${compatible.colorQuality.name}", + "hdr=${if (compatible.hdrEnabled) 1 else 0}", + "l4s=${if (compatible.enableL4S) 1 else 0}", + "keyboard=${compatible.keyboardLayout.trim()}", + "language=${compatible.gameLanguage.trim()}", + ).joinToString(";") +} + +internal data class StreamResolutionOption( + val value: String, + val aspectRatio: String, + val tier: String, + val requiredPlan: StreamResolutionPlan = StreamResolutionPlan.Free, +) { + fun toChoice(): StreamResolutionChoice { + val (width, height) = parseResolutionPixels(value) + return StreamResolutionChoice( + value = value, + width = width, + height = height, + aspectRatio = aspectRatio, + requiredPlan = requiredPlan, + ) + } +} + +internal enum class StreamResolutionPlan { + Free, + Priority, + Ultimate, +} + +internal data class StreamResolutionChoice( + val value: String, + val width: Int, + val height: Int, + val aspectRatio: String, + val requiredPlan: StreamResolutionPlan, +) { + val label: String + get() = "$width x $height" + + val requiredPlanLabel: String? + get() = streamResolutionPlanLabel(requiredPlan).takeIf { requiredPlan != StreamResolutionPlan.Free } + + fun isAvailableFor(subscriptionInfo: SubscriptionInfo?, fallbackMembershipTier: String?): Boolean { + if (requiredPlan == StreamResolutionPlan.Free) return true + return streamResolutionPlanRank(effectiveStreamingPlan(subscriptionInfo, fallbackMembershipTier)) >= streamResolutionPlanRank(requiredPlan) + } +} + +internal fun hasUltimateStreamingPlan(subscriptionInfo: SubscriptionInfo?, fallbackMembershipTier: String?): Boolean = + streamResolutionPlanRank(effectiveStreamingPlan(subscriptionInfo, fallbackMembershipTier)) >= + streamResolutionPlanRank(StreamResolutionPlan.Ultimate) + +internal fun hasHdrStreamingPlan(subscriptionInfo: SubscriptionInfo?, fallbackMembershipTier: String?): Boolean = + streamResolutionPlanRank(effectiveStreamingPlan(subscriptionInfo, fallbackMembershipTier)) >= + streamResolutionPlanRank(StreamResolutionPlan.Priority) + +internal fun maxStreamFpsFor(subscriptionInfo: SubscriptionInfo?, fallbackMembershipTier: String?): Int = + if (hasUltimateStreamingPlan(subscriptionInfo, fallbackMembershipTier)) MAX_ULTIMATE_STREAM_FPS else MAX_STANDARD_STREAM_FPS + +internal fun StreamSettings.withFpsAllowed(subscriptionInfo: SubscriptionInfo?, fallbackMembershipTier: String?): StreamSettings { + val maxFps = maxStreamFpsFor(subscriptionInfo, fallbackMembershipTier) + val allowedFps = fps.coerceIn(30, maxFps) + return if (allowedFps == fps) this else copy(fps = allowedFps) +} + +/** + * What the signed-in plan actually allows, in the same terms the stream settings use. + * + * First-run setup shows this beside the quality choices. Without it, a Free account being offered + * 1080p60 at most reads as OpenNOW deciding the device cannot manage more, when the cap is the + * membership tier — so the tier, and the ceiling it buys, are stated outright. + */ +internal data class StreamPlanEntitlements( + val plan: StreamResolutionPlan, + val planLabel: String, + val maxResolutionLabel: String, + val maxFps: Int, + val hdrAllowed: Boolean, +) { + /** True when a higher tier would unlock resolutions or frame rates this one cannot reach. */ + val cappedBelowTopTier: Boolean get() = plan != StreamResolutionPlan.Ultimate +} + +internal fun streamPlanEntitlements( + subscriptionInfo: SubscriptionInfo?, + fallbackMembershipTier: String?, +): StreamPlanEntitlements { + val plan = effectiveStreamingPlan(subscriptionInfo, fallbackMembershipTier) + val bestResolution = STREAM_RESOLUTION_OPTIONS + .map { it.toChoice() } + .filter { it.isAvailableFor(subscriptionInfo, fallbackMembershipTier) } + .maxByOrNull { it.width.toLong() * it.height } + return StreamPlanEntitlements( + plan = plan, + planLabel = streamResolutionPlanLabel(plan), + maxResolutionLabel = bestResolution?.label ?: "1280 x 720", + maxFps = maxStreamFpsFor(subscriptionInfo, fallbackMembershipTier), + hdrAllowed = hasHdrStreamingPlan(subscriptionInfo, fallbackMembershipTier), + ) +} + +internal fun streamResolutionPlanLabel(plan: StreamResolutionPlan): String = when (plan) { + StreamResolutionPlan.Free -> "Free" + StreamResolutionPlan.Priority -> "Performance" + StreamResolutionPlan.Ultimate -> "Ultimate" +} + +internal fun smartSessionLimitFor(subscriptionInfo: SubscriptionInfo?, fallbackMembershipTier: String?): SmartSessionLimit { + val plan = effectiveStreamingPlan(subscriptionInfo, fallbackMembershipTier) + val label = streamResolutionPlanLabel(plan) + return when (plan) { + StreamResolutionPlan.Ultimate -> SmartSessionLimit(label, 8, SessionTimerMode.Stopwatch) + StreamResolutionPlan.Priority -> SmartSessionLimit(label, 6, SessionTimerMode.Stopwatch) + StreamResolutionPlan.Free -> SmartSessionLimit(label, 1, SessionTimerMode.Countdown) + } +} + +internal fun monthlyHourLimitFor(subscriptionInfo: SubscriptionInfo?, fallbackMembershipTier: String?): Double? { + val reported = subscriptionInfo?.totalHours?.takeIf { it > 0.0 } + if (reported != null) return reported + return when (effectiveStreamingPlan(subscriptionInfo, fallbackMembershipTier)) { + StreamResolutionPlan.Free -> null + StreamResolutionPlan.Priority, + StreamResolutionPlan.Ultimate, + -> 100.0 + } +} + +internal fun monthlyHoursRemainingFor(subscriptionInfo: SubscriptionInfo?, fallbackMembershipTier: String?): Double? { + val reported = subscriptionInfo?.remainingHours?.takeIf { it > 0.0 } + if (reported != null) return reported + val limit = monthlyHourLimitFor(subscriptionInfo, fallbackMembershipTier) ?: return null + return (limit - (subscriptionInfo?.usedHours ?: 0.0)).coerceAtLeast(0.0) +} + +internal fun StreamSettings.withHdrAllowed(subscriptionInfo: SubscriptionInfo?, fallbackMembershipTier: String?): StreamSettings = + if (hdrEnabled && !hasHdrStreamingPlan(subscriptionInfo, fallbackMembershipTier)) copy(hdrEnabled = false).withCodecColorCompatibility() else withCodecColorCompatibility() + +/** + * NVIDIA exposes HDR on Android only through the SHIELD TV profile. Keep the known SHIELD + * transport envelope explicit so unsupported handset, codec, high-FPS, and above-4K requests do + * not reach CloudMatch as invalid session profiles. Disabling HDR intentionally preserves the + * selected 10-bit color quality because 10-bit SDR is a separate stream mode. + */ +internal fun StreamSettings.hdrAvailableForAndroid(androidTvProfile: Boolean): Boolean { + val (width, height) = streamResolutionPixels(this) + return androidTvProfile && + codec == VideoCodec.H265 && + fps <= 60 && + width <= 3840 && + height <= 2160 +} + +internal fun StreamSettings.withAndroidHdrCompatibility(androidTvProfile: Boolean): StreamSettings = + if (hdrEnabled && !hdrAvailableForAndroid(androidTvProfile)) { + copy(hdrEnabled = false).withCodecColorCompatibility() + } else { + withCodecColorCompatibility() + } + +internal fun VideoCodec.availableForAndroidSettings(): Boolean = + true + +internal fun ColorQuality.availableForAndroidSettings(): Boolean = + !isChroma444() + +internal fun ColorQuality.availableForCodec(codec: VideoCodec): Boolean = + availableForAndroidSettings() && + codec.availableForAndroidSettings() && + (codec != VideoCodec.AV1 || !isTenBit()) + +internal fun StreamSettings.withAndroidSettingsAvailability(): StreamSettings { + val providerCompatible = withProviderCompatibleUltrawideGeometry() + val availableCodec = if (providerCompatible.codec.availableForAndroidSettings()) providerCompatible.codec else VideoCodec.H264 + val normalized = if (availableCodec == providerCompatible.codec) providerCompatible else providerCompatible.copy(codec = availableCodec) + return normalized.withCodecColorCompatibility() +} + +/** + * The old Portal-sized option used the panel's 1376x640 dimensions, but GFN does not expose that + * low 19.5:9 mode. CloudMatch selected 1680x720 and the cloud streamer then cropped it to 1376x590. + * Treat the observed 21:9 mode as the user's requested geometry so launch, negotiation, decoding, + * input mapping, and profile-change reporting all describe the same stream. + */ +private fun StreamSettings.withProviderCompatibleUltrawideGeometry(): StreamSettings = + if (resolution == LEGACY_PORTAL_STREAM_RESOLUTION && aspectRatio == LEGACY_PORTAL_STREAM_ASPECT) { + copy(resolution = LOW_ULTRAWIDE_STREAM_RESOLUTION, aspectRatio = "21:9") + } else { + this + } + +internal fun StreamSettings.withCodecColorCompatibility(): StreamSettings { + val compatibleHdr = hdrEnabled && codec != VideoCodec.AV1 + val compatibleColor = when { + codec == VideoCodec.AV1 -> ColorQuality.EightBit420 + colorQuality.isChroma444() -> colorQuality.asChroma420() + compatibleHdr && !colorQuality.isTenBit() -> ColorQuality.TenBit420 + else -> colorQuality + } + return if (compatibleColor == colorQuality && compatibleHdr == hdrEnabled) { + this + } else { + copy(colorQuality = compatibleColor, hdrEnabled = compatibleHdr) + } +} + +internal fun StreamSettings.usesTenBitStreamProfile(): Boolean = + hdrEnabled || colorQuality.isTenBit() + +internal fun StreamSettings.applyingStreamPreset(preset: StreamPreset): StreamSettings { + if (preset == StreamPreset.Custom) return this + val target = streamPresetTargetForAspect(preset, aspectRatio) + return copy( + resolution = target.resolution, + aspectRatio = target.aspectRatio, + fps = target.fps, + maxBitrateMbps = target.maxBitrateMbps, + colorQuality = ColorQuality.EightBit420, + hdrEnabled = false, + ).withoutExperimentalTransportRequests() + .withAndroidSettingsAvailability() +} + +internal fun StreamSettings.withoutExperimentalTransportRequests(): StreamSettings = + if (!enableL4S) this else copy(enableL4S = false) + +internal fun StreamSettings.withResolutionAllowed(subscriptionInfo: SubscriptionInfo?, fallbackMembershipTier: String?): StreamSettings { + val providerCompatible = withProviderCompatibleUltrawideGeometry() + if (providerCompatible != this) { + return providerCompatible.withResolutionAllowed(subscriptionInfo, fallbackMembershipTier) + } + val customResolution = customStreamResolutionOrNull(resolution) + if (customResolution != null && customResolutionAllowedForPlan(customResolution, subscriptionInfo, fallbackMembershipTier)) { + val normalizedResolution = "${customResolution.first}x${customResolution.second}" + return if (normalizedResolution == resolution) this else copy(resolution = normalizedResolution) + } + + val allowedAspectRatio = if (streamResolutionChoicesForAspect(aspectRatio).any { it.isAvailableFor(subscriptionInfo, fallbackMembershipTier) }) { + aspectRatio + } else { + "16:9" + } + val allowedResolution = normalizeStreamResolutionForAspectAndPlan(resolution, allowedAspectRatio, subscriptionInfo, fallbackMembershipTier) + return if (allowedResolution == resolution && allowedAspectRatio == aspectRatio) this else copy(resolution = allowedResolution, aspectRatio = allowedAspectRatio) +} + +internal fun StreamSettings.eligibleForAndroidLaunch( + subscriptionInfo: SubscriptionInfo?, + fallbackMembershipTier: String?, + androidTvProfile: Boolean, +): StreamSettings = + withResolutionAllowed(subscriptionInfo, fallbackMembershipTier) + .withFpsAllowed(subscriptionInfo, fallbackMembershipTier) + .withHdrAllowed(subscriptionInfo, fallbackMembershipTier) + .withAndroidSettingsAvailability() + .withAndroidHdrCompatibility(androidTvProfile) + .withCodecColorCompatibility() + +private fun isKnownStreamResolution(resolution: String): Boolean = + STREAM_RESOLUTION_OPTIONS.any { it.value == resolution } + +private fun customStreamResolutionOrNull(resolution: String): Pair? = + parseResolutionPixelsOrNull(resolution)?.takeUnless { + isKnownStreamResolution(resolution) || resolution in UNSUPPORTED_LEGACY_STREAM_RESOLUTIONS + } + +private val UNSUPPORTED_LEGACY_STREAM_RESOLUTIONS = setOf( + "1376x640", + "1600x720", + "2400x1080", + "3200x1440", + "4800x2160", +) + +private fun customResolutionAllowedForPlan( + resolution: Pair, + subscriptionInfo: SubscriptionInfo?, + fallbackMembershipTier: String?, +): Boolean { + val availableChoices = STREAM_RESOLUTION_OPTIONS + .map { it.toChoice() } + .filter { it.isAvailableFor(subscriptionInfo, fallbackMembershipTier) } + if (availableChoices.isEmpty()) return false + + val (width, height) = resolution + val pixels = width * height + return width <= availableChoices.maxOf { it.width } && + height <= availableChoices.maxOf { it.height } && + pixels <= availableChoices.maxOf { it.width * it.height } +} + +internal val STREAM_RESOLUTION_OPTIONS = listOf( + StreamResolutionOption("1280x720", "16:9", "720"), + StreamResolutionOption("1366x768", "16:9", "768"), + StreamResolutionOption("1600x900", "16:9", "900"), + StreamResolutionOption("1280x800", "16:10", "720"), + StreamResolutionOption("1440x900", "16:10", "900"), + StreamResolutionOption("1680x1050", "16:10", "1050"), + StreamResolutionOption("1920x1080", "16:9", "1080"), + StreamResolutionOption("1920x1200", "16:10", "1080"), + StreamResolutionOption("1024x768", "4:3", "768"), + StreamResolutionOption("1112x834", "4:3", "834"), + StreamResolutionOption("1600x1200", "4:3", "1080"), + StreamResolutionOption("1280x1024", "5:4", "1050"), + StreamResolutionOption("1376x590", "21:9", "720"), + StreamResolutionOption("1680x720", "21:9", "720"), + StreamResolutionOption("2340x1080", "19.5:9", "1080", StreamResolutionPlan.Priority), + StreamResolutionOption("2560x1080", "21:9", "1080", StreamResolutionPlan.Priority), + StreamResolutionOption("3840x1080", "32:9", "1080", StreamResolutionPlan.Priority), + StreamResolutionOption("2560x1440", "16:9", "1440", StreamResolutionPlan.Priority), + StreamResolutionOption("2560x1600", "16:10", "1440", StreamResolutionPlan.Priority), + StreamResolutionOption("3440x1440", "21:9", "1440", StreamResolutionPlan.Priority), + StreamResolutionOption("5120x1440", "32:9", "1440", StreamResolutionPlan.Priority), + StreamResolutionOption("3840x1600", "24:10", "1440", StreamResolutionPlan.Priority), + StreamResolutionOption("3840x2160", "16:9", "2160", StreamResolutionPlan.Ultimate), + StreamResolutionOption("3456x2160", "16:10", "2160", StreamResolutionPlan.Ultimate), + StreamResolutionOption("5120x2160", "21:9", "2160", StreamResolutionPlan.Ultimate), + StreamResolutionOption("5120x2880", "16:9", "2880", StreamResolutionPlan.Ultimate), +) + +private val PREFERRED_RESOLUTION_BY_TIER_AND_ASPECT = mapOf( + "720" to mapOf("16:9" to "1280x720", "16:10" to "1280x800", "4:3" to "1024x768", "21:9" to "1680x720"), + "768" to mapOf("16:9" to "1366x768", "4:3" to "1024x768"), + "834" to mapOf("4:3" to "1112x834"), + "900" to mapOf("16:9" to "1600x900", "16:10" to "1440x900"), + "1050" to mapOf("16:10" to "1680x1050", "5:4" to "1280x1024"), + "1080" to mapOf("16:9" to "1920x1080", "16:10" to "1920x1200", "4:3" to "1600x1200", "19.5:9" to "2340x1080", "21:9" to "2560x1080", "32:9" to "3840x1080"), + "1440" to mapOf("16:9" to "2560x1440", "16:10" to "2560x1600", "21:9" to "3440x1440", "24:10" to "3840x1600", "32:9" to "5120x1440"), + "2160" to mapOf("16:9" to "3840x2160", "16:10" to "3456x2160", "21:9" to "5120x2160"), + "2880" to mapOf("16:9" to "5120x2880"), +) + +private data class StreamPresetTarget( + val resolution: String, + val aspectRatio: String, + val fps: Int, + val maxBitrateMbps: Int, +) + +private fun streamPresetTargetForAspect(preset: StreamPreset, aspectRatio: String): StreamPresetTarget { + val normalizedAspect = aspectRatio.takeIf { streamResolutionOptionsForAspect(it).isNotEmpty() } ?: "16:9" + val maxHeight = when (preset) { + StreamPreset.Custom -> Int.MAX_VALUE + StreamPreset.Recommended -> 1200 + StreamPreset.LowDataSaver -> 800 + StreamPreset.Medium -> 1200 + StreamPreset.High -> 1600 + } + val options = STREAM_RESOLUTION_OPTIONS + .filter { it.aspectRatio == normalizedAspect } + .sortedBy { it.pixelCount() } + val resolution = options + .filter { parseResolutionPixels(it.value).second <= maxHeight } + .maxByOrNull { it.pixelCount() } + ?: options.firstOrNull() + ?: StreamResolutionOption("1280x720", "16:9", "720") + + return when (preset) { + StreamPreset.Custom -> StreamPresetTarget(resolution.value, resolution.aspectRatio, 60, 75) + StreamPreset.Recommended -> StreamPresetTarget(resolution.value, resolution.aspectRatio, 60, 35) + StreamPreset.LowDataSaver -> StreamPresetTarget(resolution.value, resolution.aspectRatio, 30, 12) + StreamPreset.Medium -> StreamPresetTarget(resolution.value, resolution.aspectRatio, 60, 35) + StreamPreset.High -> StreamPresetTarget(resolution.value, resolution.aspectRatio, MAX_ULTIMATE_STREAM_FPS, 75) + } +} + +private fun ColorQuality.isChroma444(): Boolean = + this == ColorQuality.EightBit444 || this == ColorQuality.TenBit444 + +internal fun ColorQuality.isTenBit(): Boolean = + this == ColorQuality.TenBit420 || this == ColorQuality.TenBit444 + +private fun ColorQuality.asChroma420(): ColorQuality = + when (this) { + ColorQuality.TenBit444 -> ColorQuality.TenBit420 + ColorQuality.EightBit444 -> ColorQuality.EightBit420 + else -> this + } + +private fun resolutionTierForHeight(height: Int): String = + when { + height >= 2600 -> "2880" + height >= 2000 -> "2160" + height >= 1320 -> "1440" + height >= 1120 -> "1080" + height >= 975 -> "1050" + height >= 850 -> "900" + height >= 800 -> "834" + height >= 740 -> "768" + else -> "720" + } + +/** + * What a game's `minimumMembershipTierLabel` demands, when the signed-in plan cannot meet it. + * + * Null whenever the game names no tier, names one this account already has, or names something the + * normalizer does not recognise. Guessing here would be worse than staying quiet: a spurious + * "requires Ultimate" in front of the Play button reads as the app refusing to launch a game the + * player owns. + */ +data class GameMembershipRequirement( + val requiredPlanLabel: String, + val currentPlanLabel: String, +) + +/** The launch a membership warning is holding, so continuing resumes exactly what was asked for. */ +data class PendingMembershipNotice( + val game: GameInfo, + val requirement: GameMembershipRequirement, + val streamingBaseUrlOverride: String?, + val skipPrintedWaste: Boolean, + val skipStoreChoice: Boolean, +) + +internal fun gameMembershipRequirement( + game: GameInfo, + subscriptionInfo: SubscriptionInfo?, + fallbackMembershipTier: String?, +): GameMembershipRequirement? { + val label = game.membershipTierLabel?.trim()?.takeIf { it.isNotEmpty() } ?: return null + val requiredPlan = planForMembershipTier(label) + if (requiredPlan == StreamResolutionPlan.Free) return null + val currentPlan = effectiveStreamingPlan(subscriptionInfo, fallbackMembershipTier) + if (streamResolutionPlanRank(currentPlan) >= streamResolutionPlanRank(requiredPlan)) return null + return GameMembershipRequirement( + requiredPlanLabel = streamResolutionPlanLabel(requiredPlan), + currentPlanLabel = streamResolutionPlanLabel(currentPlan), + ) +} + +private fun planForMembershipTier(membershipTier: String?): StreamResolutionPlan { + val normalized = membershipTier.orEmpty().uppercase(Locale.US).replace(NON_ALNUM_UPPER_RUN, "") + return when { + normalized.contains("ULTIMATE") || normalized.contains("RTX3080") -> StreamResolutionPlan.Ultimate + normalized.contains("PRIORITY") || normalized.contains("PERFORMANCE") || normalized.contains("FOUNDERS") -> StreamResolutionPlan.Priority + else -> StreamResolutionPlan.Free + } +} + +private fun effectiveStreamingPlan( + subscriptionInfo: SubscriptionInfo?, + fallbackMembershipTier: String?, +): StreamResolutionPlan = + listOf( + planForMembershipTier(subscriptionInfo?.membershipTier), + planForMembershipTier(fallbackMembershipTier), + ).maxBy { streamResolutionPlanRank(it) } + +private fun streamResolutionPlanRank(plan: StreamResolutionPlan): Int = + when (plan) { + StreamResolutionPlan.Free -> 0 + StreamResolutionPlan.Priority -> 1 + StreamResolutionPlan.Ultimate -> 2 + } + +@Serializable +data class ControllerThemeRgb( + val r: Int = 124, + val g: Int = 241, + val b: Int = 177, +) + +@Serializable +data class LoginProvider( + val idpId: String, + val code: String, + val displayName: String, + val streamingServiceUrl: String, + val priority: Int = 0, +) + +val LoginProvider.supportsDeviceCodeLogin: Boolean + get() = code.equals("NVIDIA", ignoreCase = true) + +@Serializable +data class AuthTokens( + val accessToken: String, + val refreshToken: String? = null, + val idToken: String? = null, + val expiresAt: Long, + val clientToken: String? = null, + val clientTokenExpiresAt: Long? = null, + val authClientId: String? = null, +) + +@Serializable +data class AuthUser( + val userId: String, + val displayName: String, + val email: String? = null, + val avatarUrl: String? = null, + val membershipTier: String = "FREE", +) + +@Serializable +data class AuthSession( + val provider: LoginProvider, + val tokens: AuthTokens, + val user: AuthUser, +) + +data class DeviceLoginPrompt( + val userCode: String, + val verificationUri: String, + val verificationUriComplete: String? = null, + val expiresAt: Long, +) + +@Serializable +data class SavedAccount( + val userId: String, + val displayName: String, + val email: String? = null, + val avatarUrl: String? = null, + val membershipTier: String = "FREE", + val providerCode: String = "NVIDIA", +) + +@Serializable +data class PersistedAuthState( + val sessions: List = emptyList(), + val activeUserId: String? = null, + val selectedProvider: LoginProvider? = null, +) + +@Serializable +data class StreamRegion( + val name: String, + val url: String, + val pingMs: Long? = null, +) + +@Serializable +data class EntitledResolution( + val width: Int, + val height: Int, + val fps: Int, +) + +@Serializable +data class StorageAddon( + val type: String = "PERMANENT_STORAGE", + val sizeGb: Double? = null, + val usedGb: Double? = null, + val regionName: String? = null, + val regionCode: String? = null, + val status: String? = null, + val subType: String? = null, + val autoPayEnabled: Boolean? = null, +) + +@Serializable +data class SubscriptionInfo( + val membershipTier: String = "FREE", + val subscriptionType: String? = null, + val subscriptionSubType: String? = null, + val allottedHours: Double = 0.0, + val purchasedHours: Double = 0.0, + val rolledOverHours: Double = 0.0, + val usedHours: Double = 0.0, + val remainingHours: Double = 0.0, + val totalHours: Double = 0.0, + val state: String? = null, + val isGamePlayAllowed: Boolean? = null, + val isUnlimited: Boolean = false, + val storageAddon: StorageAddon? = null, + val entitledResolutions: List = emptyList(), +) + +@Serializable +data class AccountConnector( + val store: String, + val label: String, + val supported: Boolean = true, + val required: Boolean = false, + val userDisplayName: String? = null, + val userIdentifier: String? = null, + val expiresInSeconds: Long? = null, + val syncedGameCount: Int? = null, + val syncState: String? = null, + val syncDate: String? = null, +) + +val AccountConnector.isLinked: Boolean + get() = !userDisplayName.isNullOrBlank() || + !userIdentifier.isNullOrBlank() || + expiresInSeconds != null || + syncedGameCount != null || + !syncState.isNullOrBlank() || + !syncDate.isNullOrBlank() + +@Immutable +@Serializable +data class GameVariant( + val id: String, + val store: String, + val storeUrl: String? = null, + val supportedControls: List = emptyList(), + val librarySelected: Boolean? = null, + val libraryStatus: String? = null, + val lastPlayedDate: String? = null, + val gfnStatus: String? = null, + val isFreeToPlay: Boolean = false, +) + +@Immutable +@Serializable +data class GameInfo( + val id: String, + val uuid: String? = null, + val launchAppId: String? = null, + val title: String, + val catalogSectionId: String? = null, + val catalogSectionTitle: String? = null, + val description: String? = null, + val longDescription: String? = null, + val featureLabels: List = emptyList(), + val genres: List = emptyList(), + val imageUrl: String? = null, + val tvCardImageUrl: String? = null, + val screenshotUrl: String? = null, + val screenshotUrls: List = emptyList(), + val tvBannerUrl: String? = null, + val playType: String? = null, + val membershipTierLabel: String? = null, + val publisherName: String? = null, + val contentRatings: List = emptyList(), + val playabilityState: String? = null, + val availableStores: List = emptyList(), + val searchText: String? = null, + val lastPlayed: String? = null, + val isInLibrary: Boolean = false, + val selectedVariantIndex: Int = 0, + val variants: List = emptyList(), +) + +private val primaryCatalogStoreKeys = setOf( + "STEAM", + "EPIC", + "EPIC_GAMES_STORE", + "EGS", + "XBOX", + "XBOX_GAME_PASS", + "MICROSOFT", + "MICROSOFT_STORE", +) + +private val ownedLibraryStatuses = setOf("MANUAL", "PLATFORM_SYNC", "IN_LIBRARY") + +internal fun isOwnedLibraryStatus(status: String?): Boolean = + status in ownedLibraryStatuses + +internal fun isOwnedGameVariant(variant: GameVariant): Boolean = + isOwnedLibraryStatus(variant.libraryStatus) + +internal fun isGameInLibrary(game: GameInfo): Boolean = + game.isInLibrary || game.variants.any(::isOwnedGameVariant) + +internal fun gameTrackingKey(game: GameInfo): String = + game.uuid?.takeIf { it.isNotBlank() } + ?: game.launchAppId?.takeIf { it.isNotBlank() } + ?: game.id + +internal fun shouldLaunchWithAccountLinked(game: GameInfo, selectedVariant: GameVariant?): Boolean { + if (game.playType == "INSTALL_TO_PLAY") return false + if (selectedVariant?.let(::isOwnedGameVariant) == true) return true + return isGameInLibrary(game) +} + +internal fun shouldMarkVariantOwnedBeforeLaunch(selectedVariant: GameVariant?): Boolean { + if (selectedVariant == null || selectedVariant.id.isBlank()) return false + return selectedVariant.libraryStatus == "NOT_OWNED" +} + +internal fun GameInfo.withManuallyOwnedVariant(variantId: String): GameInfo { + val selectedIndex = variants.indexOfFirst { it.id == variantId } + if (selectedIndex < 0) return this + return copy( + isInLibrary = true, + selectedVariantIndex = selectedIndex, + variants = variants.mapIndexed { index, variant -> + if (index == selectedIndex) { + variant.copy(libraryStatus = "MANUAL") + } else { + variant + } + }, + ) +} + +internal fun mergeKnownLibraryGames(vararg groups: List): List { + val byKey = linkedMapOf() + for (game in groups.flatMap { it }) { + if (!isGameInLibrary(game)) continue + val key = game.uuid ?: game.id + val existing = byKey[key] + byKey[key] = if (existing == null) game.copy(isInLibrary = true) else mergeGameInfo(existing, game).copy(isInLibrary = true) + } + return byKey.values.toList() +} + +internal fun mergePanelGameWithMetadata(panelGame: GameInfo, metadataGame: GameInfo): GameInfo = + mergeGameInfo(panelGame, metadataGame).copy( + imageUrl = metadataGame.imageUrl ?: panelGame.imageUrl, + tvCardImageUrl = metadataGame.tvCardImageUrl ?: panelGame.tvCardImageUrl, + screenshotUrl = metadataGame.screenshotUrl ?: panelGame.screenshotUrl, + screenshotUrls = (metadataGame.screenshotUrls + panelGame.screenshotUrls).distinct(), + tvBannerUrl = metadataGame.tvBannerUrl ?: panelGame.tvBannerUrl, + ) + +internal fun normalizeGameStore(store: String): String = + store.uppercase(Locale.US).replace(STORE_SEPARATOR_RUN, "_") + +internal fun splitGameStoreKeys(store: String): List = + store.split(",") + .map { normalizeGameStore(it.trim()) } + .filter { it.isNotBlank() } + +internal fun isPrimaryCatalogStoreValue(store: String): Boolean { + val storeKeys = splitGameStoreKeys(store) + return storeKeys.isNotEmpty() && storeKeys.all { it in primaryCatalogStoreKeys } +} + +internal fun gameStoreDisplayName(store: String): String { + val parts = store.split(",") + .map { it.trim() } + .filter { it.isNotBlank() } + .ifEmpty { listOf(store.trim()) } + return parts.map { part -> + when (normalizeGameStore(part)) { + "EPIC", "EGS", "EPIC_GAMES_STORE" -> "Epic" + "STEAM" -> "Steam" + "XBOX", "XBOX_GAME_PASS" -> "Xbox" + "MICROSOFT", "MICROSOFT_STORE" -> "Microsoft Store" + else -> part.replace('_', ' ').lowercase(Locale.US) + .split(WHITESPACE_RUN) + .filter { it.isNotBlank() } + .joinToString(" ") { word -> word.replaceFirstChar { char -> char.titlecase(Locale.US) } } + .ifBlank { "Unknown" } + } + }.distinct().joinToString(" / ") +} + +internal fun launchableGameVariants(variants: List): List { + val uniqueVariants = variants.distinctBy { it.id } + val individualPrimaryStores = uniqueVariants + .map { splitGameStoreKeys(it.store) } + .filter { it.size == 1 && it.first() in primaryCatalogStoreKeys } + .flatten() + .toSet() + val filtered = uniqueVariants.filterNot { variant -> + val storeKeys = splitGameStoreKeys(variant.store) + storeKeys.size > 1 && storeKeys.all { it in individualPrimaryStores } + } + val byStore = linkedMapOf() + for (variant in filtered) { + val storeKey = splitGameStoreKeys(variant.store).joinToString(",").ifBlank { normalizeGameStore(variant.store) } + val existing = byStore[storeKey] + if (existing == null || variantLaunchRank(variant) > variantLaunchRank(existing)) { + byStore[storeKey] = variant + } + } + return byStore.values.toList() +} + +internal fun displayStoresForVariants(variants: List): List = + launchableGameVariants(variants) + .flatMap { variant -> gameStoreDisplayName(variant.store).split(" / ") } + .map { it.trim() } + .filter { it.isNotBlank() } + .distinctBy { normalizeGameStore(it) } + +internal fun libraryStoreDisplayNames(game: GameInfo): List { + val variants = when { + game.variants.any(::isOwnedGameVariant) -> game.variants.filter(::isOwnedGameVariant) + game.isInLibrary -> listOfNotNull(game.variants.firstOrNull { it.librarySelected == true }) + .ifEmpty { listOfNotNull(game.variants.getOrNull(game.selectedVariantIndex)) } + .ifEmpty { game.variants.take(1) } + else -> emptyList() + } + val variantStores = variants + .flatMap { variant -> gameStoreDisplayName(variant.store).split(" / ") } + .map { it.trim() } + .filter { it.isNotBlank() } + .distinctBy { normalizeGameStore(it) } + if (variantStores.isNotEmpty()) return variantStores + if (!game.isInLibrary) return emptyList() + return game.availableStores + .map(::gameStoreDisplayName) + .map { it.trim() } + .filter { it.isNotBlank() } + .distinctBy { normalizeGameStore(it) } +} + +internal fun mergeGameInfo(left: GameInfo, right: GameInfo): GameInfo { + val variants = linkedMapOf() + for (variant in left.variants + right.variants) { + val existing = variants[variant.id] + variants[variant.id] = if (existing == null) variant else mergeGameVariant(existing, variant) + } + val mergedVariants = variants.values.toList() + val selectedVariantId = left.variants.getOrNull(left.selectedVariantIndex)?.id + ?: right.variants.getOrNull(right.selectedVariantIndex)?.id + val selectedIndex = selectedVariantId?.let { id -> mergedVariants.indexOfFirst { it.id == id } } ?: -1 + return left.copy( + uuid = left.uuid ?: right.uuid, + launchAppId = left.launchAppId ?: right.launchAppId, + description = left.description ?: right.description, + longDescription = left.longDescription ?: right.longDescription, + imageUrl = left.imageUrl ?: right.imageUrl, + tvCardImageUrl = left.tvCardImageUrl ?: right.tvCardImageUrl, + screenshotUrl = left.screenshotUrl ?: right.screenshotUrl, + screenshotUrls = (left.screenshotUrls + right.screenshotUrls).distinct(), + tvBannerUrl = left.tvBannerUrl ?: right.tvBannerUrl, + playType = left.playType ?: right.playType, + membershipTierLabel = left.membershipTierLabel ?: right.membershipTierLabel, + publisherName = left.publisherName ?: right.publisherName, + contentRatings = (left.contentRatings + right.contentRatings).distinct(), + playabilityState = left.playabilityState ?: right.playabilityState, + variants = mergedVariants, + availableStores = displayStoresForVariants(mergedVariants), + genres = (left.genres + right.genres).distinct(), + featureLabels = (left.featureLabels + right.featureLabels).distinct(), + searchText = listOfNotNull(left.searchText, right.searchText).joinToString(" ").ifBlank { null }, + lastPlayed = left.lastPlayed ?: right.lastPlayed, + isInLibrary = left.isInLibrary || right.isInLibrary, + selectedVariantIndex = if (selectedIndex >= 0) selectedIndex else left.selectedVariantIndex.coerceAtMost(max(mergedVariants.size - 1, 0)), + ) +} + +private fun mergeGameVariant(left: GameVariant, right: GameVariant): GameVariant = + left.copy( + store = left.store.takeUnless { it.isBlank() || it.equals("Unknown", ignoreCase = true) } ?: right.store, + storeUrl = left.storeUrl?.takeIf { it.isNotBlank() } ?: right.storeUrl, + supportedControls = (left.supportedControls + right.supportedControls).distinct(), + librarySelected = when { + left.librarySelected == true || right.librarySelected == true -> true + else -> left.librarySelected ?: right.librarySelected + }, + libraryStatus = when { + isOwnedLibraryStatus(left.libraryStatus) -> left.libraryStatus + isOwnedLibraryStatus(right.libraryStatus) -> right.libraryStatus + else -> left.libraryStatus ?: right.libraryStatus + }, + lastPlayedDate = left.lastPlayedDate ?: right.lastPlayedDate, + gfnStatus = left.gfnStatus ?: right.gfnStatus, + isFreeToPlay = left.isFreeToPlay || right.isFreeToPlay, + ) + +private fun variantLaunchRank(variant: GameVariant): Int = + when { + variant.librarySelected == true && isOwnedGameVariant(variant) -> 4 + isOwnedGameVariant(variant) -> 3 + variant.id.all(Char::isDigit) -> 2 + else -> 1 + } + +@Serializable +data class CatalogFilterOption( + val id: String, + val rawId: String, + val label: String, + val groupId: String, + val groupLabel: String, +) + +@Serializable +data class CatalogFilterGroup( + val id: String, + val label: String, + val options: List, +) + +@Serializable +data class CatalogSortOption( + val id: String, + val label: String, + val orderBy: String, +) + +@Serializable +data class CatalogBrowseResult( + val games: List, + val numberReturned: Int = games.size, + val numberSupported: Int = games.size, + val totalCount: Int = games.size, + val hasNextPage: Boolean = false, + val endCursor: String? = null, + val searchQuery: String = "", + val selectedSortId: String = "relevance", + val selectedFilterIds: List = emptyList(), + val filterGroups: List = emptyList(), + val sortOptions: List = emptyList(), +) + +@Serializable +data class PrintedWasteZone( + val QueuePosition: Int, + val LastUpdated: Long = 0, + val Region: String, + val eta: Long? = null, +) + +@Serializable +data class PrintedWasteServerMappingEntry( + val title: String? = null, + val region: String? = null, + val is4080Server: Boolean? = null, + val is5080Server: Boolean? = null, + val nuked: Boolean? = null, +) + +@Serializable +data class PingResult( + val url: String, + val pingMs: Long? = null, + val error: String? = null, +) + +@Serializable +data class IceServer( + val urls: List, + val username: String? = null, + val credential: String? = null, +) + +@Serializable +data class MediaConnectionInfo( + val ip: String, + val port: Int, +) + +@Serializable +data class NegotiatedStreamProfile( + val resolution: String? = null, + val fps: Int? = null, + val codec: VideoCodec? = null, + val colorQuality: ColorQuality? = null, + val enableL4S: Boolean? = null, + val enableReflex: Boolean? = null, +) + +@Serializable +data class SessionMonitorSnapshot( + val requestedResolution: String? = null, + val requestedFps: Int? = null, + val returnedResolution: String? = null, + val returnedFps: Int? = null, + val finalSelectedResolution: String? = null, +) + +@Serializable +data class SessionAdMediaFile( + val mediaFileUrl: String? = null, + val encodingProfile: String? = null, +) + +@Serializable +data class SessionAdInfo( + val adId: String, + val state: Int? = null, + val adState: Int? = null, + val adUrl: String? = null, + val mediaUrl: String? = null, + val adMediaFiles: List = emptyList(), + val clickThroughUrl: String? = null, + val adLengthInSeconds: Double? = null, + val durationMs: Long? = null, + val title: String? = null, + val description: String? = null, +) + +@Serializable +data class SessionOpportunityInfo( + val state: String? = null, + val queuePaused: Boolean? = null, + val gracePeriodSeconds: Int? = null, + val message: String? = null, + val title: String? = null, + val description: String? = null, +) + +@Serializable +data class SessionAdState( + val isAdsRequired: Boolean = false, + val sessionAdsRequired: Boolean? = null, + val isQueuePaused: Boolean? = null, + val gracePeriodSeconds: Int? = null, + val message: String? = null, + val sessionAds: List = emptyList(), + val ads: List = emptyList(), + val opportunity: SessionOpportunityInfo? = null, + val serverSentEmptyAds: Boolean = false, +) + +@Serializable +data class StreamingFeatures( + val reflex: Boolean? = null, + val bitDepth: Int? = null, + val chromaFormat: Int? = null, + val enabledL4S: Boolean? = null, + val trueHdr: Boolean? = null, +) + +@Serializable +data class SessionInfo( + val sessionId: String, + val status: Int, + val timerStartedAtMs: Long? = null, + val queuePosition: Int? = null, + val seatSetupStep: Int? = null, + val adState: SessionAdState? = null, + val zone: String = "", + val assignedZone: String? = null, + val streamingBaseUrl: String? = null, + val serverIp: String, + val signalingServer: String, + val signalingUrl: String, + val gpuType: String? = null, + val iceServers: List = emptyList(), + val mediaConnectionInfo: MediaConnectionInfo? = null, + val negotiatedStreamProfile: NegotiatedStreamProfile? = null, + val monitorSnapshot: SessionMonitorSnapshot? = null, + val requestedStreamingFeatures: StreamingFeatures? = null, + val finalizedStreamingFeatures: StreamingFeatures? = null, + val clientId: String? = null, + val deviceId: String? = null, +) + +/** + * The subset of a cloud-session snapshot that actually defines the native media transport. + * + * Queue/status and negotiated-profile fields are refreshed while a stream is connected. They are + * useful diagnostics, but treating the whole [SessionInfo] as a Compose effect key tears down a + * healthy WebRTC transport whenever one of those fields changes. + */ +internal data class NativeStreamTransportIdentity( + val sessionId: String, + val serverIp: String, + val signalingServer: String, + val signalingUrl: String, + val iceServers: List, + val mediaConnectionInfo: MediaConnectionInfo?, +) + +internal fun SessionInfo.nativeStreamTransportIdentity(): NativeStreamTransportIdentity = + NativeStreamTransportIdentity( + sessionId = sessionId, + serverIp = serverIp, + signalingServer = signalingServer, + signalingUrl = signalingUrl, + iceServers = iceServers, + mediaConnectionInfo = mediaConnectionInfo, + ) + +@Serializable +data class ActiveSessionInfo( + val sessionId: String, + val appId: Int, + val gpuType: String? = null, + val status: Int, + val queuePosition: Int? = null, + val seatSetupStep: Int? = null, + val streamingBaseUrl: String? = null, + val serverIp: String? = null, + val signalingUrl: String? = null, + val resolution: String? = null, + val fps: Int? = null, + val settingsSignature: String? = null, +) + +internal fun SessionInfo.isReadyForStream(): Boolean = + status in setOf(2, 3) && + serverIp.isNotBlank() && + signalingServer.isNotBlank() && + signalingUrl.isNotBlank() + +/** + * CloudMatch status 6 is a transient cleanup state, but the other statuses above 3 are terminal. + * In particular, a stale recovered session remains at status 7 forever and must not be treated as + * ordinary rig setup. + */ +internal fun isTerminalSessionStatus(status: Int): Boolean = status > 3 && status != 6 + +internal fun ActiveSessionInfo.isReadyForClaim(): Boolean = + status in setOf(2, 3) && !serverIp.isNullOrBlank() + +internal fun ActiveSessionInfo.matchesStreamGeometry(settings: StreamSettings): Boolean { + val activeResolution = parseResolutionPixelsOrNull(resolution) + val expectedResolution = streamResolutionPixels(settings) + val activeFps = fps?.takeIf { it > 0 } + return activeResolution == expectedResolution && activeFps == settings.fps +} + +internal fun ActiveSessionInfo.matchesStreamSettings(settings: StreamSettings): Boolean = + settingsSignature == streamSettingsSessionSignature(settings) && matchesStreamGeometry(settings) + +internal fun activeSessionRecoveryCandidate( + sessions: List, + previousSessionId: String, + launchAppId: Int?, + settings: StreamSettings, +): ActiveSessionInfo? { + val readySessions = sessions.filter { it.isReadyForClaim() } + return readySessions.firstOrNull { + it.sessionId == previousSessionId && it.matchesStreamGeometry(settings) + } ?: launchAppId?.let { appId -> + readySessions.firstOrNull { + it.appId == appId && it.matchesStreamSettings(settings) + } + } +} + +internal fun activeSessionLaunchConflict( + sessions: List, + launchAppId: Int?, + settings: StreamSettings, +): ActiveSessionInfo? = + sessions + .filter { it.status in setOf(1, 2, 3) } + .sortedWith( + compareByDescending { launchAppId != null && it.appId == launchAppId } + .thenByDescending { it.matchesStreamSettings(settings) } + .thenByDescending { it.isReadyForClaim() } + .thenBy { it.queuePosition ?: Int.MAX_VALUE }, + ) + .firstOrNull() + +internal fun parseResolutionPixelsOrNull(value: String?): Pair? { + val parts = value?.split("x") ?: return null + val width = parts.getOrNull(0)?.toIntOrNull() + val height = parts.getOrNull(1)?.toIntOrNull() + return if (width != null && height != null && width > 0 && height > 0) width to height else null +} + +data class CodecCapability( + val codec: VideoCodec, + val decoderAvailable: Boolean, + val encoderAvailable: Boolean, + val hardwareDecoder: Boolean, + val hardwareEncoder: Boolean, + val decoderName: String? = null, + val encoderName: String? = null, + val realtimeSafe: Boolean = hardwareDecoder, + val nativeDecoderAvailable: Boolean? = null, + val webRtcDecoderAvailable: Boolean? = null, + val webRtcHardwareDecoderAvailable: Boolean? = null, + val webRtcDecoderName: String? = null, + val webRtcCodecProfiles: List = emptyList(), + val maxSupportedWidth: Int? = null, + val maxSupportedHeight: Int? = null, +) + +data class RuntimeCodecReport( + val capabilities: List, + val nativeRuntimeSummary: String, + val androidTvProfile: Boolean, + val lowPowerGpuProfile: Boolean, + val constrainedRuntimeProfile: Boolean = false, +) + +data class StreamRuntimeStats( + val bitrateKbps: Int? = null, + val availableIncomingBitrateKbps: Int? = null, + val pingMs: Int? = null, + val fps: Int? = null, + val gameFps: Int? = null, + val receivedFps: Int? = null, + val decodedFps: Int? = null, + val resolution: String? = null, + val codec: String? = null, + val decodeMs: Double? = null, + val jitterMs: Double? = null, + val packetLossPct: Double? = null, + val packetsLostDelta: Long? = null, + val packetsReceivedDelta: Long? = null, + val processCpuPercent: Double? = null, + val deviceCpuCapacityPercent: Double? = null, + val cpuLogicalCoreCount: Int? = null, +) + +internal fun CodecCapability.streamingDecoderAvailable(): Boolean = + webRtcDecoderAvailable ?: decoderAvailable + +internal fun CodecCapability.streamingHardwareDecoderAvailable(): Boolean = + webRtcHardwareDecoderAvailable ?: (nativeDecoderAvailable?.let { it && hardwareDecoder } ?: hardwareDecoder) + +internal fun CodecCapability.streamingDecoderName(): String? = + webRtcDecoderName ?: decoderName + +internal fun CodecCapability.streamingRealtimeSafe(): Boolean = + streamingDecoderUsableForLaunch() + +internal fun CodecCapability.hasKnownHighResolutionAv1Failure(settings: StreamSettings): Boolean { + if (codec != VideoCodec.AV1) return false + val decoder = streamingDecoderName()?.lowercase(Locale.US).orEmpty() + if (decoder != KNOWN_AMLOGIC_AV1_DECODER) return false + val (width, height) = streamResolutionPixels(settings) + return width.toLong() * height.toLong() >= ANDROID_1440P_PIXEL_BUDGET.toLong() +} + +internal fun CodecCapability.streamingDecoderUsableForLaunch(): Boolean { + if (codec == VideoCodec.H264) return webRtcDecoderAvailable ?: decoderAvailable + + // The stream is decoded by the WebRTC decoder factory, so its successful hardware + // probe is authoritative. The Media NDK probe is only a secondary diagnostic and can + // legitimately disagree on devices whose codec is exposed through WebRTC's factory. + if (webRtcDecoderAvailable != null) { + return webRtcDecoderAvailable && webRtcHardwareDecoderAvailable == true + } + + return nativeDecoderAvailable == true && hardwareDecoder && realtimeSafe +} + +private fun RuntimeCodecReport.bestStreamingFallbackCodec(): VideoCodec = + listOf(VideoCodec.H264, VideoCodec.H265, VideoCodec.AV1) + .firstOrNull { codec -> capabilities.firstOrNull { it.codec == codec }?.streamingDecoderUsableForLaunch() == true } + ?: VideoCodec.H264 + +internal fun StreamSettings.adjustedForDevice(report: RuntimeCodecReport?): StreamSettings { + val availableSettings = withAndroidSettingsAvailability() + if (availableSettings != this) return availableSettings.adjustedForDevice(report) + + if ( + report?.androidTvProfile == true && + report.lowPowerGpuProfile && + !report.constrainedRuntimeProfile + ) { + val requestedCapability = report.capabilities.firstOrNull { it.codec == codec } + val requestedCodecUsable = requestedCapability?.streamingDecoderUsableForLaunch() ?: (codec == VideoCodec.H264) + val knownAv1Failure = requestedCapability?.hasKnownHighResolutionAv1Failure(this) == true + val usableCodec = when { + knownAv1Failure -> report.bestCodecForKnownHighResolutionAv1Failure(this) + requestedCodecUsable -> codec + else -> report.bestStreamingFallbackCodec() + } + val effectiveCodec = if (report.capabilities.firstOrNull { it.codec == usableCodec }.supportsStreamResolution(this) != false) { + usableCodec + } else { + report.bestStreamingCodecForResolution(copy(codec = usableCodec)) ?: usableCodec + } + val lowPowerProfile = copy( + codec = effectiveCodec, + colorQuality = ColorQuality.EightBit420, + fps = minOf(fps, LOW_POWER_TV_FPS_CAP), + hdrEnabled = false, + ).withStableAndroidCloudMatchProfile() + .withoutAndroidTvSharpening(report) + // A codec probe may be incomplete or conservative, especially on Android TV. It can + // choose a safer codec/FPS, but it must not silently replace the user's geometry or + // selected bitrate ceiling. + // The server-negotiated and decoded dimensions are reported separately at runtime. + return lowPowerProfile.copy( + resolution = normalizeStreamResolutionForAspect(resolution, aspectRatio), + ) + } + + val capability = report?.capabilities?.firstOrNull { it.codec == codec } + val codecSupported = capability?.streamingDecoderUsableForLaunch() ?: true + val knownAv1Failure = report?.androidTvProfile == true && + capability?.hasKnownHighResolutionAv1Failure(this) == true + val effectiveCodec = when { + knownAv1Failure -> requireNotNull(report).bestCodecForKnownHighResolutionAv1Failure(this) + !codecSupported -> requireNotNull(report).bestStreamingFallbackCodec() + capability.supportsStreamResolution(this) != false -> codec + else -> report?.bestStreamingCodecForResolution(this) ?: codec + } + + val adjusted = (if (effectiveCodec == codec) this else copy(codec = effectiveCodec)).withCodecColorCompatibility() + val compatible = when (effectiveCodec) { + VideoCodec.H264 -> adjusted.copy(colorQuality = ColorQuality.EightBit420) + VideoCodec.H265, + VideoCodec.AV1 -> adjusted.copy( + colorQuality = adjusted.androidWebRtcColorQuality(), + ) + }.withStableAndroidCloudMatchProfile() + .withoutAndroidTvSharpening(report) + return compatible.copy( + resolution = normalizeStreamResolutionForAspect(compatible.resolution, compatible.aspectRatio), + ) +} + +private fun RuntimeCodecReport.bestCodecForKnownHighResolutionAv1Failure(settings: StreamSettings): VideoCodec = + listOf(VideoCodec.H265, VideoCodec.H264) + .firstOrNull { codec -> + val capability = capabilities.firstOrNull { it.codec == codec } + capability != null && + capability.streamingDecoderUsableForLaunch() && + capability.launchResolutionSupport(settings.copy(codec = codec)) != false + } + ?: VideoCodec.H264 + +private fun RuntimeCodecReport.bestStreamingCodecForResolution(settings: StreamSettings): VideoCodec? = + listOf(VideoCodec.H265, VideoCodec.AV1, VideoCodec.H264) + .asSequence() + .filter { it != settings.codec } + .mapNotNull { candidate -> capabilities.firstOrNull { it.codec == candidate } } + .firstOrNull { capability -> + capability.streamingDecoderUsableForLaunch() && capability.supportsStreamResolution(settings) == true + } + ?.codec + +private fun CodecCapability?.launchResolutionSupport(settings: StreamSettings): Boolean? { + this ?: return null + val probedSupport = supportsStreamResolution(settings) + if (probedSupport == true) return true + + val normalized = normalizeStreamResolutionForAspect(settings.resolution, settings.aspectRatio) + val (width, height) = parseResolutionPixels(normalized) + // Some Android TV codec implementations omit or underreport VideoCapabilities even + // though WebRTC successfully opens their hardware decoder. Honor the selected 1440p + // profile in that confirmed path; keep higher unknown profiles on the safety cap. + val confirmedHardware1440pPath = streamingDecoderUsableForLaunch() && + streamingHardwareDecoderAvailable() && + width * height <= ANDROID_1440P_PIXEL_BUDGET + return if (confirmedHardware1440pPath) true else probedSupport +} + +private fun CodecCapability?.supportsStreamResolution(settings: StreamSettings): Boolean? { + this ?: return null + val maxWidth = maxSupportedWidth ?: return null + val maxHeight = maxSupportedHeight ?: return null + val normalized = normalizeStreamResolutionForAspect(settings.resolution, settings.aspectRatio) + val (width, height) = parseResolutionPixels(normalized) + val maxPixelCount = (maxWidth * maxHeight * DECODER_RESOLUTION_HEADROOM).roundToInt() + return width <= maxWidth * 2 && + height <= maxHeight * 2 && + width * height <= maxPixelCount +} + +internal fun StreamSettings.androidSafeVideoFallback(): StreamSettings = + copy( + fps = minOf(fps, 60), + codec = VideoCodec.H264, + colorQuality = ColorQuality.EightBit420, + hdrEnabled = false, + streamSharpeningEnabled = false, + ) + +private fun StreamSettings.androidWebRtcColorQuality(): ColorQuality { + val compatible = withCodecColorCompatibility() + if (compatible.hdrEnabled) return when (compatible.colorQuality) { + ColorQuality.TenBit420, + ColorQuality.TenBit444, + -> compatible.colorQuality + else -> ColorQuality.TenBit420 + } + return when (compatible.colorQuality) { + ColorQuality.EightBit420, + ColorQuality.EightBit444, + -> compatible.colorQuality + else -> ColorQuality.EightBit420 + } +} + +private fun StreamSettings.withStableAndroidCloudMatchProfile(): StreamSettings { + val normalizedResolution = normalizeStreamResolutionForAspect(resolution, aspectRatio) + // The provider's low 21:9 mode was observed at 60 FPS. Requesting the retired 1376x640 panel + // geometry at high refresh made CloudMatch select 1680x720 before the streamer cropped it. + val geometryCompatibleFps = if (normalizedResolution == LOW_ULTRAWIDE_STREAM_RESOLUTION) { + LOW_ULTRAWIDE_STREAM_MAX_FPS + } else { + MAX_ULTIMATE_STREAM_FPS + } + return copy( + resolution = normalizedResolution, + fps = minOf(fps, geometryCompatibleFps), + hdrEnabled = hdrEnabled && codec != VideoCodec.H264, + ) +} + +internal fun StreamSettings.lowPowerPerformanceWarningReasons(report: RuntimeCodecReport?): List { + if (report?.lowPowerGpuProfile != true && report?.constrainedRuntimeProfile != true) return emptyList() + + val normalizedResolution = normalizeStreamResolutionForAspect(resolution, aspectRatio) + val (width, height) = parseResolutionPixels(normalizedResolution) + return buildList { + if (width * height > LOW_POWER_RECOMMENDED_PIXEL_COUNT) add("$normalizedResolution resolution") + if (fps > LOW_POWER_RECOMMENDED_FPS) add("$fps FPS") + if (maxBitrateMbps > LOW_POWER_RECOMMENDED_BITRATE_MBPS) add("$maxBitrateMbps Mbps bitrate") + if (hdrEnabled) add("HDR") + if (streamSharpeningEnabled) add("stream sharpening") + } +} + +private fun StreamSettings.withoutAndroidTvSharpening(report: RuntimeCodecReport?): StreamSettings = + if ( + report?.androidTvProfile == true && + report.constrainedRuntimeProfile == false && + streamSharpeningEnabled + ) { + copy(streamSharpeningEnabled = false) + } else { + this + } + +private fun StreamResolutionOption.pixelCount(): Int { + val (width, height) = parseResolutionPixels(value) + return width * height +} + +private const val LOW_POWER_TV_FPS_CAP = 60 +private const val LEGACY_PORTAL_STREAM_RESOLUTION = "1376x640" +private const val LEGACY_PORTAL_STREAM_ASPECT = "19.5:9" +private const val LOW_ULTRAWIDE_STREAM_RESOLUTION = "1376x590" +private const val LOW_ULTRAWIDE_STREAM_MAX_FPS = 60 +private const val MAX_STANDARD_STREAM_FPS = 60 +private const val MAX_ULTIMATE_STREAM_FPS = 360 +private const val MIN_STREAM_FPS = 30 +private const val LOW_POWER_RECOMMENDED_PIXEL_COUNT = 1280 * 720 +private const val LOW_POWER_RECOMMENDED_FPS = 30 +private const val LOW_POWER_RECOMMENDED_BITRATE_MBPS = 12 +private const val ANDROID_1440P_PIXEL_BUDGET = 2560 * 1440 +private const val KNOWN_AMLOGIC_AV1_DECODER = "omx.amlogic.av1.decoder.awesome" +private const val DECODER_RESOLUTION_HEADROOM = 1.4f diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/MouseMoveBurstLimiter.kt b/android/app/src/main/java/com/opencloudgaming/opennow/MouseMoveBurstLimiter.kt new file mode 100644 index 000000000..e273f98ff --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/MouseMoveBurstLimiter.kt @@ -0,0 +1,83 @@ +package com.opencloudgaming.opennow + +/** One mouse movement packet after burst limiting has combined adjacent deltas. */ +internal data class MouseMoveBatch( + val dx: Int, + val dy: Int, + val partiallyReliable: Boolean, +) + +/** + * Bounds high-rate physical mouse traffic without delaying the first movement in a burst. + * + * The first delta after an idle period is returned immediately. Deltas that arrive before + * [minimumIntervalMs] has elapsed are accumulated for one trailing packet, preserving total + * movement while preventing 500 Hz mice from creating 500 SCTP sends and worker tasks per second. + * The state machine is deliberately independent of Android/WebRTC so scheduling stays testable. + */ +internal class MouseMoveBurstLimiter( + private val minimumIntervalMs: Long, +) { + init { + require(minimumIntervalMs > 0L) + } + + private var lastSentAtMs: Long? = null + private var pendingDx = 0L + private var pendingDy = 0L + private var pendingPartiallyReliable = true + + val hasPendingMovement: Boolean + get() = pendingDx != 0L || pendingDy != 0L + + /** + * Returns a packet for immediate sending, or null when this delta belongs in the trailing + * packet for the current interval. + */ + fun offer(dx: Int, dy: Int, partiallyReliable: Boolean, nowMs: Long): MouseMoveBatch? { + if (dx == 0 && dy == 0) return null + val lastSent = lastSentAtMs + if (lastSent == null || nowMs - lastSent >= minimumIntervalMs) { + addPending(dx, dy, partiallyReliable) + return takePending(nowMs) + } + addPending(dx, dy, partiallyReliable) + return null + } + + fun delayUntilFlushMs(nowMs: Long): Long? { + if (!hasPendingMovement) return null + val lastSent = lastSentAtMs ?: return 0L + return (minimumIntervalMs - (nowMs - lastSent)).coerceAtLeast(0L) + } + + /** Flushes accumulated movement now, preserving all deltas and reliability requirements. */ + fun flush(nowMs: Long): MouseMoveBatch? = takePending(nowMs) + + fun reset() { + lastSentAtMs = null + pendingDx = 0L + pendingDy = 0L + pendingPartiallyReliable = true + } + + private fun addPending(dx: Int, dy: Int, partiallyReliable: Boolean) { + pendingDx += dx.toLong() + pendingDy += dy.toLong() + pendingPartiallyReliable = pendingPartiallyReliable && partiallyReliable + } + + private fun takePending(nowMs: Long): MouseMoveBatch? { + if (!hasPendingMovement) return null + val batch = MouseMoveBatch( + dx = pendingDx.coerceIn(Int.MIN_VALUE.toLong(), Int.MAX_VALUE.toLong()).toInt(), + dy = pendingDy.coerceIn(Int.MIN_VALUE.toLong(), Int.MAX_VALUE.toLong()).toInt(), + partiallyReliable = pendingPartiallyReliable, + ) + pendingDx = 0L + pendingDy = 0L + pendingPartiallyReliable = true + lastSentAtMs = nowMs + return batch + } +} diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/NativeTouchGames.kt b/android/app/src/main/java/com/opencloudgaming/opennow/NativeTouchGames.kt new file mode 100644 index 000000000..a4b341ed7 --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/NativeTouchGames.kt @@ -0,0 +1,88 @@ +package com.opencloudgaming.opennow + +/** + * Which games get native touch. + * + * GeForce NOW ships no per-game touch layouts. The games that "support touch" are simply the ones + * whose Windows build already reacts to a Windows digitizer — invariably because they also ship on + * phones or tablets — and they switch to their own mobile UI the moment one appears. So this file + * decides nothing about *how* touch works, only *where* we turn it on. + * + * The catalog carries the answer: a variant's `supportedControls` includes `TOUCHSCREEN`, which is + * the same capability signal used by the official client. Using that signal avoids a title-based + * allowlist that breaks for localized names and needs manual updates for every new touch game. + */ +/** The value the catalog uses to mark a touch-capable variant. */ +internal const val SUPPORTED_CONTROL_TOUCHSCREEN = "TOUCHSCREEN" +internal const val CATALOG_FILTER_TOUCHSCREEN = "opennow:supported-controls:touchscreen" + +/** Whether the catalog itself claims this game takes touch, across any of its variants. */ +internal fun catalogClaimsTouchSupport(game: GameInfo): Boolean = + game.variants.any { variant -> + variant.supportedControls.any { it.equals(SUPPORTED_CONTROL_TOUCHSCREEN, ignoreCase = true) } + } + +internal fun nativeTouchModeLabel(mode: NativeTouchMode): String = when (mode) { + NativeTouchMode.Auto -> "Supported games" + NativeTouchMode.Off -> "Off" + NativeTouchMode.Always -> "Every game" +} + +/** + * Preserve a mode already saved by an older release. New installs start in Auto, which remains + * limited to catalog variants that explicitly advertise TOUCHSCREEN support. + */ +internal fun AndroidTouchSettings.effectiveNativeTouchMode(): NativeTouchMode = nativeTouchMode + +internal fun AndroidTouchSettings.withNativeTouchMode(mode: NativeTouchMode): AndroidTouchSettings = copy( + nativeTouchMode = mode, + nativeTouchOptedIn = mode != NativeTouchMode.Off, +) + +internal fun shouldUseNativeTouch(mode: NativeTouchMode, game: GameInfo?): Boolean = when (mode) { + NativeTouchMode.Off -> false + NativeTouchMode.Always -> true + NativeTouchMode.Auto -> game != null && catalogClaimsTouchSupport(game) +} + +/** + * Native touch now uses the native Android touch identity, which keeps the desktop allocation + * matrix. Auto can therefore follow the catalog capability at high resolution and high FPS too. + */ +internal fun shouldUseNativeTouch( + mode: NativeTouchMode, + game: GameInfo?, + @Suppress("UNUSED_PARAMETER") + streamSettings: StreamSettings, +): Boolean = shouldUseNativeTouch(mode, game) + +/** + * Resolves native game touch for the active stream after the player has made a session-level + * choice. A catalog touch capability is useful guidance, but it must not lock the player out of + * OpenNOW's virtual controller. + */ +internal fun shouldUseNativeTouchForStream( + mode: NativeTouchMode, + game: GameInfo?, + streamSettings: StreamSettings, + preferVirtualController: Boolean, + preferKeyboardMouse: Boolean = false, +): Boolean = !preferVirtualController && + !preferKeyboardMouse && + shouldUseNativeTouch(mode, game, streamSettings) + +/** One line per session showing the catalog signal and the resulting native-touch decision. */ +internal fun nativeTouchDiagnostics( + game: GameInfo, + enabled: Boolean, + physicalMouseConnected: Boolean = false, +): String { + val controls = game.variants + .flatMap { it.supportedControls } + .distinct() + .joinToString("|") + .ifBlank { "none" } + return "native touch enabled=$enabled physicalMouse=$physicalMouseConnected id=${game.id} title=${game.title} " + + "catalogTouch=${catalogClaimsTouchSupport(game)} " + + "supportedControls=$controls" +} diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowAnalytics.kt b/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowAnalytics.kt new file mode 100644 index 000000000..e2affaeaf --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowAnalytics.kt @@ -0,0 +1,143 @@ +package com.opencloudgaming.opennow + +import android.app.Application +import android.util.Log +import com.posthog.PostHog +import com.posthog.android.PostHogAndroid +import com.posthog.android.PostHogAndroidConfig + +private const val ANALYTICS_LOG_TAG = "OpenNowAnalytics" +private const val ANALYTICS_FLUSH_INTERVAL_SECONDS = 10 + +internal object OpenNowAnalytics { + fun setup(application: Application, settings: AppSettings) { + val token = BuildConfig.POSTHOG_PROJECT_TOKEN.trim() + if (token.isEmpty()) { + Log.w(ANALYTICS_LOG_TAG, "PostHog disabled because no project token is configured.") + return + } + + val config = PostHogAndroidConfig( + apiKey = token, + host = BuildConfig.POSTHOG_HOST, + ).apply { applyOpenNowSettings(settings) } + + runCatching { + PostHogAndroid.setup(application, config) + applyOptOut(!settings.analyticsSharingEnabled) + }.onFailure { error -> + Log.w(ANALYTICS_LOG_TAG, "PostHog setup failed.", error) + } + } + + fun applyOptOut(optedOut: Boolean) { + runPostHogOperation("opt-out update") { + if (optedOut) { + PostHog.optOut() + } else { + PostHog.optIn() + } + } + } + + fun capture(event: String, properties: Map? = null) { + runPostHogOperation("capture") { + PostHog.capture( + event = event, + properties = sanitizedAnalyticsProperties(properties), + ) + flushReleaseQueue() + } + } + + fun reset() { + runPostHogOperation("reset") { + val optedOut = PostHog.isOptOut() + PostHog.reset() + applyOptOut(optedOut) + } + } + + private fun flushReleaseQueue() { + if (BuildConfig.DEBUG) return + PostHog.flush() + } + + private inline fun runPostHogOperation(operation: String, block: () -> Unit) { + runCatching(block).onFailure { error -> + Log.w(ANALYTICS_LOG_TAG, "PostHog $operation failed.", error) + } + } +} + +internal fun PostHogAndroidConfig.applyOpenNowSettings(settings: AppSettings) { + optOut = !settings.analyticsSharingEnabled + captureApplicationLifecycleEvents = true + captureDeepLinks = false + captureScreenViews = true + flushIntervalSeconds = ANALYTICS_FLUSH_INTERVAL_SECONDS + sessionReplay = false + sessionReplayConfig.apply { + maskAllTextInputs = true + maskAllImages = true + screenshot = false + captureLogcat = false + } + errorTrackingConfig.autoCapture = true + addBeforeSend { event -> + event.copy( + properties = sanitizedAnalyticsProperties( + properties = event.properties, + redactExceptionText = event.event == "\$exception", + ).toMutableMap(), + ) + } +} + +internal fun sanitizedAnalyticsProperties( + properties: Map?, + redactExceptionText: Boolean = false, +): Map = + buildMap { + put("\$geoip_disable", true) + properties.orEmpty().forEach { (key, value) -> + if (!isSensitiveAnalyticsProperty(key, redactExceptionText)) { + put(key, sanitizeAnalyticsValue(value, redactExceptionText)) + } + } + } + +private fun sanitizeAnalyticsValue(value: Any, redactExceptionText: Boolean): Any = + when (value) { + is String -> sanitizeDiagnosticExport(value).take(500) + is Map<*, *> -> value.entries + .mapNotNull { (key, nestedValue) -> + val stringKey = key as? String ?: return@mapNotNull null + val presentValue = nestedValue ?: return@mapNotNull null + stringKey.takeUnless { isSensitiveAnalyticsProperty(it, redactExceptionText) } + ?.let { it to sanitizeAnalyticsValue(presentValue, redactExceptionText) } + } + .toMap() + is Iterable<*> -> value.mapNotNull { it?.let { item -> sanitizeAnalyticsValue(item, redactExceptionText) } } + is Array<*> -> value.mapNotNull { it?.let { item -> sanitizeAnalyticsValue(item, redactExceptionText) } } + else -> value + } + +private fun isSensitiveAnalyticsProperty(key: String, redactExceptionText: Boolean): Boolean { + val normalized = key.lowercase().filter(Char::isLetterOrDigit) + return (redactExceptionText && normalized in setOf("message", "value", "exceptionmessage")) || normalized in setOf( + "authorization", + "credential", + "cookie", + "displayname", + "email", + "errormessage", + "password", + "query", + "searchquery", + "secret", + "token", + "userid", + "username", + ) || normalized.endsWith("token") || normalized.endsWith("credential") +} diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowApplication.kt b/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowApplication.kt new file mode 100644 index 000000000..aa89387fc --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowApplication.kt @@ -0,0 +1,110 @@ +package com.opencloudgaming.opennow + +import android.app.Application +import android.content.pm.PackageManager +import android.content.res.Configuration +import android.util.Log +import coil3.ImageLoader +import coil3.PlatformContext +import coil3.SingletonImageLoader +import coil3.disk.DiskCache +import coil3.disk.directory +import coil3.memory.MemoryCache +import coil3.network.okhttp.OkHttpNetworkFetcherFactory +import coil3.request.crossfade +import okio.Path.Companion.toOkioPath +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class OpenNowApplication : Application(), SingletonImageLoader.Factory { + private val startupScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val startupDataReady = CompletableDeferred() + internal val httpClient by lazy(::defaultHttpClient) + internal val authStore by lazy { AuthStore(this) } + internal val authRepository by lazy { GfnAuthRepository(this, authStore, httpClient) } + internal val localTvConnector by lazy { LocalTvConnector() } + internal val diagnosticHistoryStore by lazy { DiagnosticHistoryStore(filesDir) } + + override fun onCreate() { + super.onCreate() + runCatching { diagnosticHistoryStore.beginAppRun() } + .onFailure { error -> + Log.w(OPENNOW_DEBUG_LOG_TAG, "Could not rotate diagnostic history", error) + } + + startupScope.launch { + val settings = runCatching { + SettingsStore(this@OpenNowApplication).settings.value.also { + // Warm secure auth and run its one-time migration on the same background path. + authStore.state.value + } + }.getOrElse { AppSettings() } + startupDataReady.complete(Unit) + if (isTelevisionDevice()) { + delay(TV_BACKGROUND_SERVICE_START_DELAY_MS) + } + withContext(Dispatchers.Main) { + initializeBackgroundServices(settings) + } + } + } + + /** + * Coil's default loader builds its own OkHttp client, which means a second connection pool, + * dispatcher and thread pool alongside the one the API already uses — and no shared TLS session + * reuse with the CDN. Handing it [httpClient] collapses that back to one, and the caches below + * are sized deliberately rather than left to a fraction of whatever the device reports. + */ + override fun newImageLoader(context: PlatformContext): ImageLoader = + ImageLoader.Builder(context) + .components { + add(OkHttpNetworkFetcherFactory(callFactory = { httpClient })) + } + .memoryCache { + // Poster art is re-shown constantly while scrolling a grid; this is the cache that + // keeps a fling from re-decoding every bitmap it passes. + MemoryCache.Builder() + .maxSizePercent(context, IMAGE_MEMORY_CACHE_FRACTION) + .build() + } + .diskCache { + DiskCache.Builder() + .directory(cacheDir.resolve(IMAGE_DISK_CACHE_DIR).toOkioPath()) + .maxSizeBytes(IMAGE_DISK_CACHE_BYTES) + .build() + } + // Artwork that is already in memory should appear instantly; a fade on every cell makes + // a fast grid look slower than it is. + .crossfade(false) + .build() + + internal suspend fun awaitStartupData() { + startupDataReady.await() + } + + override fun onTerminate() { + localTvConnector.close() + super.onTerminate() + } + + private fun initializeBackgroundServices(settings: AppSettings) { + OpenNowAnalytics.setup(this, settings) + AndroidAuthRefreshScheduler.schedule(this) + } + + private fun isTelevisionDevice(): Boolean = + packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK) || + resources.configuration.uiMode and Configuration.UI_MODE_TYPE_MASK == Configuration.UI_MODE_TYPE_TELEVISION + + private companion object { + const val TV_BACKGROUND_SERVICE_START_DELAY_MS = 2_500L + const val IMAGE_MEMORY_CACHE_FRACTION = 0.25 + const val IMAGE_DISK_CACHE_DIR = "image_cache" + const val IMAGE_DISK_CACHE_BYTES = 256L * 1024 * 1024 + } +} diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowButtons.kt b/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowButtons.kt new file mode 100644 index 000000000..ed17a49ce --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowButtons.kt @@ -0,0 +1,68 @@ +package com.opencloudgaming.opennow + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.RowScope +import androidx.compose.material3.Button as MaterialButton +import androidx.compose.material3.ButtonColors +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ButtonElevation +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.staticCompositionLocalOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.dp + +internal val LocalControllerFocusEnabled = staticCompositionLocalOf { false } + +/** + * Shared primary action button with an unmistakable controller/TV focus state. + * Touch does not normally focus buttons, so the treatment stays out of the phone UI. + */ +@Composable +internal fun Button( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + shape: Shape = ButtonDefaults.shape, + colors: ButtonColors = ButtonDefaults.buttonColors(), + elevation: ButtonElevation? = ButtonDefaults.buttonElevation(), + border: BorderStroke? = null, + contentPadding: PaddingValues = ButtonDefaults.ContentPadding, + interactionSource: MutableInteractionSource? = null, + content: @Composable RowScope.() -> Unit, +) { + val controllerFocusEnabled = LocalControllerFocusEnabled.current + var focused by remember { mutableStateOf(false) } + + MaterialButton( + onClick = onClick, + modifier = modifier.onFocusChanged { focusState -> + focused = focusState.isFocused || focusState.hasFocus + }, + enabled = enabled, + shape = shape, + colors = colors, + elevation = elevation, + border = if (focused && controllerFocusEnabled && LocalAbsoluteCinemaEffects.current) { + BorderStroke(4.dp, LocalActiveSelectionColor.current) + } else { + border + }, + contentPadding = contentPadding, + interactionSource = interactionSource, + content = content, + ) +} + +internal fun shouldShowControllerFocus( + focused: Boolean, + tvProfile: Boolean, + physicalControllerConnected: Boolean, +): Boolean = focused && (tvProfile || physicalControllerConnected) diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowCatalogControls.kt b/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowCatalogControls.kt new file mode 100644 index 000000000..8031d34bf --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowCatalogControls.kt @@ -0,0 +1,1314 @@ +package com.opencloudgaming.opennow + +import android.net.Uri +import android.os.SystemClock +import androidx.activity.compose.BackHandler +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable +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.ColumnScope +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.AssistChip +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.key +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.contentDescription +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.Dp +import coil3.compose.AsyncImage +import kotlinx.coroutines.launch +import java.io.File +import java.util.Locale +import com.opencloudgaming.opennow.ui.theme.LocalReduceMotion +import com.opencloudgaming.opennow.ui.theme.OpenNowPalette +import kotlin.math.PI +import kotlin.math.max +import kotlin.math.sin + +@Composable +private fun catalogSortDisplayLabel(sortId: String, fallback: String): String = + when (catalogSortKind(sortId)) { + CatalogSortKind.Relevance -> fallback + CatalogSortKind.Popular -> stringResource(R.string.catalog_sort_popular) + CatalogSortKind.NewlyAdded -> stringResource(R.string.catalog_sort_new_games) + CatalogSortKind.LastPlayed -> stringResource(R.string.catalog_sort_last_played) + CatalogSortKind.Other -> fallback + } + +@Composable +internal fun SortPicker( + options: List, + selected: String, + onSelect: (String) -> Unit, + modifier: Modifier = Modifier, + compact: Boolean = false, +) { + val labels = options.ifEmpty { + listOf(CatalogSortOption(DEFAULT_CATALOG_SORT_ID, "Most Popular", "")) + } + val selectedLabel = labels.firstOrNull { it.id == selected }?.label ?: labels.first().label + var expanded by remember { mutableStateOf(false) } + var focused by remember { mutableStateOf(false) } + BackHandler(enabled = expanded) { expanded = false } + val controlShape = RoundedCornerShape(999.dp) + val controlColor = Color.White.copy(alpha = 0.1f) + Box(modifier) { + OutlinedButton( + onClick = { expanded = true }, + modifier = Modifier + .fillMaxWidth() + .height(if (compact) TopBarCompactControlHeight else 40.dp) + .onFocusChanged { focused = it.isFocused || it.hasFocus }, + shape = controlShape, + border = null, + colors = ButtonDefaults.outlinedButtonColors( + containerColor = controlColor, + contentColor = TextPrimary, + ), + contentPadding = PaddingValues(horizontal = if (compact) 8.dp else 12.dp), + ) { + Text( + "Sort: ${catalogSortDisplayLabel(selected, selectedLabel)}", + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = if (compact) MaterialTheme.typography.labelMedium else MaterialTheme.typography.labelLarge, + ) + } + InteractionFocusFrame( + visible = focused, + cornerRadius = 999.dp, + cinemaEffectEnabled = LocalAbsoluteCinemaEverywhere.current, + ) + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + labels.forEach { option -> + DropdownMenuItem( + text = { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(if (option.id == selected) "✓" else "", modifier = Modifier.width(24.dp)) + Text(catalogSortDisplayLabel(option.id, option.label)) + } + }, + onClick = { + expanded = false + onSelect(option.id) + }, + ) + } + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +internal fun SelectedFilterChips(options: List, selectedIds: List, onToggle: (String) -> Unit) { + val selectedOptions = options.filter { it.id in selectedIds } + if (selectedOptions.isEmpty()) return + FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + selectedOptions.take(4).forEach { option -> + AssistChip( + onClick = { onToggle(option.id) }, + label = { Text(option.label, maxLines = 1, overflow = TextOverflow.Ellipsis) }, + border = BorderStroke(2.dp, Color.White.copy(alpha = 0.92f)), + ) + } + if (selectedOptions.size > 4) { + AssistChip(onClick = {}, label = { Text("+${selectedOptions.size - 4}") }) + } + } +} + +private val CATALOG_VISIBLE_FILTER_GROUP_IDS = setOf("digital_store", "genre", "subscriptions") + +internal fun catalogVisibleFilterGroups(groups: List): List = + groups.filter { it.id in CATALOG_VISIBLE_FILTER_GROUP_IDS } + +/** + * The filter rows for [groups], plus OpenNOW's own touch-controls filter. + * + * Deduplicated by id. The provider hands back one flat id namespace across groups, so the same + * option can legitimately appear under both `digital_store` and `subscriptions` — and the lists + * built here are rendered by `LazyColumn`/`items(key = ...)`, which throws on a repeated key. That + * crash only reproduces against accounts whose catalogue happens to carry an overlap, which is why + * the invariant belongs here rather than at each call site. + */ +internal fun catalogFilterOptions( + groups: List, + touchFilterLabel: String, + controlsGroupLabel: String, +): List = + ( + groups.flatMap { group -> group.options.take(if (group.id == "genre") 10 else group.options.size) } + + CatalogFilterOption( + id = CATALOG_FILTER_TOUCHSCREEN, + rawId = SUPPORTED_CONTROL_TOUCHSCREEN, + label = touchFilterLabel, + groupId = "supported_controls", + groupLabel = controlsGroupLabel, + ) + ).distinctBy { it.id } + +/** + * Remembers [catalogFilterOptions] for the current catalogue. + * + * The uncached version ran on every recomposition — including every frame of a grid scroll — and + * allocated a fresh list each time. That is invisible on a fast phone and is exactly the kind of + * steady allocation that pushes a low-RAM device into continuous GC. + */ +@Composable +internal fun rememberCatalogFilterOptions(groups: List): List { + val touchFilterLabel = stringResource(R.string.catalog_filter_touch_controls) + val controlsGroupLabel = stringResource(R.string.catalog_filter_controls_group) + return remember(groups, touchFilterLabel, controlsGroupLabel) { + catalogFilterOptions(groups, touchFilterLabel, controlsGroupLabel) + } +} + +@Composable +internal fun FilterMenu( + options: List, + selectedIds: List, + onToggle: (String) -> Unit, + compact: Boolean = false, +) { + var expanded by remember { mutableStateOf(false) } + var focused by remember { mutableStateOf(false) } + val filterControlShape = RoundedCornerShape(999.dp) + val filterControlColor = Color.White.copy(alpha = 0.1f) + Box { + OutlinedButton( + onClick = { expanded = true }, + modifier = Modifier + .height(if (compact) TopBarCompactControlHeight else 36.dp) + .onFocusChanged { focused = it.isFocused || it.hasFocus }, + shape = filterControlShape, + border = null, + colors = ButtonDefaults.outlinedButtonColors( + containerColor = filterControlColor, + contentColor = TextPrimary, + ), + contentPadding = PaddingValues(horizontal = 10.dp), + ) { + Text(if (selectedIds.isEmpty()) "Filters" else "Filters ${selectedIds.size}", maxLines = 1, style = MaterialTheme.typography.labelMedium) + } + InteractionFocusFrame( + visible = focused, + cornerRadius = 999.dp, + cinemaEffectEnabled = LocalAbsoluteCinemaEverywhere.current, + ) + if (expanded) { + AlertDialog( + onDismissRequest = { expanded = false }, + title = { + Text( + stringResource(R.string.catalog_filters), + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.titleMedium, + color = TextPrimary, + ) + }, + text = { + LazyColumn( + modifier = Modifier.fillMaxHeight(0.6f), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + items(options) { option -> + val isSelected = option.id in selectedIds + var rowFocused by remember { mutableStateOf(false) } + Box(Modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .onFocusChanged { rowFocused = it.isFocused || it.hasFocus } + .background(if (rowFocused) Color.White.copy(alpha = 0.08f) else Color.Transparent) + .clickable { onToggle(option.id) } + .padding(horizontal = 8.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox(checked = isSelected, onCheckedChange = null) + Spacer(Modifier.width(12.dp)) + Text(option.label, style = MaterialTheme.typography.bodyLarge, color = TextPrimary) + } + InteractionFocusFrame( + visible = rowFocused, + cornerRadius = 8.dp, + cinemaEffectEnabled = LocalAbsoluteCinemaEverywhere.current, + ) + } + } + } + }, + confirmButton = { + Button(onClick = { expanded = false }) { + Text(stringResource(R.string.stream_panel_done)) + } + } + ) + } + } +} + +/** One compact top-bar entry point for the complete catalogue ordering and filtering surface. */ +@Composable +internal fun CatalogSortFilterMenu( + sortOptions: List, + selectedSortId: String, + filterOptions: List, + selectedFilterIds: List, + onSortChange: (String) -> Unit, + onFilterToggle: (String) -> Unit, + modifier: Modifier = Modifier, + focusRequester: FocusRequester? = null, + leadingFocusRequester: FocusRequester? = null, +) { + // Same flat provider id namespace as the filter options — deduplicate before the keyed + // `items` below turns a repeated id into a crash. + val sorts = remember(sortOptions) { + sortOptions.distinctBy { it.id }.ifEmpty { + listOf(CatalogSortOption(DEFAULT_CATALOG_SORT_ID, "Most Popular", "")) + } + } + var expanded by remember { mutableStateOf(false) } + BackHandler(enabled = expanded) { expanded = false } + val description = if (selectedFilterIds.isEmpty()) { + stringResource(R.string.catalog_sort_filter) + } else { + stringResource(R.string.catalog_sort_filter_active, selectedFilterIds.size) + } + var focused by remember { mutableStateOf(false) } + Box(modifier) { + Surface( + shape = RoundedCornerShape(14.dp), + color = Color.White.copy(alpha = 0.1f), + border = null, + ) { + IconButton( + onClick = { expanded = true }, + modifier = Modifier + .size(40.dp) + .then(focusRequester?.let { Modifier.focusRequester(it) } ?: Modifier) + .then( + leadingFocusRequester?.let { leading -> + Modifier.focusProperties { + left = leading + up = leading + } + } ?: Modifier, + ) + .onFocusChanged { focused = it.isFocused }, + ) { + Icon( + painter = painterResource(R.drawable.ic_sort_filter), + contentDescription = description, + tint = if (selectedFilterIds.isEmpty()) TextPrimary else LocalSelectionTintColor.current, + modifier = Modifier.size(22.dp), + ) + } + } + InteractionFocusFrame( + visible = focused, + cornerRadius = 14.dp, + cinemaEffectEnabled = LocalAbsoluteCinemaEverywhere.current, + ) + if (expanded) { + AlertDialog( + onDismissRequest = { expanded = false }, + title = { + Text( + stringResource(R.string.catalog_sort_filter), + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.titleMedium, + color = TextPrimary, + ) + }, + text = { + LazyColumn( + modifier = Modifier.fillMaxHeight(0.68f), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + item { + Text( + stringResource(R.string.catalog_sort_section), + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp), + ) + } + items(sorts, key = { "sort:${it.id}" }) { option -> + val selected = option.id == selectedSortId + CatalogMenuChoiceRow( + label = catalogSortDisplayLabel(option.id, option.label), + selected = selected, + onClick = { onSortChange(option.id) }, + ) + } + if (filterOptions.isNotEmpty()) { + item { + Text( + stringResource(R.string.catalog_filter_section), + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 12.dp), + ) + } + items(filterOptions, key = { "filter:${it.id}" }) { option -> + val selected = option.id in selectedFilterIds + CatalogMenuChoiceRow( + label = option.label, + selected = selected, + checkbox = true, + onClick = { onFilterToggle(option.id) }, + ) + } + } + } + }, + confirmButton = { + androidx.compose.material3.Button(onClick = { expanded = false }) { + Text(stringResource(R.string.stream_panel_done)) + } + }, + ) + } + } +} + +@Composable +private fun CatalogMenuChoiceRow( + label: String, + selected: Boolean, + checkbox: Boolean = false, + onClick: () -> Unit, +) { + var focused by remember { mutableStateOf(false) } + val shape = RoundedCornerShape(9.dp) + Box(Modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(shape) + .onFocusChanged { focused = it.isFocused || it.hasFocus } + .background( + when { + focused -> Color.White.copy(alpha = 0.1f) + selected -> LocalSelectionTintColor.current.copy(alpha = 0.12f) + else -> Color.Transparent + }, + ) + .clickable(onClick = onClick) + .padding(horizontal = 8.dp, vertical = 7.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (checkbox) { + Checkbox(checked = selected, onCheckedChange = null) + } else { + Text(if (selected) "✓" else "", modifier = Modifier.width(36.dp), color = LocalSelectionTintColor.current) + } + Spacer(Modifier.width(if (checkbox) 10.dp else 0.dp)) + Text( + label, + color = TextPrimary, + fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal, + modifier = Modifier.weight(1f), + ) + } + InteractionFocusFrame( + visible = focused, + cornerRadius = 9.dp, + cinemaEffectEnabled = LocalAbsoluteCinemaEverywhere.current, + ) + } +} + +@Composable +internal fun PrintedWasteSelector( + state: OpenNowUiState, + game: GameInfo, + viewModel: OpenNowViewModel, + modifier: Modifier = Modifier, +) { + BackHandler(onBack = viewModel::dismissPrintedWasteSelector) + val zones = remember(state.printedWasteQueue, state.printedWasteMapping, state.printedWastePings) { + state.printedWasteQueue + .filter { (zoneId, _) -> isStandardPrintedWasteZone(zoneId) && state.printedWasteMapping[zoneId]?.nuked != true } + .map { (zoneId, zone) -> + val routingUrl = printedWasteZoneUrl(zoneId) + PrintedWasteZoneOption( + zoneId = zoneId, + zone = zone, + routingUrl = routingUrl, + pingMs = state.printedWastePings[routingUrl], + ) + } + } + val autoZone = remember(zones) { recommendedPrintedWasteZone(zones) } + // One row per physical location rather than per server id — see PrintedWasteZones.kt. + val regionGroups = remember(zones, state.printedWasteMapping) { + val maxPing = zones.mapNotNull { it.pingMs }.maxOrNull()?.coerceAtLeast(1L) ?: 1L + val maxQueue = zones.maxOfOrNull { it.zone.QueuePosition }?.coerceAtLeast(1) ?: 1 + printedWasteRegionGroups( + printedWasteLocations(zones, state.printedWasteMapping), + maxPing = maxPing, + maxQueue = maxQueue, + ) + } + val locations = remember(regionGroups) { regionGroups.flatMap { it.second } } + // Match by name, not by id: the recommendation and the fold use slightly different tiebreaks, + // so the recommended server can end up as an alternate inside its location rather than its + // primary. Matching on id there would drop the "best route" card entirely. + val autoLocation = remember(locations, autoZone, state.printedWasteMapping) { + val autoTitle = autoZone?.let { + printedWasteZoneTitle(it.zoneId, state.printedWasteMapping[it.zoneId]) + } + locations.firstOrNull { it.title == autoTitle } + } + var selectedZoneId by remember(game.id, locations) { + mutableStateOf((autoLocation ?: locations.firstOrNull())?.primary?.zoneId) + } + val selectedZone = locations.firstOrNull { it.primary.zoneId == selectedZoneId }?.primary ?: autoZone + val context = LocalContext.current + + BoxWithConstraints( + Modifier + .fillMaxSize() + .lockedFocusGroup() + .background(Color.Black.copy(alpha = 0.72f)) + .clickable(enabled = false) {}, + ) { + val phoneLandscape = isPhoneLandscape(maxWidth, maxHeight) + Box( + Modifier.fillMaxSize(), + contentAlignment = if (phoneLandscape) Alignment.CenterEnd else Alignment.Center, + ) { + Card( + modifier = modifier + .then( + if (phoneLandscape) { + Modifier + .padding(end = 12.dp) + .fillMaxWidth(0.9f) + .fillMaxHeight(0.9f) + } else { + Modifier + .fillMaxWidth(0.94f) + .fillMaxHeight(0.82f) + }, + ), + colors = CardDefaults.cardColors(containerColor = Panel), + shape = RoundedCornerShape(22.dp), + ) { + if (phoneLandscape) { + Row( + Modifier.fillMaxSize().padding(14.dp), + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + PrintedWasteGameSummary( + game = game, + modifier = Modifier + .width(190.dp) + .fillMaxHeight(), + ) + PrintedWasteOptionsColumn( + state = state, + regionGroups = regionGroups, + locations = locations, + selectedZoneId = selectedZoneId, + selectedZone = selectedZone, + autoLocation = autoLocation, + showRecommendedCard = true, + onSelectZone = { selectedZoneId = it }, + onRetry = viewModel::refreshPrintedWasteQueues, + onDismiss = viewModel::dismissPrintedWasteSelector, + onDefault = { viewModel.launchWithPrintedWaste(null) }, + onLaunch = { viewModel.launchWithPrintedWaste(selectedZone?.routingUrl) }, + modifier = Modifier + .weight(1f) + .fillMaxHeight(), + ) + } + } else { + Column(Modifier.fillMaxSize().padding(18.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + UrlImage( + gameTvBannerImageUrl(context, game), + Modifier + .width(98.dp) + .aspectRatio(16f / 9f) + .clip(RoundedCornerShape(12.dp)), + ) + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f)) { + Text(game.title, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(stringResource(R.string.catalog_free_tier_routing), color = TextMuted, style = MaterialTheme.typography.bodySmall) + } + } + PrintedWasteOptionsColumn( + state = state, + regionGroups = regionGroups, + locations = locations, + selectedZoneId = selectedZoneId, + selectedZone = selectedZone, + autoLocation = autoLocation, + showRecommendedCard = true, + onSelectZone = { selectedZoneId = it }, + onRetry = viewModel::refreshPrintedWasteQueues, + onDismiss = viewModel::dismissPrintedWasteSelector, + onDefault = { viewModel.launchWithPrintedWaste(null) }, + onLaunch = { viewModel.launchWithPrintedWaste(selectedZone?.routingUrl) }, + modifier = Modifier.weight(1f), + ) + } + } + } + } + } +} + +@Composable +private fun PrintedWasteGameSummary( + game: GameInfo, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + Column(modifier, verticalArrangement = Arrangement.spacedBy(10.dp)) { + UrlImage( + gameTvBannerImageUrl(context, game), + Modifier + .fillMaxWidth() + .aspectRatio(16f / 9f) + .clip(RoundedCornerShape(16.dp)), + ) + Column(verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text(game.title, fontWeight = FontWeight.Bold, maxLines = 2, overflow = TextOverflow.Ellipsis) + Text(stringResource(R.string.catalog_free_tier_routing), color = TextMuted, style = MaterialTheme.typography.bodySmall, maxLines = 1) + } + } +} + +@Composable +private fun PrintedWasteOptionsColumn( + state: OpenNowUiState, + regionGroups: List>>, + locations: List, + selectedZoneId: String?, + selectedZone: PrintedWasteZoneOption?, + autoLocation: PrintedWasteLocation?, + showRecommendedCard: Boolean, + onSelectZone: (String) -> Unit, + onRetry: () -> Unit, + onDismiss: () -> Unit, + onDefault: () -> Unit, + onLaunch: () -> Unit, + modifier: Modifier = Modifier, +) { + val zoneListState = rememberLazyListState() + val zoneListFocusRequester = remember { FocusRequester() } + val defaultFocusRequester = remember { FocusRequester() } + val launchFocusRequester = remember { FocusRequester() } + var zoneListFocused by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + fun selectZoneAt(index: Int) { + val next = locations.getOrNull(index) ?: return + onSelectZone(next.primary.zoneId) + scope.launch { + // Region headings are list items too, so a location's row sits further down than its + // index among locations. Counting the headings above it keeps the scroll honest. + val headingsAbove = regionGroups + .runningFold(0) { acc, group -> acc + group.second.size } + .indexOfFirst { it > index } + .coerceAtLeast(1) + zoneListState.animateScrollToItem(index + headingsAbove) + } + } + LaunchedEffect(state.printedWasteLoading, state.printedWasteError, locations.size) { + val initialFocusRequester = if ( + !state.printedWasteLoading && + state.printedWasteError == null && + locations.isNotEmpty() + ) { + launchFocusRequester + } else { + defaultFocusRequester + } + requestFocusWithRetry(initialFocusRequester) + } + Column(modifier, verticalArrangement = Arrangement.spacedBy(12.dp)) { + if (state.printedWasteLoading) { + Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp)) { + CircularProgressIndicator(color = MaterialTheme.colorScheme.primary) + Text(stringResource(R.string.catalog_checking_queues), color = TextMuted) + } + } + } else if (state.printedWasteError != null) { + Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text(state.printedWasteError, color = Color(0xffff9f9f)) + OutlinedButton(onClick = onRetry) { Text(stringResource(R.string.action_retry)) } + } + } + } else { + if (showRecommendedCard) { + autoLocation?.let { + RecommendedPrintedWasteCard(it) + } + } + var listFocused by remember { mutableStateOf(false) } + LazyColumn( + state = zoneListState, + modifier = Modifier + .weight(1f) + .focusRequester(zoneListFocusRequester) + .onFocusChanged { listFocused = it.isFocused } + .onPreviewKeyEvent { event -> + if (isTvActivateKey(event)) { + if (selectedZone != null) { + onLaunch() + true + } else { + false + } + } else if (event.type == KeyEventType.KeyDown) { + val selectedIndex = locations + .indexOfFirst { it.primary.zoneId == selectedZoneId } + .let { if (it >= 0) it else 0 } + when (event.key) { + Key.DirectionUp -> { + if (selectedIndex > 0) { + selectZoneAt(selectedIndex - 1) + true + } else { + false + } + } + Key.DirectionDown -> { + if (selectedIndex < locations.lastIndex) { + selectZoneAt(selectedIndex + 1) + true + } else { + runCatching { launchFocusRequester.requestFocus() }.isSuccess + } + } + else -> false + } + } else { + false + } + } + .focusable(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + regionGroups.forEach { (region, regionLocations) -> + item(key = "region:$region") { + Text( + region, + color = TextMuted, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(top = 6.dp, bottom = 2.dp), + ) + } + items(regionLocations, key = { it.primary.zoneId }) { location -> + val isCurrent = location.primary.zoneId == selectedZoneId + PrintedWasteLocationRow( + location = location, + selected = isCurrent, + focused = isCurrent && listFocused, + listFocused = listFocused, + liveSelectedOutlines = LocalActiveSelectionEnabled.current, + onClick = { onSelectZone(location.primary.zoneId) }, + ) + } + } + } + } + + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically) { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } + OutlinedButton( + onClick = onDefault, + modifier = Modifier + .weight(1f) + .focusRequester(defaultFocusRequester), + ) { + Text(stringResource(R.string.store_selector_default), maxLines = 1, overflow = TextOverflow.Ellipsis) + } + Button( + onClick = onLaunch, + enabled = !state.printedWasteLoading && selectedZone != null, + modifier = Modifier + .weight(1f) + .focusRequester(launchFocusRequester) + .focusProperties { up = zoneListFocusRequester }, + ) { + Text(stringResource(R.string.action_launch), maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } +} + +@Composable +private fun RecommendedPrintedWasteCard(location: PrintedWasteLocation) { + val zoneOption = location.primary + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f), + ) { + Row( + Modifier.padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + stringResource(R.string.queue_best_route), + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + ) + Text( + location.title, + color = TextPrimary, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + printedWasteLocationDetail(location), + color = TextMuted, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + QueueMetricPill( + stringResource(R.string.stream_statusbar_metric_ping), + zoneOption.pingMs?.let { "$it ms" } ?: stringResource(R.string.queue_checking), + ) + QueueMetricPill( + stringResource(R.string.queue_metric_ahead), + zoneOption.zone.QueuePosition.toString(), + queueColor(zoneOption.zone.QueuePosition), + ) + } + } +} + +/** + * The secondary line under a location name: its region, the GPU it runs, and how many server ids + * were folded into this one row. + * + * The alternate count is stated rather than hidden because it is capacity the player may care + * about — three Southern California servers behind one row is a different proposition from one. + */ +@Composable +private fun printedWasteLocationDetail(location: PrintedWasteLocation): String { + val parts = buildList { + add(location.region) + location.gpuTier?.let { add(it.label) } + if (location.alternateCount > 0) { + add(pluralStringResource(R.plurals.queue_location_servers, location.alternateCount + 1, location.alternateCount + 1)) + } + } + return parts.joinToString(" · ") +} + +@Composable +private fun PrintedWasteLocationRow( + location: PrintedWasteLocation, + selected: Boolean, + focused: Boolean, + listFocused: Boolean, + liveSelectedOutlines: Boolean, + onClick: () -> Unit, +) { + val zoneOption = location.primary + val zone = zoneOption.zone + val detail = printedWasteLocationDetail(location) + Box(Modifier.fillMaxWidth()) { + Surface( + modifier = Modifier + .fillMaxWidth() + .focusProperties { canFocus = false } + .clickable { onClick() }, + shape = RoundedCornerShape(12.dp), + color = if (focused) { + Color.White.copy(alpha = 0.16f) + } else if (selected) { + LocalSelectionTintColor.current.copy(alpha = 0.16f) + } else { + PanelAlt + }, + tonalElevation = if (selected) 2.dp else 0.dp, + border = if (selected && listFocused && !liveSelectedOutlines) { + BorderStroke(2.dp, LocalSelectionTintColor.current) + } else { + null + }, + ) { + BoxWithConstraints(Modifier.fillMaxWidth()) { + val compact = maxWidth < 520.dp + val nameBlock: @Composable ColumnScope.() -> Unit = { + Text( + location.title, + fontWeight = FontWeight.Bold, + color = if (selected) LocalSelectionTintColor.current else TextPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + detail, + color = TextMuted, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + if (compact) { + Column( + Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f), content = nameBlock) + if (selected) { + Text( + stringResource(R.string.store_selector_selected), + color = LocalSelectionTintColor.current, + style = MaterialTheme.typography.labelMedium, + ) + } + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + PrintedWasteZoneMetrics(zoneOption) + } + } + } else { + Row( + Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Column(Modifier.weight(1f), content = nameBlock) + PrintedWasteZoneMetrics(zoneOption) + } + } + } + } + ControllerFocusFrame( + visible = shouldShowActiveSelectionOutline(selected, liveSelectedOutlines), + cornerRadius = 12.dp, + tint = LocalActiveSelectionColor.current, + secondaryTint = LocalActiveSelectionSecondaryColor.current, + ) + } +} + +@Composable +private fun PrintedWasteZoneMetrics(zoneOption: PrintedWasteZoneOption) { + val zone = zoneOption.zone + QueueMetricPill( + stringResource(R.string.stream_statusbar_metric_ping), + zoneOption.pingMs?.let { "$it ms" } ?: "--", + zoneOption.pingMs?.let(::pingColor) ?: TextMuted, + ) + QueueMetricPill( + stringResource(R.string.queue_metric_ahead), + zone.QueuePosition.toString(), + queueColor(zone.QueuePosition), + ) + zone.eta?.let { + QueueMetricPill(stringResource(R.string.queue_metric_wait), formatPrintedWasteWait(it)) + } +} + +@Composable +private fun QueueMetricPill( + label: String, + value: String, + valueColor: Color = TextPrimary, +) { + Surface( + shape = RoundedCornerShape(10.dp), + color = Color.Black.copy(alpha = 0.22f), + border = if (LocalAbsoluteCinemaEffects.current) { + BorderStroke(1.dp, LocalActiveSelectionColor.current.copy(alpha = 0.72f)) + } else { + null + }, + ) { + Column( + Modifier.padding(horizontal = 9.dp, vertical = 6.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text(label, color = TextMuted, style = MaterialTheme.typography.labelSmall) + Text(value, color = valueColor, fontWeight = FontWeight.Bold, style = MaterialTheme.typography.labelMedium) + } + } +} + +private fun queueColor(queue: Int): Color = when { + queue <= 5 -> Green + queue <= 20 -> Color(0xffc7ef6b) + queue <= 45 -> Color(0xffffc95a) + else -> Color(0xffff8d8d) +} + +private fun pingColor(pingMs: Long): Color = when { + pingMs <= 60L -> Green + pingMs <= 120L -> Color(0xffc7ef6b) + pingMs <= 180L -> Color(0xffffc95a) + else -> Color(0xffff8d8d) +} + +@Composable +internal fun ProviderPicker(providers: List, selected: LoginProvider, onSelect: (LoginProvider) -> Unit) { + var expanded by remember { mutableStateOf(false) } + BackHandler(enabled = expanded) { expanded = false } + Box { + OutlinedButton(onClick = { expanded = true }) { Text(selected.displayName) } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + providers.forEach { provider -> + DropdownMenuItem( + text = { Text(provider.displayName) }, + onClick = { + expanded = false + onSelect(provider) + }, + ) + } + } + } +} + +private sealed interface UrlImageState { + data object Empty : UrlImageState + data object Loading : UrlImageState + data object Failed : UrlImageState + data object Loaded : UrlImageState +} + +internal fun imageDataForSource(source: String): Any? { + val key = source.trim() + if (key.isBlank()) return null + val uri = runCatching { Uri.parse(key) }.getOrNull() ?: return null + val scheme = uri.scheme.orEmpty().lowercase(Locale.US) + return when { + scheme == "http" || scheme == "https" -> key + scheme == "content" || scheme == "android.resource" || scheme == "file" -> uri + scheme.isBlank() && key.startsWith("/") -> File(key) + else -> uri + } +} + +@Composable +internal fun UrlImage( + url: String?, + modifier: Modifier = Modifier, + fallbackUrl: String? = null, + contentScale: ContentScale = ContentScale.Crop, +) { + val source = url?.trim().orEmpty() + val fallbackSource = fallbackUrl?.trim()?.takeIf { it.isNotBlank() && it != source } + var activeSource by remember(source, fallbackSource) { + mutableStateOf(source.takeIf { it.isNotBlank() } ?: fallbackSource) + } + var imageState by remember(source, fallbackSource) { + mutableStateOf(if (activeSource == null) UrlImageState.Empty else UrlImageState.Loading) + } + val loadingTracker = LocalImageLoadingTracker.current + val loading = imageState == UrlImageState.Loading + val imageRequestsPaused = LocalCatalogImageRequestsPaused.current + DisposableEffect(loadingTracker, loading) { + if (loading) loadingTracker?.invoke(1) + onDispose { + if (loading) loadingTracker?.invoke(-1) + } + } + val imageData = remember(activeSource) { activeSource?.let(::imageDataForSource) } + LaunchedEffect(activeSource, imageData, fallbackSource, source) { + if (activeSource == null) { + imageState = UrlImageState.Empty + } else if (imageData == null) { + if (activeSource == source && fallbackSource != null) { + activeSource = fallbackSource + imageState = UrlImageState.Loading + } else { + imageState = UrlImageState.Failed + } + } + } + Box(modifier.background(OpenNowPalette.ImagePlaceholder), contentAlignment = Alignment.Center) { + if (imageData != null && shouldStartCatalogImageRequest(imageRequestsPaused, imageState == UrlImageState.Loaded)) { + key(activeSource) { + AsyncImage( + model = imageData, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = contentScale, + onLoading = { imageState = UrlImageState.Loading }, + onSuccess = { imageState = UrlImageState.Loaded }, + onError = { + if (activeSource == source && fallbackSource != null) { + activeSource = fallbackSource + imageState = UrlImageState.Loading + } else { + imageState = UrlImageState.Failed + } + }, + ) + } + } + when (imageState) { + UrlImageState.Loading -> LoadingShimmer(Modifier.fillMaxSize()) + UrlImageState.Loaded -> Unit + UrlImageState.Empty, + UrlImageState.Failed, + -> OpenNowMark(42.dp) + } + } +} + +@Composable +internal fun LoadingShimmer(modifier: Modifier = Modifier) { + // Use the shared grid animation when available; the local fallback only runs while an + // individual image placeholder is actually composed. + // Using nullable avoids treating 0f (a valid animation start value) as "not provided". + val animateLoading = LocalImageLoadingAnimationsEnabled.current && !LocalReduceMotion.current + val sharedPulse = LocalTvLoadingPulse.current + val localPulse = if (animateLoading && LocalTvLoadingProfile.current && sharedPulse == null) { + val transition = rememberInfiniteTransition(label = "loading-pulse-local") + val pulse = transition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 900, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "loading-pulse-local", + ) + pulse + } else { + null + } + val pulse = sharedPulse ?: localPulse + // Same rule as the shared driver above: no perpetual sweep under reduced motion. + val shimmer = LocalShimmerOffset.current ?: if (pulse == null && animateLoading) run { + val transition = rememberInfiniteTransition(label = "shimmer-local") + val localOffset = transition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = SHIMMER_CYCLE_DURATION_MS, easing = LinearEasing), + ), + label = "shimmer-offset-local", + ) + localOffset + } else null + val baseColor = OpenNowPalette.ShimmerBase + val highlightColor1 = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.32f) + val highlightColor2 = MaterialTheme.colorScheme.primary.copy(alpha = 0.18f) + + Spacer( + modifier = modifier + .background(baseColor) + .drawBehind { + if (pulse != null) { + drawRect(highlightColor1.copy(alpha = 0.08f + pulse.value * 0.18f)) + } else { + val width = size.width + val bandWidth = (width * 0.52f).coerceAtLeast(1f) + // Observe the transition for draw invalidations, but anchor the visible phase + // to device uptime so recreating an image loader cannot jump the band back. + val animationFrame = shimmer?.value + val bandStart = shimmerBandStartX( + progress = if (animationFrame == null) { + 0f + } else { + shimmerProgressAtUptime(SystemClock.uptimeMillis()) + }, + containerWidth = width, + bandWidth = bandWidth, + ) + // A horizontal shader gives the sweep exact bounds. At both repeat endpoints + // its transparent edge only touches the card, so Restart cannot paint a + // backward-moving frame on tall poster placeholders. + val brush = Brush.horizontalGradient( + colors = listOf( + Color.Transparent, + highlightColor1, + highlightColor2, + highlightColor1, + Color.Transparent, + ), + startX = bandStart, + endX = bandStart + bandWidth, + ) + drawRect(brush) + } + } + ) +} + +internal fun shimmerProgressAtUptime(uptimeMillis: Long): Float { + val cycleDurationMs = SHIMMER_CYCLE_DURATION_MS.toLong() + return Math.floorMod(uptimeMillis, cycleDurationMs).toFloat() / cycleDurationMs +} + +internal fun shimmerBandStartX(progress: Float, containerWidth: Float, bandWidth: Float): Float { + val safeWidth = containerWidth.coerceAtLeast(0f) + val safeBandWidth = bandWidth.coerceAtLeast(1f) + return -safeBandWidth + + progress.coerceIn(0f, 1f) * (safeWidth + safeBandWidth) +} + +@Composable +internal fun OpenNowMark(size: androidx.compose.ui.unit.Dp, modifier: Modifier = Modifier) { + Image( + painter = painterResource(R.drawable.opennow_logo_mark), + contentDescription = "OpenNOW", + modifier = modifier + .width(size * 1.85f) + .height(size), + contentScale = ContentScale.Fit, + ) +} + +@Composable +internal fun OpenNowAppIcon( + size: androidx.compose.ui.unit.Dp, + animate: Boolean = false, +) { + val spin = if (animate) { + val transition = rememberInfiniteTransition(label = "active-app-icon") + transition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 3_000, easing = LinearEasing), + ), + label = "active-app-icon-phase", + ) + } else { + null + } + val motionModifier = if (spin == null) { + Modifier + } else { + // Read the animation state in the layer block so each frame only updates this GPU layer; + // it does not remeasure the navigation rail or recompose the surrounding chrome. + Modifier.graphicsLayer { + val spinProgress = activeLogoSpinProgress(spin.value) + val pulse = sin(spinProgress * PI.toFloat()).coerceAtLeast(0f) + rotationY = spinProgress * 360f + translationY = activeLogoFloatOffsetDp(spin.value).dp.toPx() + cameraDistance = 12f * density + scaleX = 1f + pulse * 0.035f + scaleY = scaleX + } + } + Image( + painter = painterResource(R.drawable.opennow_icon), + contentDescription = "OpenNOW", + modifier = Modifier + .size(size) + .then(motionModifier), + contentScale = ContentScale.Fit, + ) +} + +internal fun activeLogoSpinProgress(cycleProgress: Float): Float = + ((cycleProgress.coerceIn(0f, 1f) - 0.30f) / 0.18f).coerceIn(0f, 1f) + +internal fun activeLogoFloatOffsetDp(cycleProgress: Float): Float = + sin(cycleProgress.coerceIn(0f, 1f) * 2f * PI.toFloat()) * 2.5f + +internal val ColorQuality.label: String + get() = when (this) { + ColorQuality.EightBit420 -> "8-bit 4:2:0" + ColorQuality.EightBit444 -> "8-bit 4:4:4" + ColorQuality.TenBit420 -> "10-bit 4:2:0" + ColorQuality.TenBit444 -> "10-bit 4:4:4" + } + +internal val GameCardOverlayGradient = Brush.verticalGradient( + colors = listOf(Color.Transparent, Color.Transparent, Color.Black.copy(alpha = 0.95f)) +) diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowCatalogScreens.kt b/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowCatalogScreens.kt new file mode 100644 index 000000000..34842c4b1 --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowCatalogScreens.kt @@ -0,0 +1,5292 @@ +package com.opencloudgaming.opennow + +import android.content.Context +import android.content.Intent +import android.content.res.Configuration +import android.net.ConnectivityManager +import android.net.Uri +import android.os.SystemClock +import android.net.NetworkCapabilities +import androidx.annotation.StringRes +import android.view.KeyEvent +import android.widget.Toast +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.MutableTransitionState +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.togetherWith +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.draggable +import androidx.compose.foundation.gestures.rememberDraggableState +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.interaction.collectIsPressedAsState +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.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items as gridItems +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.foundation.lazy.layout.LazyLayoutCacheWindow +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AssistChip +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.OpenInNew +import androidx.compose.material.icons.outlined.Cast +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.minimumInteractiveComponentSize +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.key +import androidx.compose.runtime.setValue +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.MutableIntState +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.Shadow +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.layout.layout +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.Velocity +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import coil3.SingletonImageLoader +import coil3.request.ImageRequest +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.util.Locale +import com.opencloudgaming.opennow.ui.theme.LocalReduceMotion +import com.opencloudgaming.opennow.ui.theme.OpenNowMotion +import com.opencloudgaming.opennow.ui.theme.OpenNowPalette +import com.opencloudgaming.opennow.ui.theme.OpenNowRadius +import com.opencloudgaming.opennow.ui.theme.OpenNowSpacing +import com.opencloudgaming.opennow.ui.theme.tint +import kotlin.math.abs +import kotlin.math.roundToInt + +@OptIn(ExperimentalComposeUiApi::class, ExperimentalFoundationApi::class) +@Composable +internal fun HomeScreen( + state: OpenNowUiState, + viewModel: OpenNowViewModel, + tvProfile: Boolean, + hideChromeWhenScrolled: Boolean, + controlsInTopBar: Boolean, + topBarFocusRequester: FocusRequester?, + searchRequested: Boolean, + onSearchDismissed: () -> Unit, + onScrollChromeHiddenChange: (Boolean) -> Unit, +) { + val landscapeLayout = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE + val catalogGames = state.games.ifEmpty { state.catalogResult.games } + val visibleGames = remember(catalogGames, state.catalogFilterIds) { + filterCatalogGamesForLocalControls(catalogGames, state.catalogFilterIds) + } + val filterActive = state.catalogFilterIds.isNotEmpty() + val searchingCatalog = state.loadingGames && state.catalogSearch.isNotBlank() + // A 120 Hz panel has only 8.3 ms per frame. Keep its speculative composition window leaner + // than 60/90 Hz instead of spending that tighter frame budget on a full extra viewport. + val refreshRateHz = LocalView.current.display?.refreshRate ?: 60f + val cacheFractions = remember(refreshRateHz) { + catalogCacheWindowFractions(refreshRateHz) + } + val gridState = rememberLazyGridState( + cacheWindow = LazyLayoutCacheWindow( + aheadFraction = cacheFractions.first, + behindFraction = cacheFractions.second, + ), + ) + val searchFocusRequester = remember { FocusRequester() } + val scope = rememberCoroutineScope() + val haptics = LocalOpenNowHaptics.current + val selectGameWithHaptic: (GameInfo) -> Unit = { game -> + viewModel.selectGame(game) + haptics?.play(HapticCue.Activate) + } + val focusManager = LocalFocusManager.current + val keyboardController = LocalSoftwareKeyboardController.current + val showSearch = searchRequested || state.catalogSearch.isNotBlank() + val resultsOnly = showSearch || filterActive + val physicalControllerConnected = rememberPhysicalControllerConnected( + enabled = hideChromeWhenScrolled && !tvProfile, + ) + val showScrollActions by remember(gridState) { + derivedStateOf { + gridState.firstVisibleItemIndex > 0 || gridState.firstVisibleItemScrollOffset > 80 + } + } + val scrolledAwayFromTop by remember(gridState) { + derivedStateOf { + gridState.firstVisibleItemIndex > 0 || gridState.firstVisibleItemScrollOffset > 0 + } + } + val hideScrollChrome = shouldHideStoreChromeOnScroll( + hideChromeWhenScrolled = hideChromeWhenScrolled, + scrolledAwayFromTop = scrolledAwayFromTop, + physicalControllerConnected = physicalControllerConnected, + ) + LaunchedEffect(hideScrollChrome) { + onScrollChromeHiddenChange(hideScrollChrome) + } + DisposableEffect(Unit) { + onDispose { onScrollChromeHiddenChange(false) } + } + LaunchedEffect(searchRequested) { + if (searchRequested) { + delay(90) + runCatching { searchFocusRequester.requestFocus() } + keyboardController?.show() + } + } + SwipeToRefreshContainer( + refreshing = shouldShowCatalogRefreshIndicator( + loadingGames = state.loadingGames, + hasVisibleGames = visibleGames.isNotEmpty(), + ), + enabled = !tvProfile, + showRefreshIndicator = !searchingCatalog, + onRefresh = viewModel::refreshGames, + modifier = Modifier.fillMaxSize(), + ) { + BoxWithConstraints(Modifier.fillMaxSize()) { + // Same one-margin rule as the Library: the grid owns its inset, so nothing above it + // adds a second one. Store and Library must agree here or switching tabs shifts the + // whole page sideways. + Column( + Modifier + .fillMaxSize() + .padding( + top = storeScreenTopPadding( + controlsInTopBar = controlsInTopBar, + phoneLandscapeHero = landscapeLayout && !tvProfile && !resultsOnly, + ), + bottom = 12.dp, + ), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + AnimatedVisibility(visible = showSearch) { + NativeSearchField( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = OpenNowSpacing.ScreenEdge), + query = state.catalogSearch, + onQueryChange = { next -> + viewModel.setCatalogSearch(next) + if (next.isBlank()) onSearchDismissed() + }, + placeholder = stringResource(R.string.search_games), + searching = searchingCatalog, + focusRequester = searchFocusRequester, + onOpen = { + if (gridState.firstVisibleItemIndex > 0 || gridState.firstVisibleItemScrollOffset > 0) { + scope.launch { gridState.animateScrollToItem(0) } + } + }, + ) + } + Box( + Modifier + .weight(1f) + .pointerInput(Unit) { + awaitEachGesture { + awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial) + focusManager.clearFocus() + keyboardController?.hide() + } + }, + ) { + if ( + shouldShowCatalogLoadingPlaceholder( + queryLoading = state.catalogQueryLoading, + loadingGames = state.loadingGames, + hasVisibleGames = visibleGames.isNotEmpty(), + ) + ) { + Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Box(Modifier.padding(horizontal = OpenNowSpacing.ScreenEdge)) { + StoreScrollableControls( + state = state, + onSortChange = viewModel::setCatalogSort, + onFilterToggle = viewModel::toggleCatalogFilter, + showToolbar = !controlsInTopBar, + ) + } + if (resultsOnly) { + SectionHeader( + title = stringResource(R.string.store_results), + modifier = Modifier.padding( + start = OpenNowSpacing.ScreenEdge, + top = OpenNowSpacing.lg, + end = OpenNowSpacing.ScreenEdge, + bottom = OpenNowSpacing.sm, + ), + ) + } + RefreshingGamesPlaceholder( + settings = state.settings, + tvProfile = tvProfile, + storeLayout = !resultsOnly, + storeRailCount = if (resultsOnly) { + 0 + } else { + storeStartRailGroups( + games = visibleGames, + libraryGames = state.libraryGames, + favoriteIds = state.settings.favoriteGameIds, + queuedGameKeys = state.queuedGameKeys, + ).visibleGroupCount.coerceAtLeast(1) + }, + modifier = Modifier.weight(1f), + ) + } + } else { + StoreGameGrid( + games = visibleGames, + favoriteIds = state.settings.favoriteGameIds, + settings = state.settings, + tvProfile = tvProfile, + state = state, + onSelect = selectGameWithHaptic, + onFavorite = viewModel::updateFavorites, + onPlay = viewModel::play, + onChooseStore = viewModel::chooseStore, + onSortChange = viewModel::setCatalogSort, + onFilterToggle = viewModel::toggleCatalogFilter, + onHideLandscapeNewGames = { + viewModel.updateSettings( + state.settings.copy(landscapeNewGamesHero = false), + ) + }, + onClearSearch = { + viewModel.setCatalogSearch("") + onSearchDismissed() + }, + onClearFilters = viewModel::clearCatalogFilters, + gridState = gridState, + showToolbar = !controlsInTopBar, + topFocusRequester = topBarFocusRequester, + resultsOnly = resultsOnly, + modifier = Modifier.fillMaxSize(), + ) + } + if (showScrollActions) { + Box(Modifier.align(Alignment.BottomEnd).padding(2.dp)) { + StoreScrollActionButton( + iconRes = R.drawable.ic_arrow_up, + contentDescription = stringResource(R.string.action_scroll_top), + ) { + scope.launch { gridState.animateScrollToItem(0) } + } + } + } + } + } + } + } +} + +@Composable +private fun StoreScrollableControls( + state: OpenNowUiState, + onSortChange: (String) -> Unit, + onFilterToggle: (String) -> Unit, + showToolbar: Boolean = true, +) { + val filterOptions = rememberCatalogFilterOptions( + remember(state.catalogResult.filterGroups) { + catalogVisibleFilterGroups(state.catalogResult.filterGroups) + }, + ) + val hasSelectedFilters = state.catalogFilterIds.isNotEmpty() + val hasError = !state.error.isNullOrBlank() + if (!showToolbar && !hasSelectedFilters && !hasError) return + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + if (showToolbar) { + StoreCatalogToolbar( + state = state, + onSortChange = onSortChange, + onFilterToggle = onFilterToggle, + modifier = Modifier.fillMaxWidth(), + ) + } + SelectedFilterChips(options = filterOptions, selectedIds = state.catalogFilterIds, onToggle = onFilterToggle) + InlineErrorNotice(error = state.error) + } +} + +@Composable +internal fun StoreCatalogToolbar( + state: OpenNowUiState, + onSortChange: (String) -> Unit, + onFilterToggle: (String) -> Unit, + modifier: Modifier = Modifier, + compact: Boolean = false, +) { + val filterOptions = rememberCatalogFilterOptions( + remember(state.catalogResult.filterGroups) { + catalogVisibleFilterGroups(state.catalogResult.filterGroups) + }, + ) + Row( + modifier, + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + SortPicker( + options = state.catalogResult.sortOptions, + selected = state.catalogSortId, + onSelect = onSortChange, + modifier = Modifier.width(if (compact) 118.dp else 172.dp), + compact = compact, + ) + if (filterOptions.isNotEmpty()) { + FilterMenu(options = filterOptions, selectedIds = state.catalogFilterIds, onToggle = onFilterToggle, compact = compact) + } + } +} + +@Composable +private fun InlineErrorNotice(error: String?) { + if (error.isNullOrBlank()) return + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + color = OpenNowPalette.ErrorContainer, + tonalElevation = 0.dp, + ) { + Column(Modifier.padding(horizontal = 12.dp, vertical = 10.dp)) { + Text( + compactErrorTitle(error), + color = OpenNowPalette.OnErrorContainer, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + compactErrorBody(error), + color = OpenNowPalette.OnErrorContainer, + style = MaterialTheme.typography.bodySmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +private fun compactErrorTitle(error: String): String = + when { + error.contains("DNS lookup failed", ignoreCase = true) -> "Network lookup failed" + error.contains("Unable to resolve host", ignoreCase = true) -> "Network lookup failed" + else -> "Something went wrong" + } + +private fun compactErrorBody(error: String): String = + error + .replace('\n', ' ') + .replace(SEARCH_WHITESPACE_RUN, " ") + .let { if (it.length > 180) "${it.take(177)}..." else it } + +@Composable +private fun StoreScrollActionButton(iconRes: Int, contentDescription: String, onClick: () -> Unit) { + Surface( + shape = CircleShape, + color = PanelAlt.copy(alpha = 0.96f), + tonalElevation = 4.dp, + shadowElevation = 4.dp, + ) { + IconButton(onClick = onClick, modifier = Modifier.size(44.dp)) { + Icon( + painter = painterResource(iconRes), + contentDescription = contentDescription, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(22.dp), + ) + } + } +} + +@Composable +internal fun LibraryScreen( + state: OpenNowUiState, + viewModel: OpenNowViewModel, + tvProfile: Boolean, + hideChromeWhenScrolled: Boolean, + controlsInTopBar: Boolean, + topBarFocusRequester: FocusRequester?, + searchRequested: Boolean, + onSearchDismissed: () -> Unit, + onScrollChromeHiddenChange: (Boolean) -> Unit, +) { + val haptics = LocalOpenNowHaptics.current + val selectGameWithHaptic: (GameInfo) -> Unit = { game -> + viewModel.selectGame(game) + haptics?.play(HapticCue.Activate) + } + val orderedGames = remember(state.libraryGames, state.settings.favoriteGameIds, state.librarySortId) { + sortLibraryGames( + favoriteOrderedGames(state.libraryGames, state.settings.favoriteGameIds), + state.librarySortId, + ) + } + val touchFilterLabel = stringResource(R.string.catalog_filter_touch_controls) + val filterOptions = remember(orderedGames, touchFilterLabel) { + libraryStoreFilterOptions(orderedGames, touchFilterLabel) + } + val games = remember(orderedGames, state.librarySearch, state.libraryFilterIds) { + val searchTerms = searchTermsFor(state.librarySearch) + orderedGames.filter { game -> + gameMatchesSearch(game, searchTerms) && gameMatchesLibraryFilters(game, state.libraryFilterIds) + } + } + val gridState = rememberLazyGridState() + val searchFocusRequester = remember { FocusRequester() } + val localAppsFocusRequester = remember { FocusRequester() } + val localAppsHeaderFocusRequester = remember { FocusRequester() } + val keyboardController = LocalSoftwareKeyboardController.current + val showSearch = searchRequested || state.librarySearch.isNotBlank() + val localAppsShelfVisible = BuildConfig.LOCAL_APP_LAUNCHER_SUPPORTED && state.settings.localAppsEnabled + val localAppsCollapsed = state.settings.localAppsCollapsed + val scrolledAwayFromTop by remember(gridState) { + derivedStateOf { + gridState.firstVisibleItemIndex > 0 || gridState.firstVisibleItemScrollOffset > 0 + } + } + val hideScrollChrome = hideChromeWhenScrolled && scrolledAwayFromTop + LaunchedEffect(hideScrollChrome) { + onScrollChromeHiddenChange(hideScrollChrome) + } + DisposableEffect(Unit) { + onDispose { onScrollChromeHiddenChange(false) } + } + LaunchedEffect(searchRequested) { + if (searchRequested) { + delay(90) + runCatching { searchFocusRequester.requestFocus() } + keyboardController?.show() + } + } + SwipeToRefreshContainer( + refreshing = state.loadingGames, + enabled = !tvProfile, + onRefresh = viewModel::refreshGames, + modifier = Modifier.fillMaxSize(), + ) { + BoxWithConstraints(Modifier.fillMaxSize()) { + // Horizontal insets live on the children, not on this Column: the grid pads its own + // content so cards can scroll under the edge, and matching that from the outside is + // what left the local-apps row and the catalogue grid on two different margins. + Column( + Modifier + .fillMaxSize() + .padding( + top = if (controlsInTopBar) 4.dp else 12.dp, + bottom = 12.dp, + ), + verticalArrangement = Arrangement.spacedBy(OpenNowSpacing.sm), + ) { + AnimatedVisibility(visible = showSearch) { + NativeSearchField( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = OpenNowSpacing.ScreenEdge), + query = state.librarySearch, + onQueryChange = { next -> + viewModel.setLibrarySearch(next) + if (next.isBlank()) onSearchDismissed() + }, + placeholder = "Search library", + focusRequester = searchFocusRequester, + ) + } + if (localAppsShelfVisible) { + LocalAppsShelf( + packageNames = state.settings.localAppPackageNames, + collapsed = localAppsCollapsed, + onCollapsedChange = viewModel::setLocalAppsCollapsed, + onAddPackage = viewModel::addLocalApp, + onRemovePackage = viewModel::removeLocalApp, + horizontalPadding = OpenNowSpacing.ScreenEdge, + headerFocusRequester = localAppsHeaderFocusRequester, + focusRequester = localAppsFocusRequester, + topFocusRequester = topBarFocusRequester, + ) + } + LibraryFilterControls( + gameCount = games.size, + totalCount = state.libraryGames.size, + options = filterOptions, + selectedIds = state.libraryFilterIds, + onToggle = viewModel::toggleLibraryFilter, + showToolbar = !controlsInTopBar, + modifier = Modifier.padding(horizontal = OpenNowSpacing.ScreenEdge), + ) + if (state.loadingGames && state.libraryGames.isEmpty()) { + RefreshingGamesPlaceholder( + settings = state.settings, + tvProfile = tvProfile, + topContentPadding = OpenNowSpacing.xs, + modifier = Modifier.weight(1f), + ) + } else { + GameGrid( + games, + state.settings.favoriteGameIds, + state.settings, + tvProfile, + selectGameWithHaptic, + viewModel::updateFavorites, + viewModel::play, + viewModel::chooseStore, + topFocusRequester = libraryGridUpFocusRequester( + shelfVisible = localAppsShelfVisible, + shelfCollapsed = localAppsCollapsed, + shelfTile = localAppsFocusRequester, + shelfHeader = localAppsHeaderFocusRequester, + topBar = topBarFocusRequester, + ), + modifier = Modifier.weight(1f), + gridState = gridState, + // Everything above already ends on the Column's 8dp gap, so the grid adds a + // hairline rather than its full 12dp inset: those two stacked were the + // paragraph-sized hole between the filter row and the first row of posters. + topContentPadding = OpenNowSpacing.xs, + emptyContent = { + val hasSearch = state.librarySearch.isNotBlank() + val hasFilters = state.libraryFilterIds.isNotEmpty() + if ((hasSearch || hasFilters) && state.libraryGames.isNotEmpty()) { + SearchEmptyState( + title = stringResource(R.string.library_empty_search_title), + message = when { + hasSearch && hasFilters -> stringResource(R.string.library_empty_search_filters_body) + hasSearch -> stringResource(R.string.library_empty_search_body) + else -> stringResource(R.string.library_empty_filters_body) + }, + onClearSearch = if (hasSearch) { + { + viewModel.setLibrarySearch("") + onSearchDismissed() + } + } else { + null + }, + onClearFilters = if (hasFilters) { + { viewModel.clearLibraryFilters() } + } else { + null + }, + ) + } else { + Text(stringResource(R.string.no_games_loaded), color = TextMuted) + } + }, + ) + } + } + } + } +} + +private fun PaddingValues.withTop(top: Dp?): PaddingValues = + if (top == null) { + this + } else { + PaddingValues( + start = calculateStartPadding(LayoutDirection.Ltr), + top = top, + end = calculateEndPadding(LayoutDirection.Ltr), + bottom = calculateBottomPadding(), + ) + } + +/** + * Where "up" from the Library's first grid row lands. + * + * Folding the shelf hides its tiles, and a [FocusRequester] pointed at an element that is no longer + * composed throws when it is requested — so the fold has to move the target to the header, which is + * the one part of the shelf that is always on screen. + */ +internal fun libraryGridUpFocusTarget( + shelfVisible: Boolean, + shelfCollapsed: Boolean, + shelfTile: T, + shelfHeader: T, + topBar: T?, +): T? = when { + !shelfVisible -> topBar + shelfCollapsed -> shelfHeader + else -> shelfTile +} + +private fun libraryGridUpFocusRequester( + shelfVisible: Boolean, + shelfCollapsed: Boolean, + shelfTile: FocusRequester, + shelfHeader: FocusRequester, + topBar: FocusRequester?, +): FocusRequester? = libraryGridUpFocusTarget(shelfVisible, shelfCollapsed, shelfTile, shelfHeader, topBar) + +internal const val LIBRARY_SORT_DEFAULT = "library" +internal const val LIBRARY_SORT_RECENT = "recent" +internal const val LIBRARY_SORT_TITLE = "title" + +@Composable +internal fun librarySortOptions(): List = listOf( + CatalogSortOption(LIBRARY_SORT_DEFAULT, stringResource(R.string.library_sort_default), ""), + CatalogSortOption(LIBRARY_SORT_RECENT, stringResource(R.string.library_sort_recent), ""), + CatalogSortOption(LIBRARY_SORT_TITLE, stringResource(R.string.library_sort_title), ""), +) + +internal fun sortLibraryGames(games: List, sortId: String): List = + when (sortId) { + LIBRARY_SORT_TITLE -> games.sortedBy { it.title.lowercase(Locale.US) } + LIBRARY_SORT_RECENT -> games.sortedWith( + compareByDescending { it.recentPlaySortKey() != null } + .thenByDescending { it.recentPlaySortKey() } + .thenBy { it.title.lowercase(Locale.US) }, + ) + else -> games + } + +@Composable +internal fun LibraryFilterControls( + gameCount: Int, + totalCount: Int, + options: List, + selectedIds: List, + onToggle: (String) -> Unit, + modifier: Modifier = Modifier, + compact: Boolean = false, + showToolbar: Boolean = true, + showSelectedChips: Boolean = true, +) { + if (!showToolbar && (!showSelectedChips || selectedIds.isEmpty())) return + Column(modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) { + if (showToolbar) { + Row( + if (compact) Modifier else Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + val countModifier = if (compact) Modifier else Modifier.weight(1f) + Text( + text = if (gameCount == totalCount) { + stringResource(R.string.library_count, totalCount) + } else { + "$gameCount / ${stringResource(R.string.library_count, totalCount)}" + }, + color = TextMuted, + style = if (compact) MaterialTheme.typography.labelSmall else MaterialTheme.typography.labelMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Start, + modifier = countModifier, + ) + if (options.isNotEmpty()) { + FilterMenu(options = options, selectedIds = selectedIds, onToggle = onToggle, compact = compact) + } + } + } + if (showSelectedChips) { + SelectedFilterChips(options = options, selectedIds = selectedIds, onToggle = onToggle) + } + } +} + +internal fun libraryStoreFilterOptions( + games: List, + touchFilterLabel: String = "Touch controls", +): List { + val labelsById = linkedMapOf() + games.forEach { game -> + libraryStoreFilterIds(game).forEach { (id, label) -> + // Map.putIfAbsent is API 24; this module ships to 23 without core library desugaring. + if (id !in labelsById) labelsById[id] = label + } + } + val storeOptions = labelsById.entries + .sortedBy { it.value.lowercase(Locale.US) } + .map { (id, label) -> + CatalogFilterOption( + id = id, + rawId = id.removePrefix(LIBRARY_STORE_FILTER_PREFIX), + label = label, + groupId = "library_store", + groupLabel = "Launcher", + ) + } + val touchOption = if (games.any(::catalogClaimsTouchSupport)) { + listOf( + CatalogFilterOption( + id = CATALOG_FILTER_TOUCHSCREEN, + rawId = SUPPORTED_CONTROL_TOUCHSCREEN, + label = touchFilterLabel, + groupId = "supported_controls", + groupLabel = "Controls", + ), + ) + } else { + emptyList() + } + return touchOption + storeOptions +} + +internal fun gameMatchesLibraryFilters(game: GameInfo, selectedIds: List): Boolean { + if (selectedIds.isEmpty()) return true + if (CATALOG_FILTER_TOUCHSCREEN in selectedIds && !catalogClaimsTouchSupport(game)) return false + val selectedStoreIds = selectedIds.filter { it.startsWith(LIBRARY_STORE_FILTER_PREFIX) } + if (selectedStoreIds.isEmpty()) return true + val gameFilterIds = libraryStoreFilterIds(game).map { it.first }.toSet() + return selectedStoreIds.any { it in gameFilterIds } +} + +internal fun filterCatalogGamesForLocalControls(games: List, selectedIds: List): List = + if (CATALOG_FILTER_TOUCHSCREEN in selectedIds) games.filter(::catalogClaimsTouchSupport) else games + +private fun libraryStoreFilterIds(game: GameInfo): List> { + val labels = libraryStoreDisplayNames(game) + return labels + .mapNotNull { label -> + val normalized = normalizeGameStore(label) + if (normalized.isBlank()) return@mapNotNull null + LIBRARY_STORE_FILTER_PREFIX + normalized to label + } + .distinctBy { it.first } +} + +private const val LIBRARY_STORE_FILTER_PREFIX = "library_store:" + +@Composable +private fun ActiveSessionResumeCard( + state: OpenNowUiState, + onResumeActiveSession: () -> Unit, + modifier: Modifier = Modifier, +) { + val active = state.activeSession ?: return + val game = activeSessionGame(state, active) + Surface( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(18.dp), + color = PanelAlt.copy(alpha = 0.92f), + tonalElevation = 3.dp, + ) { + Row( + Modifier.padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + UrlImage( + game?.imageUrl, + Modifier + .width(44.dp) + .height(58.dp) + .clip(RoundedCornerShape(10.dp)), + ) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(stringResource(R.string.catalog_resume_cloud_session), color = TextPrimary, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text( + game?.title ?: stringResource(R.string.catalog_app_id, active.appId), + color = TextMuted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + activeSessionSummary(active), + color = TextMuted, + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Button(onClick = onResumeActiveSession, contentPadding = PaddingValues(horizontal = 14.dp, vertical = 8.dp)) { + Text(stringResource(R.string.action_resume), maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } +} + +internal fun activeSessionGame(state: OpenNowUiState, active: ActiveSessionInfo): GameInfo? = + (state.games + state.libraryGames).firstOrNull { game -> + game.launchAppId == active.appId.toString() || + game.variants.any { variant -> variant.id == active.appId.toString() } + } + +@Composable +internal fun activeSessionSummary(active: ActiveSessionInfo): String = + listOfNotNull( + when (active.status) { + 1 -> active.queuePosition?.takeIf { it > 0 }?.let { stringResource(R.string.queue_short_position, it) } + ?: stringResource(R.string.common_starting) + 2, 3 -> stringResource(R.string.common_ready) + else -> stringResource(R.string.common_active) + }, + active.resolution, + active.fps?.let { "${it} FPS" }, + active.gpuType, + active.sessionId.take(8).takeIf { it.isNotBlank() }?.let { "Session $it" }, + ).joinToString(" - ") + +@Composable +private fun SearchEmptyState( + title: String, + message: String, + onClearSearch: (() -> Unit)? = null, + onClearFilters: (() -> Unit)? = null, +) { + Column( + Modifier + .fillMaxWidth() + .padding(horizontal = 28.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + title, + color = TextPrimary, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + ) + Text( + message, + color = TextMuted, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + ) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + onClearSearch?.let { clearSearch -> + OutlinedButton(onClick = clearSearch) { + Text(stringResource(R.string.search_clear), maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + onClearFilters?.let { clearFilters -> + OutlinedButton(onClick = clearFilters) { + Text(stringResource(R.string.action_clear_filters), maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } + } +} + +@Composable +private fun RefreshingGamesPlaceholder( + settings: AppSettings, + tvProfile: Boolean, + storeLayout: Boolean = false, + storeRailCount: Int = 0, + topContentPadding: Dp? = null, + modifier: Modifier = Modifier, +) { + GameGridSkeleton( + settings = settings, + tvProfile = tvProfile, + storeLayout = storeLayout, + storeRailCount = storeRailCount, + topContentPadding = topContentPadding, + modifier = modifier, + ) +} + +internal val LocalShimmerOffset = staticCompositionLocalOf?> { null } +internal val LocalTvLoadingPulse = staticCompositionLocalOf?> { null } +internal val LocalTvLoadingProfile = staticCompositionLocalOf { false } +internal val LocalImageLoadingAnimationsEnabled = staticCompositionLocalOf { true } +internal val LocalCatalogImageRequestsPaused = staticCompositionLocalOf { false } +internal val LocalImageLoadingTracker = staticCompositionLocalOf<((Int) -> Unit)?> { null } +internal val LocalTouchControllerStyle = staticCompositionLocalOf { TouchControllerStyle.V1 } +internal val LocalSelectedCatalogGameId = staticCompositionLocalOf { null } +internal const val SHIMMER_CYCLE_DURATION_MS = 760 + +/** Keep decoded artwork mounted, but do not start new fetch/decode work in the middle of a fling. */ +internal fun shouldStartCatalogImageRequest(requestsPaused: Boolean, imageAlreadyLoaded: Boolean): Boolean = + !requestsPaused || imageAlreadyLoaded + +/** + * One loading animation drives every visible poster. During a fling the placeholders stay flat so + * bitmap upload and list movement get the frame budget instead of a stack of shimmer transitions. + */ +@Composable +private fun CatalogImageLoadingAnimationProvider( + tvProfile: Boolean, + animationsEnabled: Boolean, + content: @Composable () -> Unit, +) { + var loadingImageCount by remember { mutableIntStateOf(0) } + val updateLoadingImageCount: (Int) -> Unit = remember { + { delta -> loadingImageCount = (loadingImageCount + delta).coerceAtLeast(0) } + } + // The previous shared transition lived for as long as the grid was composed, even after every + // image had loaded. On a 120 Hz display that kept the entire app scheduling frames while idle. + // Start the one shared clock only while at least one visible image actually shows a shimmer. + val animate = animationsEnabled && loadingImageCount > 0 && !LocalReduceMotion.current + val driver: State? = if (animate) { + val transition = rememberInfiniteTransition(label = "catalog-image-loading") + transition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween( + durationMillis = if (tvProfile) 900 else SHIMMER_CYCLE_DURATION_MS, + easing = LinearEasing, + ), + repeatMode = if (tvProfile) RepeatMode.Reverse else RepeatMode.Restart, + ), + label = "catalog-image-loading-driver", + ) + } else { + null + } + CompositionLocalProvider( + LocalImageLoadingAnimationsEnabled provides animate, + LocalCatalogImageRequestsPaused provides !animationsEnabled, + LocalImageLoadingTracker provides updateLoadingImageCount, + LocalShimmerOffset provides driver.takeUnless { tvProfile }, + LocalTvLoadingPulse provides driver.takeIf { tvProfile }, + content = content, + ) +} + +@Composable +private fun GameGridSkeleton( + settings: AppSettings, + tvProfile: Boolean, + storeLayout: Boolean, + storeRailCount: Int, + topContentPadding: Dp?, + modifier: Modifier = Modifier, +) { + val compact = settings.compactGameCards + val landscapeLayout = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE + val physicalControllerConnected = rememberPhysicalControllerConnected(enabled = tvProfile || landscapeLayout) + val controllerActionMode = catalogControllerActionMode(tvProfile, landscapeLayout, physicalControllerConnected) + val artworkOnly = shouldUseArtworkOnlyCatalogCards( + tvProfile = tvProfile, + controllerActionMode = controllerActionMode, + ) + + val shimmerOffset: State? + val tvPulse: State? + // Under reduced motion the skeletons still show — they just stop animating. A never-ending + // sweep is exactly the kind of movement the setting exists to stop. + if (LocalReduceMotion.current) { + shimmerOffset = null + tvPulse = null + } else if (tvProfile) { + val transition = rememberInfiniteTransition(label = "loading-pulse-global") + val pulse = transition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 900, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "loading-pulse-global", + ) + shimmerOffset = null + tvPulse = pulse + } else { + val transition = rememberInfiniteTransition(label = "shimmer-global") + val shimmer = transition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = SHIMMER_CYCLE_DURATION_MS, easing = LinearEasing), + ), + label = "shimmer-offset-global", + ) + shimmerOffset = shimmer + tvPulse = null + } + + CompositionLocalProvider( + LocalShimmerOffset provides shimmerOffset, + LocalTvLoadingPulse provides tvPulse, + ) { + BoxWithConstraints(modifier.fillMaxSize()) { + val gridSpec = gameGridSpec(maxWidth, compact, landscapeLayout, settings, handheldLayout = !tvProfile) + val contentPadding = gridSpec.contentPadding.withTop( + when { + storeLayout && landscapeLayout && !tvProfile -> 0.dp + else -> topContentPadding + }, + ) + val placeholderItems = remember(gridSpec.columnCount, storeLayout) { + List(catalogSkeletonPlaceholderCount(gridSpec.columnCount, storeLayout)) { it } + } + LazyVerticalGrid( + modifier = Modifier.fillMaxSize(), + columns = gridSpec.cells, + contentPadding = contentPadding, + horizontalArrangement = Arrangement.spacedBy(gridSpec.horizontalSpacing), + verticalArrangement = Arrangement.spacedBy(gridSpec.verticalSpacing), + userScrollEnabled = false, + ) { + if (storeLayout) { + item(span = { GridItemSpan(maxLineSpan) }) { + StoreStartRailsSkeleton( + settings = settings, + tvProfile = tvProfile, + railCount = storeRailCount, + ) + } + item(span = { GridItemSpan(maxLineSpan) }) { + SkeletonSectionHeader( + modifier = Modifier.padding( + top = OpenNowSpacing.lg, + bottom = OpenNowSpacing.sm, + ), + ) + } + } + gridItems(placeholderItems, key = { it }) { + GameCardSkeleton( + expressiveUi = settings.expressiveUi, + tvProfile = tvProfile, + squareCard = gridSpec.squareCards, + thumbnailFavoriteOverlay = shouldShowCatalogFavoriteIcon(settings), + showCardTitles = !artworkOnly && shouldShowCatalogCardTitles( + tvProfile = tvProfile, + enabled = settings.showCardTitles, + ), + ) + } + } + } + } +} + +@Composable +private fun StoreStartRailsSkeleton( + settings: AppSettings, + tvProfile: Boolean, + railCount: Int, +) { + val landscapeLayout = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE + val showFeaturedHero = shouldShowStoreHero( + tvProfile = tvProfile, + landscape = landscapeLayout, + landscapeEnabled = settings.landscapeNewGamesHero, + ) + Column( + Modifier + .fillMaxWidth() + .padding(top = if (landscapeLayout) 0.dp else 2.dp, bottom = 6.dp), + verticalArrangement = Arrangement.spacedBy(OpenNowSpacing.lg), + ) { + if (showFeaturedHero) { + StoreHeroSkeleton( + settings = settings, + tvProfile = tvProfile, + landscapeLayout = landscapeLayout, + ) + } + repeat(railCount.coerceAtLeast(1)) { + StoreRailSectionSkeleton( + expressiveUi = settings.expressiveUi, + tvProfile = tvProfile, + landscapeLayout = landscapeLayout, + cardScale = settings.posterSizeScale, + showFavoriteIcon = shouldShowCatalogFavoriteIcon(settings), + ) + } + } +} + +/** Mirrors the configured Coming next hero so the first loaded frame does not reshape the Store. */ +@Composable +private fun StoreHeroSkeleton( + settings: AppSettings, + tvProfile: Boolean, + landscapeLayout: Boolean, +) { + val shape = RoundedCornerShape(if (settings.expressiveUi) 24.dp else 16.dp) + Column( + Modifier + .fillMaxWidth() + .padding(top = if (landscapeLayout && !tvProfile) 0.dp else 6.dp), + verticalArrangement = Arrangement.spacedBy( + if (landscapeLayout && !tvProfile) OpenNowSpacing.sm else OpenNowSpacing.md, + ), + ) { + SkeletonSectionHeader( + showSubtitle = true, + reserveTrailingAction = landscapeLayout && !tvProfile, + ) + Surface( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(heroAspectRatio(tvProfile, landscapeLayout)) + .border( + 2.dp, + storeHeroBorderColor(LocalGameCardBordersEnabled.current), + shape, + ), + shape = shape, + color = Panel, + tonalElevation = 0.dp, + shadowElevation = 1.dp, + ) { + Box(Modifier.fillMaxSize().clip(shape)) { + LoadingShimmer(Modifier.fillMaxSize()) + Column( + Modifier + .align(Alignment.BottomStart) + .fillMaxWidth(0.56f) + .padding(18.dp), + verticalArrangement = Arrangement.spacedBy(7.dp), + ) { + SkeletonLine(widthFraction = 1f, height = 18.dp) + SkeletonLine(widthFraction = 0.52f, height = 10.dp) + } + if (shouldShowCatalogFavoriteIcon(settings)) { + SkeletonCircle( + size = 38.dp, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(14.dp), + ) + } + LoadingShimmer( + Modifier + .align(Alignment.BottomEnd) + .padding(14.dp) + .width(58.dp) + .height(19.dp) + .clip(RoundedCornerShape(999.dp)), + ) + } + } + } +} + +@Composable +private fun SkeletonSectionHeader( + modifier: Modifier = Modifier, + showSubtitle: Boolean = false, + reserveTrailingAction: Boolean = false, +) { + Row( + modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + CatalogSectionHeaderText( + title = null, + subtitle = null, + showSubtitle = showSubtitle, + modifier = Modifier.weight(1f), + ) + if (reserveTrailingAction) { + Spacer(Modifier.size(48.dp)) + } + } +} + +@Composable +private fun StoreRailSectionSkeleton( + expressiveUi: Boolean, + tvProfile: Boolean, + landscapeLayout: Boolean, + cardScale: Float, + showFavoriteIcon: Boolean, +) { + val spacing = OpenNowSpacing.md + val contentInset = OpenNowSpacing.ScreenEdge + Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) { + SkeletonSectionHeader() + BoxWithConstraints( + Modifier + .horizontalBleed(contentInset) + .clipToBounds(), + ) { + val cardWidth = storeRailCardWidth(tvProfile, landscapeLayout, cardScale) + val visibleCount = storeRailVisibleCardCount( + availableWidthDp = (maxWidth - contentInset * 2).coerceAtLeast(1.dp).value, + cardWidthDp = cardWidth.value, + spacingDp = spacing.value, + ) + Row( + Modifier + .fillMaxWidth() + .padding(horizontal = contentInset), + horizontalArrangement = Arrangement.spacedBy(spacing), + ) { + repeat(visibleCount) { + StoreRailGameCardSkeleton( + width = cardWidth, + expressiveUi = expressiveUi, + tvProfile = tvProfile, + portraitCard = !tvProfile, + showFavoriteIcon = showFavoriteIcon, + ) + } + } + } + } +} + +@Composable +private fun StoreRailGameCardSkeleton( + width: Dp, + expressiveUi: Boolean, + tvProfile: Boolean, + portraitCard: Boolean, + showFavoriteIcon: Boolean, +) { + val shape = RoundedCornerShape(if (expressiveUi) 12.dp else 8.dp) + Surface( + modifier = Modifier + .width(width) + .padding(vertical = if (tvProfile) CATALOG_CONTROLLER_FOCUS_INSET else 0.dp) + .aspectRatio(if (portraitCard) GAME_BOX_ART_ASPECT_RATIO else 1f) + .border( + 2.dp, + catalogCardBorderColor( + LocalActiveSelectionColor.current, + LocalGameCardBordersEnabled.current, + ), + shape, + ), + shape = shape, + color = Color.Black, + tonalElevation = 0.dp, + shadowElevation = 1.dp, + ) { + Box(Modifier.fillMaxSize().clip(shape)) { + LoadingShimmer(Modifier.fillMaxSize()) + if (showFavoriteIcon) { + SkeletonCircle( + size = 34.dp, + modifier = Modifier + .align(Alignment.TopStart) + .padding(6.dp), + ) + } + } + } +} + +/** Mirrors [GameCard]'s layout exactly, so nothing shifts when real content replaces it. */ +@Composable +private fun GameCardSkeleton( + expressiveUi: Boolean, + tvProfile: Boolean, + squareCard: Boolean, + thumbnailFavoriteOverlay: Boolean, + showCardTitles: Boolean, +) { + val cardShape = RoundedCornerShape(if (expressiveUi) OpenNowRadius.md else OpenNowRadius.sm) + Column( + Modifier + .fillMaxWidth() + .padding(vertical = if (tvProfile) CATALOG_CONTROLLER_FOCUS_INSET else 0.dp), + ) { + Box(Modifier.catalogCardArtworkSize(squareCard)) { + Card( + modifier = Modifier.matchParentSize(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.58f)), + shape = cardShape, + ) { + Box(Modifier.fillMaxSize()) { + LoadingShimmer(Modifier.fillMaxSize()) + if (thumbnailFavoriteOverlay) { + SkeletonCircle( + size = 34.dp, + modifier = Modifier + .align(Alignment.TopStart) + .padding(6.dp), + ) + } + } + } + } + if (showCardTitles) { + CatalogCardCaption(title = null) + } + } +} + +/** One artwork measurement path for loaded recommendation cards and their loading skeletons. */ +private fun Modifier.catalogCardArtworkSize(squareCard: Boolean): Modifier = + fillMaxWidth().aspectRatio(if (squareCard) 1f else GAME_BOX_ART_ASPECT_RATIO) + +private const val CATALOG_CARD_TITLE_LINES = 2 + +/** + * Uses the real title text measurement even while loading, so font scale and line height cannot + * make a loaded recommendation card taller than the skeleton it replaces. + */ +@Composable +private fun CatalogCardCaption(title: String?) { + Box( + Modifier + .fillMaxWidth() + .padding(top = OpenNowSpacing.sm), + ) { + Text( + text = title ?: " ", + color = if (title == null) Color.Transparent else TextPrimary, + style = MaterialTheme.typography.titleMedium, + maxLines = CATALOG_CARD_TITLE_LINES, + minLines = CATALOG_CARD_TITLE_LINES, + overflow = TextOverflow.Ellipsis, + ) + if (title == null) { + Column( + Modifier + .matchParentSize() + .padding(vertical = 5.dp), + verticalArrangement = Arrangement.SpaceBetween, + ) { + SkeletonLine(widthFraction = 0.86f) + SkeletonLine(widthFraction = 0.52f) + } + } + } +} + +@Composable +private fun SkeletonLine( + widthFraction: Float, + height: Dp = 9.dp, + modifier: Modifier = Modifier, +) { + LoadingShimmer( + modifier + .fillMaxWidth(widthFraction) + .height(height) + .clip(RoundedCornerShape(999.dp)), + ) +} + +@Composable +private fun SkeletonCircle(size: Dp, modifier: Modifier = Modifier) { + LoadingShimmer( + modifier + .size(size) + .clip(CircleShape), + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun SwipeToRefreshContainer( + refreshing: Boolean, + onRefresh: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + showRefreshIndicator: Boolean = true, + content: @Composable () -> Unit, +) { + if (!enabled) { + Box(modifier) { + content() + } + return + } + val pullRefreshState = rememberPullToRefreshState() + PullToRefreshBox( + isRefreshing = refreshing, + onRefresh = onRefresh, + modifier = modifier, + state = pullRefreshState, + indicator = { + if (showRefreshIndicator) { + PullToRefreshDefaults.Indicator( + state = pullRefreshState, + isRefreshing = refreshing, + modifier = Modifier.align(Alignment.TopCenter), + ) + } + }, + ) { + content() + } +} + +@Composable +private fun GameGrid( + games: List, + favoriteIds: List, + settings: AppSettings, + tvProfile: Boolean, + onSelect: (GameInfo) -> Unit, + onFavorite: (String) -> Unit, + onPlay: (GameInfo) -> Unit, + onChooseStore: (GameInfo) -> Unit, + topFocusRequester: FocusRequester? = null, + modifier: Modifier = Modifier, + gridState: androidx.compose.foundation.lazy.grid.LazyGridState = rememberLazyGridState(), + /** Overrides the spec's top inset when the caller has already spaced the grid off what's above it. */ + topContentPadding: Dp? = null, + emptyContent: (@Composable () -> Unit)? = null, +) { + if (games.isEmpty()) { + Box(modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + if (emptyContent != null) { + emptyContent() + } else { + Text(stringResource(R.string.no_games_loaded), color = TextMuted) + } + } + return + } + val compact = settings.compactGameCards + val landscapeLayout = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE + val physicalControllerConnected = rememberPhysicalControllerConnected(enabled = tvProfile || landscapeLayout) + val controllerActionMode = catalogControllerActionMode(tvProfile, landscapeLayout, physicalControllerConnected) + val artworkOnly = shouldUseArtworkOnlyCatalogCards(tvProfile, controllerActionMode) + val imageLoadingAnimationsEnabled by remember(gridState) { + derivedStateOf { !gridState.isScrollInProgress } + } + val favoriteIdSet = remember(favoriteIds) { favoriteIds.toHashSet() } + val density = LocalDensity.current + BoxWithConstraints(modifier.fillMaxSize()) { + val gridSpec = gameGridSpec(maxWidth, compact, landscapeLayout, settings, handheldLayout = !tvProfile) + val cardRequestWidth = catalogGridCardImageRequestWidth( + availableWidth = maxWidth, + gridSpec = gridSpec, + density = density, + tvProfile = tvProfile, + ) + val contentPadding = gridSpec.contentPadding.withTop(topContentPadding) + val firstRowGameIds = remember(games, gridSpec.columnCount) { + games.take(gridSpec.columnCount).mapTo(mutableSetOf()) { it.id } + } + CatalogImageLoadingAnimationProvider(tvProfile, imageLoadingAnimationsEnabled) { + CatalogFocusScope(enabled = tvProfile) { + LazyVerticalGrid( + modifier = Modifier.fillMaxSize(), + state = gridState, + columns = gridSpec.cells, + contentPadding = contentPadding, + horizontalArrangement = Arrangement.spacedBy(gridSpec.horizontalSpacing), + verticalArrangement = Arrangement.spacedBy(gridSpec.verticalSpacing), + ) { + gridItems(games, key = { it.id }, contentType = { "catalog-game" }) { game -> + GameCard( + game = game, + favorite = game.id in favoriteIdSet, + tvProfile = tvProfile, + expressiveUi = settings.expressiveUi, + liveSelectedOutlines = LocalActiveSelectionEnabled.current, + showCardTitles = !artworkOnly && shouldShowCatalogCardTitles( + tvProfile = tvProfile, + enabled = settings.showCardTitles, + ), + squareCard = gridSpec.squareCards, + imageRequestWidth = cardRequestWidth, + thumbnailFavoriteOverlay = shouldShowCatalogFavoriteIcon(settings), + controllerActionMode = controllerActionMode, + upFocusRequester = topFocusRequester.takeIf { game.id in firstRowGameIds }, + onSelect = onSelect, + onFavorite = onFavorite, + onPlay = onPlay, + onChooseStore = onChooseStore, + ) + } + } + } + } + } +} + +@Composable +private fun StoreGameGrid( + games: List, + favoriteIds: List, + settings: AppSettings, + tvProfile: Boolean, + state: OpenNowUiState, + onSelect: (GameInfo) -> Unit, + onFavorite: (String) -> Unit, + onPlay: (GameInfo) -> Unit, + onChooseStore: (GameInfo) -> Unit, + onSortChange: (String) -> Unit, + onFilterToggle: (String) -> Unit, + onHideLandscapeNewGames: () -> Unit, + onClearSearch: () -> Unit, + onClearFilters: () -> Unit, + gridState: androidx.compose.foundation.lazy.grid.LazyGridState, + showToolbar: Boolean = true, + topFocusRequester: FocusRequester? = null, + resultsOnly: Boolean = false, + modifier: Modifier = Modifier, +) { + if (games.isEmpty()) { + Column(modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(10.dp)) { + StoreScrollableControls(state, onSortChange, onFilterToggle, showToolbar = showToolbar) + if (resultsOnly) { + SectionHeader( + title = stringResource(R.string.store_results), + modifier = Modifier.padding(top = OpenNowSpacing.lg, bottom = OpenNowSpacing.sm), + ) + } + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + val hasSearch = state.catalogSearch.isNotBlank() + val hasFilters = state.catalogFilterIds.isNotEmpty() + if (hasSearch || hasFilters) { + SearchEmptyState( + title = stringResource(R.string.store_empty_search_title), + message = when { + hasSearch && hasFilters -> stringResource(R.string.store_empty_search_filters_body) + hasSearch -> stringResource(R.string.store_empty_search_body) + else -> stringResource(R.string.store_empty_filters_body) + }, + onClearSearch = if (hasSearch) onClearSearch else null, + onClearFilters = if (hasFilters) onClearFilters else null, + ) + } else { + Text(stringResource(R.string.no_games_loaded), color = TextMuted) + } + } + } + return + } + val compact = settings.compactGameCards + val landscapeLayout = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE + val physicalControllerConnected = rememberPhysicalControllerConnected(enabled = tvProfile || landscapeLayout) + val controllerActionMode = catalogControllerActionMode(tvProfile, landscapeLayout, physicalControllerConnected) + val artworkOnly = shouldUseArtworkOnlyCatalogCards(tvProfile, controllerActionMode) + val showControlsHeader = showToolbar || state.catalogFilterIds.isNotEmpty() || !state.error.isNullOrBlank() + val showDiscoverySections = shouldShowStoreDiscoverySections( + searchActive = state.catalogSearch.isNotBlank(), + filterActive = state.catalogFilterIds.isNotEmpty(), + ) + val imageLoadingAnimationsEnabled by remember(gridState) { + derivedStateOf { !gridState.isScrollInProgress } + } + val favoriteIdSet = remember(favoriteIds) { favoriteIds.toHashSet() } + val density = LocalDensity.current + BoxWithConstraints(modifier.fillMaxSize()) { + val gridSpec = gameGridSpec(maxWidth, compact, landscapeLayout, settings, handheldLayout = !tvProfile) + val cardRequestWidth = catalogGridCardImageRequestWidth( + availableWidth = maxWidth, + gridSpec = gridSpec, + density = density, + tvProfile = tvProfile, + ) + val contentPadding = gridSpec.contentPadding.withTop( + if (showDiscoverySections && landscapeLayout && !tvProfile) 0.dp else null, + ) + val firstRowGameIds = remember(games, gridSpec.columnCount) { + games.take(gridSpec.columnCount).mapTo(mutableSetOf()) { it.id } + } + CatalogImageLoadingAnimationProvider(tvProfile, imageLoadingAnimationsEnabled) { + CatalogFocusScope(enabled = tvProfile) { + LazyVerticalGrid( + modifier = Modifier.fillMaxSize(), + state = gridState, + columns = gridSpec.cells, + contentPadding = contentPadding, + horizontalArrangement = Arrangement.spacedBy(gridSpec.horizontalSpacing), + verticalArrangement = Arrangement.spacedBy(gridSpec.verticalSpacing), + ) { + if (showControlsHeader) { + item(span = { GridItemSpan(maxLineSpan) }) { + StoreScrollableControls(state, onSortChange, onFilterToggle, showToolbar = showToolbar) + } + } + if (showDiscoverySections) { + item(span = { GridItemSpan(maxLineSpan) }) { + StoreStartRails( + games = games, + newlyAddedGames = state.newlyAddedGames, + libraryGames = state.libraryGames, + favoriteIds = favoriteIds, + queuedGameKeys = state.queuedGameKeys, + settings = settings, + tvProfile = tvProfile, + controllerActionMode = controllerActionMode, + topFocusRequester = topFocusRequester, + onSelect = onSelect, + onFavorite = onFavorite, + onPlay = onPlay, + onChooseStore = onChooseStore, + onHideLandscapeNewGames = onHideLandscapeNewGames, + ) + } + } + if (games.isNotEmpty()) { + item(span = { GridItemSpan(maxLineSpan) }) { + SectionHeader( + title = stringResource( + if (showDiscoverySections) R.string.store_recommendations else R.string.store_results, + ), + modifier = Modifier.padding(top = OpenNowSpacing.lg, bottom = OpenNowSpacing.sm), + ) + } + } + gridItems(games, key = { it.id }, contentType = { "store-game" }) { game -> + GameCard( + game = game, + favorite = game.id in favoriteIdSet, + tvProfile = tvProfile, + expressiveUi = settings.expressiveUi, + liveSelectedOutlines = LocalActiveSelectionEnabled.current, + showCardTitles = !artworkOnly && shouldShowCatalogCardTitles( + tvProfile = tvProfile, + enabled = settings.showCardTitles, + ), + squareCard = gridSpec.squareCards, + imageRequestWidth = cardRequestWidth, + thumbnailFavoriteOverlay = shouldShowCatalogFavoriteIcon(settings), + controllerActionMode = controllerActionMode, + upFocusRequester = topFocusRequester.takeIf { + !showDiscoverySections && game.id in firstRowGameIds + }, + onSelect = onSelect, + onFavorite = onFavorite, + onPlay = onPlay, + onChooseStore = onChooseStore, + ) + } + } + } + } + } +} + +@Composable +private fun StoreStartRails( + games: List, + newlyAddedGames: List, + libraryGames: List, + favoriteIds: List, + queuedGameKeys: List, + settings: AppSettings, + tvProfile: Boolean, + controllerActionMode: Boolean, + topFocusRequester: FocusRequester?, + onSelect: (GameInfo) -> Unit, + onFavorite: (String) -> Unit, + onPlay: (GameInfo) -> Unit, + onChooseStore: (GameInfo) -> Unit, + onHideLandscapeNewGames: () -> Unit, +) { + val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE + val showFeaturedHero = shouldShowStoreHero( + tvProfile = tvProfile, + landscape = landscape, + landscapeEnabled = settings.landscapeNewGamesHero, + ) + val startRails = remember(games, libraryGames, favoriteIds, queuedGameKeys) { + storeStartRailGroups(games, libraryGames, favoriteIds, queuedGameKeys) + } + val featured = remember(newlyAddedGames, startRails, showFeaturedHero) { + if (showFeaturedHero) { + newlyAddedStoreHeroGames( + games = newlyAddedGames, + excludedGames = startRails.allGames, + ) + } else { + emptyList() + } + } + var confirmHideLandscapeNewGames by remember { mutableStateOf(false) } + if (startRails.isEmpty && featured.isEmpty()) return + Column( + Modifier + .fillMaxWidth() + .padding(top = if (landscape) 0.dp else 2.dp, bottom = 6.dp), + verticalArrangement = Arrangement.spacedBy(OpenNowSpacing.lg), + ) { + // The exact same hero leads on handheld and TV. Its aspect ratio adapts to the surface, + // but its feed, interaction model, and visual treatment remain shared. + if (featured.isNotEmpty()) { + StoreComingNextCarousel( + title = stringResource(R.string.catalog_sort_new_games), + games = featured, + favoriteIds = favoriteIds, + settings = settings, + tvProfile = tvProfile, + controllerActionMode = controllerActionMode, + upFocusRequester = topFocusRequester, + onSelect = onSelect, + onFavorite = onFavorite, + onPlay = onPlay, + onChooseStore = onChooseStore, + trailing = if (landscape && !tvProfile) { + { + IconButton(onClick = { confirmHideLandscapeNewGames = true }) { + Icon( + imageVector = Icons.Outlined.Close, + contentDescription = stringResource(R.string.store_landscape_new_games_hide), + tint = Color.White, + ) + } + } + } else { + null + }, + ) + } + StoreStartRail( + R.string.store_continue_playing, + startRails.continuePlaying, + favoriteIds, + settings, + tvProfile, + controllerActionMode, + topFocusRequester, + onSelect, + onFavorite, + onPlay, + onChooseStore, + ) + StoreStartRail( + R.string.store_in_queue, + startRails.inQueue, + favoriteIds, + settings, + tvProfile, + controllerActionMode, + topFocusRequester.takeIf { featured.isEmpty() && startRails.continuePlaying.isEmpty() }, + onSelect, + onFavorite, + onPlay, + onChooseStore, + ) + StoreStartRail( + R.string.store_favorites, + startRails.favorites, + favoriteIds, + settings, + tvProfile, + controllerActionMode, + topFocusRequester.takeIf { + featured.isEmpty() && startRails.continuePlaying.isEmpty() && startRails.inQueue.isEmpty() + }, + onSelect, + onFavorite, + onPlay, + onChooseStore, + ) + } + if (confirmHideLandscapeNewGames) { + AlertDialog( + onDismissRequest = { confirmHideLandscapeNewGames = false }, + title = { Text(stringResource(R.string.store_landscape_new_games_hide_title)) }, + text = { Text(stringResource(R.string.store_landscape_new_games_hide_body)) }, + confirmButton = { + TextButton( + onClick = { + confirmHideLandscapeNewGames = false + onHideLandscapeNewGames() + }, + ) { + Text(stringResource(R.string.common_dont_show_again)) + } + }, + dismissButton = { + TextButton(onClick = { confirmHideLandscapeNewGames = false }) { + Text(stringResource(R.string.action_cancel)) + } + }, + ) + } +} + +internal fun shouldShowStoreDiscoverySections(searchActive: Boolean, filterActive: Boolean): Boolean = + !searchActive && !filterActive + +internal fun shouldShowStoreHero( + tvProfile: Boolean, + landscape: Boolean, + landscapeEnabled: Boolean = true, +): Boolean = tvProfile || !landscape || landscapeEnabled + +internal fun shouldShowCatalogLoadingPlaceholder( + queryLoading: Boolean, + loadingGames: Boolean, + hasVisibleGames: Boolean, +): Boolean = queryLoading || (loadingGames && !hasVisibleGames) + +/** Background cache/network refreshes stay silent once the Store already has usable cards. */ +internal fun shouldShowCatalogRefreshIndicator(loadingGames: Boolean, hasVisibleGames: Boolean): Boolean = + loadingGames && !hasVisibleGames + +internal fun shouldHideStoreChromeOnScroll( + hideChromeWhenScrolled: Boolean, + scrolledAwayFromTop: Boolean, + physicalControllerConnected: Boolean, +): Boolean = hideChromeWhenScrolled && scrolledAwayFromTop && !physicalControllerConnected + +internal fun storeScreenTopPadding(controlsInTopBar: Boolean, phoneLandscapeHero: Boolean): Dp = when { + phoneLandscapeHero && controlsInTopBar -> 0.dp + controlsInTopBar -> 4.dp + else -> 12.dp +} + +/** Small wrapper so Store start rails share one section implementation. */ +@Composable +private fun StoreStartRail( + @StringRes titleRes: Int, + games: List, + favoriteIds: List, + settings: AppSettings, + tvProfile: Boolean, + controllerActionMode: Boolean, + upFocusRequester: FocusRequester?, + onSelect: (GameInfo) -> Unit, + onFavorite: (String) -> Unit, + onPlay: (GameInfo) -> Unit, + onChooseStore: (GameInfo) -> Unit, +) { + if (games.isEmpty()) return + StoreRailSection( + title = stringResource(titleRes), + games = games, + favoriteIds = favoriteIds, + settings = settings, + tvProfile = tvProfile, + controllerActionMode = controllerActionMode, + upFocusRequester = upFocusRequester, + onSelect = onSelect, + onFavorite = onFavorite, + onPlay = onPlay, + onChooseStore = onChooseStore, + ) +} + +/** + * The one heading treatment used by every catalog section — rails, the hero, and the + * recommendations grid — so a section title looks the same wherever it appears. Previously each + * of those sites styled its own `Text` and they had drifted apart. + */ +@Composable +private fun SectionHeader( + title: String, + modifier: Modifier = Modifier, + subtitle: String? = null, + trailing: (@Composable () -> Unit)? = null, +) { + Row( + modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + CatalogSectionHeaderText( + title = title, + subtitle = subtitle, + showSubtitle = !subtitle.isNullOrBlank(), + modifier = Modifier.weight(1f), + ) + trailing?.invoke() + } +} + +/** Real and loading section headers share the same text metrics and therefore the same height. */ +@Composable +private fun CatalogSectionHeaderText( + title: String?, + subtitle: String?, + showSubtitle: Boolean, + modifier: Modifier = Modifier, +) { + Column(modifier) { + Box(Modifier.fillMaxWidth()) { + Text( + text = title ?: " ", + color = if (title == null) Color.Transparent else TextPrimary, + style = MaterialTheme.typography.titleLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (title == null) { + SkeletonLine( + widthFraction = 0.34f, + height = 15.dp, + modifier = Modifier.align(Alignment.CenterStart), + ) + } + } + if (showSubtitle) { + Box(Modifier.fillMaxWidth()) { + Text( + text = subtitle ?: " ", + color = if (subtitle == null) Color.Transparent else TextMuted, + style = MaterialTheme.typography.labelMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (subtitle == null) { + SkeletonLine( + widthFraction = 0.48f, + height = 9.dp, + modifier = Modifier.align(Alignment.CenterStart), + ) + } + } + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun StoreComingNextCarousel( + title: String, + games: List, + favoriteIds: List, + settings: AppSettings, + tvProfile: Boolean, + controllerActionMode: Boolean, + upFocusRequester: FocusRequester?, + onSelect: (GameInfo) -> Unit, + onFavorite: (String) -> Unit, + onPlay: (GameInfo) -> Unit, + onChooseStore: (GameInfo) -> Unit, + trailing: (@Composable () -> Unit)? = null, +) { + if (games.isEmpty()) return + val context = LocalContext.current + val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE + var page by remember(games) { mutableIntStateOf(0) } + var focused by remember { mutableStateOf(false) } + val enhancedControllerFocus = shouldShowEnhancedControllerFocus( + focused = focused, + tvProfile = tvProfile, + controllerActionMode = controllerActionMode, + ) + val selectedGameId = LocalSelectedCatalogGameId.current + val reduceMotion = LocalReduceMotion.current + val storeScrolling = LocalCatalogImageRequestsPaused.current + val carouselProgress = remember { Animatable(0f) } + var carouselDragPx by remember { mutableFloatStateOf(0f) } + val swipeThresholdPx = with(LocalDensity.current) { 48.dp.toPx() } + val carouselDragState = rememberDraggableState { delta -> carouselDragPx += delta } + LaunchedEffect(games, page, focused, reduceMotion, storeScrolling) { + // Never auto-advance under the reader's hands: not while focused, and not at all when the + // user has asked for reduced motion. Vertical Store motion also gets the full frame budget. + carouselProgress.snapTo(0f) + if (shouldAnimateStoreHero(games.size, focused, reduceMotion, storeScrolling)) { + carouselProgress.animateTo( + targetValue = 1f, + animationSpec = tween( + durationMillis = HERO_CAROUSEL_ADVANCE_MS.toInt(), + easing = LinearEasing, + ), + ) + page = (page + 1) % games.size + } else if (games.size <= 1 || reduceMotion) { + carouselProgress.snapTo(1f) + } + } + Column( + Modifier + .fillMaxWidth() + .padding(top = if (landscape && !tvProfile) 0.dp else 6.dp), + verticalArrangement = Arrangement.spacedBy( + if (landscape && !tvProfile) OpenNowSpacing.sm else OpenNowSpacing.md, + ), + ) { + SectionHeader( + title = title, + subtitle = stringResource(R.string.store_coming_next_subtitle), + trailing = trailing, + ) + AnimatedContent( + targetState = page, + transitionSpec = { + fadeIn(tween(if (reduceMotion) 0 else OpenNowMotion.DurationStandard)) togetherWith + fadeOut(tween(if (reduceMotion) 0 else OpenNowMotion.DurationFast)) + }, + label = "coming-next-carousel", + ) { targetPage -> + val featured = games[targetPage.coerceIn(games.indices)] + val selected = featured.id == selectedGameId + val selectedOutline = shouldShowActiveSelectionOutline(selected, LocalActiveSelectionEnabled.current) + val shape = RoundedCornerShape(if (settings.expressiveUi) 24.dp else 16.dp) + val transitionRegistry = LocalGameDetailsTransitionRegistry.current + val transitionBounds = remember(featured.id) { arrayOfNulls(1) } + val selectFromHero = { + transitionBounds[0]?.let { + transitionRegistry?.record(featured.id, it, GameDetailsTransitionKind.Hero) + } + onSelect(featured) + } + Box( + Modifier + .fillMaxWidth() + // Aspect ratio rather than a fixed height, so the hero scales with the screen + // instead of dominating a small phone and looking stunted on a tablet. + .aspectRatio(heroAspectRatio(tvProfile, landscape)) + .draggable( + state = carouselDragState, + orientation = Orientation.Horizontal, + enabled = games.size > 1, + onDragStarted = { carouselDragPx = 0f }, + onDragStopped = { + if (abs(carouselDragPx) >= swipeThresholdPx) { + page = if (carouselDragPx < 0f) { + (page + 1) % games.size + } else { + (page - 1 + games.size) % games.size + } + } + carouselDragPx = 0f + }, + ), + ) { + Surface( + modifier = Modifier + .matchParentSize() + .onGloballyPositioned { transitionBounds[0] = it.boundsInWindow() } + .then( + upFocusRequester?.let { requester -> + Modifier.focusProperties { up = requester } + } ?: Modifier, + ) + .onFocusChanged { focused = it.isFocused || it.hasFocus } + .focusMoveHaptics() + .border( + width = if (focused) 3.dp else 2.dp, + color = storeHeroBorderColor( + gameBorderEnabled = LocalGameCardBordersEnabled.current, + controllerFocused = enhancedControllerFocus, + borderEffectsEnabled = LocalAbsoluteCinemaEffects.current, + ), + shape = shape, + ) + .onPreviewKeyEvent { event -> + if (event.type != KeyEventType.KeyUp) return@onPreviewKeyEvent false + when { + controllerActionMode && event.key == Key.DirectionLeft && games.size > 1 -> { + page = (page - 1 + games.size) % games.size + true + } + controllerActionMode && event.key == Key.DirectionRight && games.size > 1 -> { + page = (page + 1) % games.size + true + } + controllerActionMode && handleCatalogControllerAction( + event = event, + onFavorite = { onFavorite(featured.id) }, + onPlay = { onPlay(featured) }, + ) -> true + isTvActivateKey(event) -> { + selectFromHero() + true + } + else -> false + } + } + .focusable() + .combinedClickable( + onClick = selectFromHero, + onLongClick = { onChooseStore(featured) }, + onLongClickLabel = stringResource(R.string.store_selector_play_long_press), + ), + shape = shape, + color = Panel, + tonalElevation = if (focused) 5.dp else 0.dp, + shadowElevation = if (focused) 9.dp else 1.dp, + ) { + Box(Modifier.fillMaxSize()) { + UrlImage(gameHeroImageUrl(context, featured), Modifier.fillMaxSize()) + // Horizontal scrim carries the title block; the vertical one settles the art + // into the surface below so the hero reads as part of the page, not a sticker. + Box( + Modifier + .matchParentSize() + .background( + Brush.horizontalGradient( + listOf(Color.Black.copy(alpha = 0.88f), Color.Black.copy(alpha = 0.3f), Color.Transparent), + ), + ), + ) + Box( + Modifier + .matchParentSize() + .background( + Brush.verticalGradient( + 0.45f to Color.Transparent, + 1f to Background.copy(alpha = 0.85f), + ), + ), + ) + Column( + Modifier + .align(Alignment.BottomStart) + .fillMaxWidth(0.74f) + .padding(18.dp), + verticalArrangement = Arrangement.spacedBy(5.dp), + ) { + Text( + featured.title, + color = Color.White, + style = when { + // Across a room the hero title is the only thing readable at a + // glance, so TV gets the display scale. + tvProfile -> MaterialTheme.typography.displaySmall + landscape -> MaterialTheme.typography.headlineSmall + else -> MaterialTheme.typography.headlineSmall + }, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + storeHeroSubtitle(featured)?.let { subtitle -> + Text( + subtitle, + color = Color.White.copy(alpha = 0.72f), + style = MaterialTheme.typography.labelMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + if (shouldShowCatalogFavoriteIcon(settings)) { + FavoriteIconButton( + favorite = featured.id in favoriteIds, + onClick = { onFavorite(featured.id) }, + modifier = Modifier.align(Alignment.TopEnd).padding(14.dp), + size = 38.dp, + ) + } + Surface( + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(14.dp), + shape = RoundedCornerShape(999.dp), + color = Color.Black.copy(alpha = 0.58f), + contentColor = Color.White, + tonalElevation = 0.dp, + ) { + HeroCarouselProgress( + pageCount = games.size, + activePage = page, + activeProgress = { carouselProgress.value }, + modifier = Modifier.padding(horizontal = 9.dp, vertical = 7.dp), + ) + } + } + } + ControllerFocusFrame( + visible = enhancedControllerFocus || selectedOutline || (focused && LocalAbsoluteCinemaEffects.current), + cornerRadius = if (settings.expressiveUi) 24.dp else 16.dp, + tint = if (selectedOutline || LocalAbsoluteCinemaEffects.current) LocalActiveSelectionColor.current else Color.White, + secondaryTint = if (selectedOutline || LocalAbsoluteCinemaEffects.current) LocalActiveSelectionSecondaryColor.current else Color.White, + ) + } + } + } +} + +internal fun shouldAnimateStoreHero( + pageCount: Int, + focused: Boolean, + reduceMotion: Boolean, + storeScrolling: Boolean, +): Boolean = pageCount > 1 && !focused && !reduceMotion && !storeScrolling + +@Composable +private fun HeroCarouselProgress( + pageCount: Int, + activePage: Int, + activeProgress: () -> Float, + modifier: Modifier = Modifier, +) { + val activeColor = MaterialTheme.colorScheme.primary + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(5.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + repeat(pageCount) { index -> + if (index == activePage) { + Box( + Modifier + .width(22.dp) + .height(5.dp) + .clip(CircleShape) + .background(activeColor.copy(alpha = 0.28f)), + ) { + Box( + Modifier + .matchParentSize() + .graphicsLayer { + scaleX = activeProgress().coerceIn(0f, 1f) + transformOrigin = TransformOrigin(0f, 0.5f) + } + .background(activeColor), + ) + } + } else { + Box( + Modifier + .width(6.dp) + .height(5.dp) + .clip(CircleShape) + .background(Color.White.copy(alpha = 0.38f)), + ) + } + } + } +} + +@Composable +private fun StoreRailSection( + title: String, + games: List, + favoriteIds: List, + settings: AppSettings, + tvProfile: Boolean, + controllerActionMode: Boolean, + upFocusRequester: FocusRequester?, + onSelect: (GameInfo) -> Unit, + onFavorite: (String) -> Unit, + onPlay: (GameInfo) -> Unit, + onChooseStore: (GameInfo) -> Unit, +) { + val landscapeLayout = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE + val railState = rememberLazyListState() + val railScrolling by remember(railState) { + derivedStateOf { railState.isScrollInProgress } + } + val parentImageRequestsPaused = LocalCatalogImageRequestsPaused.current + val parentImageAnimationsEnabled = LocalImageLoadingAnimationsEnabled.current + val parentShimmer = LocalShimmerOffset.current + val parentTvPulse = LocalTvLoadingPulse.current + val favoriteIdSet = remember(favoriteIds) { favoriteIds.toHashSet() } + Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(OpenNowSpacing.sm)) { + SectionHeader(title = title) + // The row breaks out of the grid's edge padding and re-applies it as content padding, so + // cards scroll all the way under the screen edge instead of stopping short of it. The + // header stays aligned to the content because the bleed is only on the row. + BoxWithConstraints(Modifier.horizontalBleed(OpenNowSpacing.ScreenEdge)) { + val spacing = OpenNowSpacing.md + val contentInset = OpenNowSpacing.ScreenEdge + // Use the persisted scale as an actual width multiplier. The old implementation used + // it only to choose a whole-number card count, then stretched cards to fill the row; + // most slider movements therefore appeared to do nothing. Matching the handheld rail + // base to the adaptive grid also keeps Continue playing from towering over the grid. + val cardWidth = storeRailCardWidth( + tvProfile = tvProfile, + landscapeLayout = landscapeLayout, + cardScale = settings.posterSizeScale, + ) + CompositionLocalProvider( + LocalCatalogImageRequestsPaused provides (parentImageRequestsPaused || railScrolling), + LocalImageLoadingAnimationsEnabled provides (parentImageAnimationsEnabled && !railScrolling), + LocalShimmerOffset provides parentShimmer.takeUnless { railScrolling }, + LocalTvLoadingPulse provides parentTvPulse.takeUnless { railScrolling }, + ) { + CatalogFocusScope(enabled = tvProfile) { + LazyRow( + state = railState, + horizontalArrangement = Arrangement.spacedBy(spacing), + contentPadding = PaddingValues(horizontal = contentInset), + ) { + items(games, key = { storeRailGameKey(it) }) { game -> + StoreRailGameCard( + game = game, + favorite = game.id in favoriteIdSet, + tvProfile = tvProfile, + expressiveUi = settings.expressiveUi, + liveSelectedOutlines = LocalActiveSelectionEnabled.current, + showFavoriteIcon = shouldShowCatalogFavoriteIcon(settings), + width = cardWidth, + controllerActionMode = controllerActionMode, + upFocusRequester = upFocusRequester, + onSelect = onSelect, + onFavorite = onFavorite, + onPlay = onPlay, + onChooseStore = onChooseStore, + ) + } + } + } + } + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun StoreRailGameCard( + game: GameInfo, + favorite: Boolean, + tvProfile: Boolean, + expressiveUi: Boolean, + liveSelectedOutlines: Boolean, + showFavoriteIcon: Boolean, + width: Dp, + controllerActionMode: Boolean, + upFocusRequester: FocusRequester?, + onSelect: (GameInfo) -> Unit, + onFavorite: (String) -> Unit, + onPlay: (GameInfo) -> Unit, + onChooseStore: (GameInfo) -> Unit, +) { + var focused by remember { mutableStateOf(false) } + val focusManager = LocalFocusManager.current + val shape = RoundedCornerShape(if (expressiveUi) OpenNowRadius.md else OpenNowRadius.sm) + val actionButtonSize = 34.dp + val enhancedControllerFocus = shouldShowEnhancedControllerFocus( + focused = focused, + tvProfile = tvProfile, + controllerActionMode = controllerActionMode, + ) + val selected = LocalSelectedCatalogGameId.current == game.id + val selectedOutline = shouldShowActiveSelectionOutline(selected, liveSelectedOutlines) + val interaction = remember { MutableInteractionSource() } + val pressed by interaction.collectIsPressedAsState() + val observeHover = tvProfile || controllerActionMode || LocalAbsoluteCinemaEffects.current + val hovered = if (observeHover) interaction.collectIsHoveredAsState().value else false + val reduceMotion = LocalReduceMotion.current + val cardScale by animateFloatAsState( + targetValue = when { + pressed -> 0.965f + focused || hovered -> when { + tvProfile -> 1.08f + controllerActionMode -> 1f + else -> 1.035f + } + else -> 1f + }, + animationSpec = tween( + durationMillis = if (reduceMotion) 0 else OpenNowMotion.DurationStandard, + easing = OpenNowMotion.EasingStandard, + ), + label = "rail-card-scale", + ) + val dimAlpha = rememberCatalogCardAlpha(focused = focused, tvProfile = tvProfile) + val transitionRegistry = LocalGameDetailsTransitionRegistry.current + val transitionBounds = remember(game.id) { arrayOfNulls(1) } + val selectFromCard = { + transitionBounds[0]?.let { + transitionRegistry?.record(game.id, it, GameDetailsTransitionKind.Card) + } + onSelect(game) + } + Box( + Modifier + .width(width) + .padding(vertical = if (tvProfile) CATALOG_CONTROLLER_FOCUS_INSET else 0.dp) + .aspectRatio(if (tvProfile) 1f else GAME_BOX_ART_ASPECT_RATIO) + .catalogCardTransform(scale = cardScale, alpha = dimAlpha) + .onGloballyPositioned { transitionBounds[0] = it.boundsInWindow() } + .semantics(mergeDescendants = true) { + contentDescription = game.title + role = Role.Button + }, + ) { + Surface( + modifier = Modifier + .matchParentSize() + .then( + upFocusRequester?.let { requester -> + Modifier.focusProperties { up = requester } + } ?: Modifier, + ) + .onFocusChanged { focused = it.isFocused || it.hasFocus } + .focusMoveHaptics() + .border( + width = if (focused) 3.dp else 2.dp, + color = catalogCardBorderColor( + selectionColor = LocalSelectionTintColor.current, + gameBorderEnabled = LocalGameCardBordersEnabled.current, + controllerFocused = enhancedControllerFocus, + borderEffectsEnabled = LocalAbsoluteCinemaEffects.current, + ), + shape = shape, + ) + .onPreviewKeyEvent { event -> + when { + controllerActionMode && handleCatalogControllerAction( + event = event, + onFavorite = { onFavorite(game.id) }, + onPlay = { onPlay(game) }, + ) -> true + isTvActivateKey(event) -> { + selectFromCard() + true + } + else -> handleDpadFocusMove(event, focusManager) + } + } + .focusable(interactionSource = interaction) + .combinedClickable( + interactionSource = interaction, + indication = null, + onClick = selectFromCard, + onLongClick = { onChooseStore(game) }, + onLongClickLabel = stringResource(R.string.store_selector_play_long_press), + ), + shape = shape, + color = OpenNowPalette.ImagePlaceholder, + tonalElevation = if (focused) 4.dp else 0.dp, + shadowElevation = if (focused) 8.dp else 1.dp, + ) { + Box(Modifier.fillMaxSize().clip(shape)) { + UrlImage( + catalogCardImageUrl(game, tvProfile), + Modifier.fillMaxSize(), + // Crop everywhere — see the note in GameCard. + contentScale = ContentScale.Crop, + ) + if (shouldOverlayCatalogCardTitle(tvProfile)) { + GameCardTitleOverlay(game.title) + } + if (showFavoriteIcon) { + FavoriteIconButton( + favorite = favorite, + onClick = { onFavorite(game.id) }, + modifier = Modifier + .align(Alignment.TopStart) + .padding(6.dp), + size = actionButtonSize, + ) + } + } + } + ControllerFocusFrame( + visible = enhancedControllerFocus || selectedOutline || ((focused || hovered) && LocalAbsoluteCinemaEffects.current), + cornerRadius = if (expressiveUi) 12.dp else 8.dp, + tint = if (selectedOutline || LocalAbsoluteCinemaEffects.current) LocalActiveSelectionColor.current else Color.White, + secondaryTint = if (selectedOutline || LocalAbsoluteCinemaEffects.current) LocalActiveSelectionSecondaryColor.current else Color.White, + ) + } +} + +/** + * The three rails that open the store, kept distinct. + * + * These used to be flattened into one "Jump back in" rail of `queued + favorites + recent + owned` + * capped at 14 items — which meant genuinely recently-played games sat third in priority and were + * routinely pushed off-screen by favourites, and the tail was padded with owned games the user had + * never launched. Owned games are the Library tab's job, so they are dropped here entirely. + */ +internal data class StoreStartRailGroups( + val continuePlaying: List, + val inQueue: List, + val favorites: List, +) { + val allGames: List get() = continuePlaying + inQueue + favorites + val isEmpty: Boolean get() = continuePlaying.isEmpty() && inQueue.isEmpty() && favorites.isEmpty() + val visibleGroupCount: Int + get() = listOf(continuePlaying, inQueue, favorites).count { it.isNotEmpty() } +} + +internal fun storeStartRailGroups( + games: List, + libraryGames: List, + favoriteIds: List, + queuedGameKeys: List, +): StoreStartRailGroups { + val favoriteSet = favoriteIds.toSet() + val combined = distinctStoreGames(libraryGames + games) + val byKey = combined.associateBy(::storeRailGameKey) + + val continuePlaying = combined + .filter { it.recentPlaySortKey() != null } + .sortedByDescending { it.recentPlaySortKey() } + .take(CONTINUE_PLAYING_RAIL_LIMIT) + val continueKeys = continuePlaying.map(::storeRailGameKey).toSet() + + val inQueue = queuedGameKeys + .mapNotNull(byKey::get) + .filterNot { storeRailGameKey(it) in continueKeys } + .take(STORE_RAIL_GAME_LIMIT) + val shownKeys = continueKeys + inQueue.map(::storeRailGameKey) + + // Favourites already visible above would just be a second sighting of the same card. + val favorites = combined + .filter { it.id in favoriteSet } + .filterNot { storeRailGameKey(it) in shownKeys } + .take(STORE_RAIL_GAME_LIMIT) + + return StoreStartRailGroups(continuePlaying, inQueue, favorites) +} + +/** Preserve the provider's New games added order without repeating the personal rails above it. */ +internal fun newlyAddedStoreHeroGames( + games: List, + excludedGames: List = emptyList(), +): List { + val distinctGames = distinctStoreGames(games) + val excludedKeys = excludedGames.mapTo(mutableSetOf(), ::storeRailGameKey) + val nonRepeatingGames = distinctGames + .filterNot { storeRailGameKey(it) in excludedKeys } + return nonRepeatingGames.ifEmpty { distinctGames }.take(HERO_CAROUSEL_PAGE_LIMIT) +} + +/** The hero identifies the game without repeating its storefront availability. */ +internal fun storeHeroSubtitle(game: GameInfo): String? = + game.publisherName?.trim()?.takeIf(String::isNotEmpty) + +private fun GameInfo.recentPlaySortKey(): String? = + listOfNotNull( + lastPlayed?.takeIf { it.isNotBlank() }, + variants.mapNotNull { it.lastPlayedDate?.takeIf(String::isNotBlank) }.maxOrNull(), + ).maxOrNull() + +private fun distinctStoreGames(games: List): List { + val byKey = linkedMapOf() + games.forEach { game -> + // Map.putIfAbsent is API 24; this module ships to 23 without core library desugaring. + val key = storeRailGameKey(game) + if (key !in byKey) byKey[key] = game + } + return byKey.values.toList() +} + +private fun storeRailGameKey(game: GameInfo): String = + gameTrackingKey(game) + +private const val STORE_RAIL_GAME_LIMIT = 14 + +/** Recently-played is a short list by nature — padding it out defeats the point of the rail. */ +private const val CONTINUE_PLAYING_RAIL_LIMIT = 12 + +/** Six hero pages keep the weekly selection varied without turning the progress row into a rash of dots. */ +private const val HERO_CAROUSEL_PAGE_LIMIT = 6 + +private const val HERO_CAROUSEL_ADVANCE_MS = 6_000L + +/** + * Wider on surfaces that are already wide, so the hero stays a banner rather than becoming a wall. + */ +private fun heroAspectRatio(tvProfile: Boolean, landscape: Boolean): Float = when { + tvProfile -> 16f / 6f + landscape -> 16f / 5f + else -> 16f / 8f +} + +/** The New games added hero keeps a white structural edge when game borders are enabled. */ +internal fun storeHeroBorderColor( + gameBorderEnabled: Boolean, + controllerFocused: Boolean = false, + borderEffectsEnabled: Boolean = false, +): Color = + catalogCardBorderColor( + selectionColor = Color.White, + gameBorderEnabled = gameBorderEnabled, + controllerFocused = controllerFocused, + borderEffectsEnabled = borderEffectsEnabled, + ) + +internal const val GAME_BOX_ART_ASPECT_RATIO = 628f / 888f + +internal fun shouldInitiallyFocusGameDetailsPlay(tvProfile: Boolean): Boolean = tvProfile + +private data class GameGridSpec( + val cells: GridCells, + /** Shared by loaded cards, skeleton rows, focus routing, and image request sizing. */ + val columnCount: Int, + val horizontalSpacing: Dp, + val verticalSpacing: Dp, + val contentPadding: PaddingValues, + val squareCards: Boolean, +) + +/** + * Number of catalog cards currently holding focus inside the surrounding grid or rail. A count + * rather than a flag so that handing focus from one card to its neighbour — where the old card + * reports losing focus in the same frame the new one reports gaining it — never dips to "nothing + * is focused" and flickers the dim. + */ +private val LocalCatalogFocusCount = compositionLocalOf { null } + +/** + * Scopes the focus count to one grid or one rail, so focusing a card in the grid doesn't dim the + * rails above it. + */ +@Composable +private fun CatalogFocusScope( + enabled: Boolean, + content: @Composable () -> Unit, +) { + if (!enabled) { + content() + return + } + val count = remember { mutableIntStateOf(0) } + CompositionLocalProvider(LocalCatalogFocusCount provides count, content = content) +} + +/** Alpha applied to unfocused cards while a sibling is focused. TV only. */ +private const val TV_UNFOCUSED_CARD_ALPHA = 0.55f + +/** + * Registers this card's focus in the surrounding [CatalogFocusScope] and returns the alpha it + * should draw at. Dimming the neighbours is what makes the focus cursor readable from across a + * room — on TV a border and a scale change alone still leave a wall of equally bright artwork. + */ +@Composable +private fun rememberCatalogCardAlpha(focused: Boolean, tvProfile: Boolean): Float { + // Phone cards never participate in sibling dimming. Returning before reading the focus scope + // avoids installing a DisposableEffect on every poster composed during a fast fling. + if (!tvProfile) return 1f + val count = LocalCatalogFocusCount.current + DisposableEffect(focused, count) { + if (focused) count?.intValue = (count?.intValue ?: 0) + 1 + onDispose { + if (focused) count?.intValue = ((count?.intValue ?: 1) - 1).coerceAtLeast(0) + } + } + val anyFocused = (count?.intValue ?: 0) > 0 + val target = if (anyFocused && !focused) TV_UNFOCUSED_CARD_ALPHA else 1f + val reduceMotion = LocalReduceMotion.current + val alpha by animateFloatAsState( + targetValue = target, + animationSpec = tween( + durationMillis = if (reduceMotion) 0 else OpenNowMotion.DurationStandard, + easing = OpenNowMotion.EasingStandard, + ), + label = "catalog-card-dim", + ) + return alpha +} + +/** Avoids allocating a graphics layer for every idle card in a long Store grid or rail. */ +private fun Modifier.catalogCardTransform(scale: Float, alpha: Float): Modifier = + if (scale == 1f && alpha == 1f) { + this + } else { + graphicsLayer { + scaleX = scale + scaleY = scale + this.alpha = alpha + } + } + +/** + * Lets a child extend [bleed] past its parent's bounds on both sides without reporting the extra + * width upward — the standard way to make a horizontally scrolling row run edge to edge inside a + * padded container. + */ +private fun Modifier.horizontalBleed(bleed: Dp): Modifier = this.layout { measurable, constraints -> + val extra = bleed.roundToPx() * 2 + val placeable = measurable.measure( + constraints.copy( + maxWidth = if (constraints.hasBoundedWidth) constraints.maxWidth + extra else constraints.maxWidth, + ), + ) + val reportedWidth = (placeable.width - extra).coerceAtLeast(0) + layout(reportedWidth, placeable.height) { + placeable.place(-bleed.roundToPx(), 0) + } +} + +private fun storeRailCardWidth( + tvProfile: Boolean, + landscapeLayout: Boolean, + cardScale: Float, +): Dp { + val baseWidth = when { + tvProfile -> 158.dp + landscapeLayout -> GRID_CELL_WIDTH_LANDSCAPE + else -> GRID_CELL_WIDTH_PORTRAIT + } + return scaledCatalogCardWidthDp(baseWidth.value, cardScale).dp +} + +internal fun scaledCatalogCardWidthDp(baseCardWidthDp: Float, cardScale: Float): Float = + baseCardWidthDp * cardScale.coerceIn(MIN_GAME_CARD_SCALE, MAX_GAME_CARD_SCALE) + +/** Target widths at `posterSizeScale == 1`; the resolved count still adapts continuously to width. */ +private val GRID_CELL_WIDTH_PORTRAIT = 96.dp +private val GRID_CELL_WIDTH_LANDSCAPE = 112.dp +private val GRID_CELL_WIDTH_TV = 158.dp + +/** Compact mode shrinks the target cell rather than switching to a separate size table. */ +private const val COMPACT_CELL_WIDTH_FACTOR = 0.88f +private val CATALOG_CONTROLLER_FOCUS_INSET = 8.dp + +internal data class CatalogGridMetrics( + val targetCellWidthDp: Float, + val columnCount: Int, +) + +/** Always produces complete placeholder rows for the exact resolved recommendation column count. */ +internal fun catalogSkeletonPlaceholderCount(columnCount: Int, storeLayout: Boolean): Int = + columnCount.coerceAtLeast(1) * if (storeLayout) 4 else 3 + +/** + * Resolves the catalogue grid once for both real content and its skeleton. + * + * Keeping this as one settings-aware calculation is important: allowing [GridCells.Adaptive] to + * resolve the loaded grid while a separate estimate sizes the skeleton can leave a partial final + * row whenever density rounding or a card-size setting makes those decisions differ. + */ +internal fun catalogGridMetrics( + availableWidthDp: Float, + compact: Boolean, + landscapeLayout: Boolean, + posterSizeScale: Float, + handheldLayout: Boolean, +): CatalogGridMetrics { + val horizontalSpacingDp = if (compact) OpenNowSpacing.sm.value else OpenNowSpacing.GridGutter.value + val horizontalPaddingDp = OpenNowSpacing.ScreenEdge.value + val baseCellWidthDp = when { + !handheldLayout -> GRID_CELL_WIDTH_TV.value + landscapeLayout -> GRID_CELL_WIDTH_LANDSCAPE.value + else -> GRID_CELL_WIDTH_PORTRAIT.value + } + val targetCellWidthDp = ( + baseCellWidthDp * + posterSizeScale.coerceIn(MIN_GAME_CARD_SCALE, MAX_GAME_CARD_SCALE) * + if (compact) COMPACT_CELL_WIDTH_FACTOR else 1f + ).coerceIn(64f, 240f) + val usableWidthDp = (availableWidthDp - horizontalPaddingDp * 2f) + .coerceAtLeast(targetCellWidthDp) + val columnCount = kotlin.math.floor( + (usableWidthDp + horizontalSpacingDp) / (targetCellWidthDp + horizontalSpacingDp), + ).toInt().coerceIn(1, 12) + + return CatalogGridMetrics( + targetCellWidthDp = targetCellWidthDp, + columnCount = columnCount, + ) +} + +private fun gameGridSpec( + maxWidth: androidx.compose.ui.unit.Dp, + compact: Boolean, + landscapeLayout: Boolean, + settings: AppSettings, + handheldLayout: Boolean, +): GameGridSpec { + val horizontalSpacing = if (compact) OpenNowSpacing.sm else OpenNowSpacing.GridGutter + val verticalSpacing = if (compact) OpenNowSpacing.md else OpenNowSpacing.GridRowGap + val horizontalPadding = OpenNowSpacing.ScreenEdge + + val metrics = catalogGridMetrics( + availableWidthDp = maxWidth.value, + compact = compact, + landscapeLayout = landscapeLayout, + posterSizeScale = settings.posterSizeScale, + handheldLayout = handheldLayout, + ) + + return GameGridSpec( + // Fixed uses the exact count resolved above but still shares remaining width evenly, which + // is the same responsive presentation as Adaptive without a second independent decision. + cells = GridCells.Fixed(metrics.columnCount), + columnCount = metrics.columnCount, + horizontalSpacing = horizontalSpacing, + verticalSpacing = verticalSpacing, + contentPadding = PaddingValues( + start = horizontalPadding, + top = OpenNowSpacing.md, + end = horizontalPadding, + bottom = AppScrollEndSpacing, + ), + // TV grid cards match the TV rail cards, which have always been square — this is the shape + // NVIDIA's tvCardImageUrl assets are cut for. + squareCards = !handheldLayout, + ) +} + +private fun catalogGridCardImageRequestWidth( + availableWidth: Dp, + gridSpec: GameGridSpec, + density: androidx.compose.ui.unit.Density, + tvProfile: Boolean, +): Int { + val direction = LayoutDirection.Ltr + val horizontalPadding = gridSpec.contentPadding.calculateStartPadding(direction) + + gridSpec.contentPadding.calculateEndPadding(direction) + val gaps = gridSpec.horizontalSpacing * (gridSpec.columnCount - 1).coerceAtLeast(0) + val cardWidth = ((availableWidth - horizontalPadding - gaps) / gridSpec.columnCount) + .coerceAtLeast(1.dp) + val cardWidthPx = with(density) { cardWidth.roundToPx() } + return catalogCardImageRequestWidth(cardWidthPx, tvProfile) +} + +/** + * Keeps phone-grid downloads close to the actual card width. The old unconditional 512 px request + * made a 96 dp card on a high-density POCO decode roughly four times the pixels it displayed, + * creating avoidable uploads and GC pressure during a 120 Hz fling. + */ +internal fun catalogCardImageRequestWidth(cardWidthPx: Int, tvProfile: Boolean): Int = when { + tvProfile -> TV_CARD_IMAGE_REQUEST_WIDTH + cardWidthPx <= 240 -> 256 + cardWidthPx <= 340 -> 384 + cardWidthPx <= 460 -> 512 + else -> 640 +} + +/** Bounded Store precomposition budget that protects the tighter high-refresh frame deadline. */ +internal fun catalogCacheWindowFractions(refreshRateHz: Float): Pair = when { + refreshRateHz >= 110f -> 0.25f to 0.08f + refreshRateHz >= 80f -> 0.4f to 0.17f + else -> 0.33f to 0.17f +} + +internal fun appContentEdgePaddingDp( + settings: AppSettings, + inStream: Boolean, + tvProfile: Boolean, +): Float = if (inStream || !tvProfile) 0f else settings.tvSafeAreaPaddingDp.coerceIn(0f, 120f) + +internal fun storeRailVisibleCardCount( + availableWidthDp: Float, + cardWidthDp: Float, + spacingDp: Float, +): Int = kotlin.math.floor( + (availableWidthDp + spacingDp) / (cardWidthDp.coerceAtLeast(1f) + spacingDp), +).toInt().coerceAtLeast(1) + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun GameCard( + game: GameInfo, + favorite: Boolean, + tvProfile: Boolean, + expressiveUi: Boolean, + liveSelectedOutlines: Boolean, + showCardTitles: Boolean, + squareCard: Boolean, + imageRequestWidth: Int = MOBILE_CARD_IMAGE_REQUEST_WIDTH, + thumbnailFavoriteOverlay: Boolean, + controllerActionMode: Boolean, + upFocusRequester: FocusRequester? = null, + onSelect: (GameInfo) -> Unit, + onFavorite: (String) -> Unit, + onPlay: (GameInfo) -> Unit, + onChooseStore: (GameInfo) -> Unit, +) { + var focused by remember { mutableStateOf(false) } + val focusManager = LocalFocusManager.current + val cardShape = RoundedCornerShape(if (expressiveUi) OpenNowRadius.md else OpenNowRadius.sm) + val handheldPosterCard = !tvProfile + val launcherTile = handheldPosterCard && thumbnailFavoriteOverlay + val overlayActionSize = if (launcherTile) 34.dp else 44.dp + val overlayActionPadding = if (launcherTile) 6.dp else 8.dp + val enhancedControllerFocus = shouldShowEnhancedControllerFocus( + focused = focused, + tvProfile = tvProfile, + controllerActionMode = controllerActionMode, + ) + val selected = LocalSelectedCatalogGameId.current == game.id + val selectedOutline = shouldShowActiveSelectionOutline(selected, liveSelectedOutlines) + // Touch-handheld captions live outside the poster, so the artwork stays visually clean. + val showCaption = handheldPosterCard && showCardTitles + + val interaction = remember { MutableInteractionSource() } + val pressed by interaction.collectIsPressedAsState() + // Hover state is useful for a TV, controller, mouse, or the opt-in cinema treatment. A normal + // touch phone cannot produce hover, so observing it on every grid item is pure churn. + val observeHover = tvProfile || controllerActionMode || LocalAbsoluteCinemaEffects.current + val hovered = if (observeHover) interaction.collectIsHoveredAsState().value else false + val reduceMotion = LocalReduceMotion.current + val cardScale by animateFloatAsState( + targetValue = when { + pressed -> 0.965f + // A bigger lift on TV: from three metres a border change is nearly invisible, but a + // card growing out of the grid is unmistakable. + focused || hovered -> when { + tvProfile -> 1.08f + controllerActionMode -> 1f + else -> 1.035f + } + else -> 1f + }, + animationSpec = tween( + durationMillis = if (reduceMotion) 0 else OpenNowMotion.DurationStandard, + easing = OpenNowMotion.EasingStandard, + ), + label = "game-card-scale", + ) + val dimAlpha = rememberCatalogCardAlpha(focused = focused, tvProfile = tvProfile) + val transitionRegistry = LocalGameDetailsTransitionRegistry.current + val transitionBounds = remember(game.id) { arrayOfNulls(1) } + val selectFromCard = { + transitionBounds[0]?.let { + transitionRegistry?.record(game.id, it, GameDetailsTransitionKind.Card) + } + onSelect(game) + } + + Column( + Modifier + .fillMaxWidth() + .padding(vertical = if (tvProfile) CATALOG_CONTROLLER_FOCUS_INSET else 0.dp) + .catalogCardTransform(scale = cardScale, alpha = dimAlpha) + // One merged node per card. Without this TalkBack reads nothing at all here: UrlImage + // passes a null contentDescription and phone cards carry no title text of their own. + .semantics(mergeDescendants = true) { + contentDescription = game.title + role = Role.Button + }, + ) { + Box(Modifier.catalogCardArtworkSize(squareCard)) { + Card( + modifier = Modifier + .matchParentSize() + .onGloballyPositioned { transitionBounds[0] = it.boundsInWindow() } + .then( + upFocusRequester?.let { requester -> + Modifier.focusProperties { up = requester } + } ?: Modifier, + ) + .onFocusChanged { focused = it.isFocused || it.hasFocus } + .focusMoveHaptics() + .border( + width = if (focused) 3.dp else 2.dp, + color = catalogCardBorderColor( + selectionColor = LocalSelectionTintColor.current, + gameBorderEnabled = LocalGameCardBordersEnabled.current, + controllerFocused = enhancedControllerFocus, + borderEffectsEnabled = LocalAbsoluteCinemaEffects.current, + ), + shape = cardShape, + ) + .onPreviewKeyEvent { event -> + when { + controllerActionMode && handleCatalogControllerAction( + event = event, + onFavorite = { onFavorite(game.id) }, + onPlay = { onPlay(game) }, + ) -> true + isTvActivateKey(event) -> { + selectFromCard() + true + } + else -> handleDpadFocusMove(event, focusManager) + } + } + .focusable(interactionSource = interaction), + colors = CardDefaults.cardColors( + containerColor = if (expressiveUi) MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.72f) else Panel, + ), + elevation = CardDefaults.cardElevation(defaultElevation = if (focused) 8.dp else 0.dp), + shape = cardShape, + ) { + Box( + Modifier + .fillMaxSize() + .combinedClickable( + interactionSource = interaction, + indication = null, + onClick = selectFromCard, + onLongClick = { onChooseStore(game) }, + onLongClickLabel = stringResource(R.string.store_selector_play_long_press), + ), + ) { + UrlImage( + catalogCardImageUrl(game, tvProfile, imageRequestWidth), + Modifier.fillMaxSize(), + // Always Crop. The card is already locked to NVIDIA's box-art ratio, so for + // correctly-cut art this is identical to Fit; when the CDN returns something + // off-ratio, Fit pillarboxed it against a flat swatch and Crop simply trims. + contentScale = ContentScale.Crop, + ) + if (shouldOverlayCatalogCardTitle(tvProfile)) { + GameCardTitleOverlay(game.title) + } + if (thumbnailFavoriteOverlay) { + FavoriteIconButton( + favorite = favorite, + onClick = { onFavorite(game.id) }, + modifier = Modifier + .align(Alignment.TopStart) + .padding(overlayActionPadding), + size = overlayActionSize, + ) + } + } + } + ControllerFocusFrame( + visible = enhancedControllerFocus || selectedOutline || ((focused || hovered) && LocalAbsoluteCinemaEffects.current), + cornerRadius = if (expressiveUi) OpenNowRadius.md else OpenNowRadius.sm, + tint = if (selectedOutline || LocalAbsoluteCinemaEffects.current) LocalActiveSelectionColor.current else Color.White, + secondaryTint = if (selectedOutline || LocalAbsoluteCinemaEffects.current) LocalActiveSelectionSecondaryColor.current else Color.White, + ) + } + if (showCaption) { + CatalogCardCaption(title = game.title) + } + } +} + +internal fun catalogCardImageUrl( + game: GameInfo, + tvProfile: Boolean, + requestWidth: Int = if (tvProfile) TV_CARD_IMAGE_REQUEST_WIDTH else MOBILE_CARD_IMAGE_REQUEST_WIDTH, +): String? { + // Keep TV and handheld cards on the same GAME_BOX_ART source. Older caches can contain a + // TV_BANNER in imageUrl, so apply the same validation on both surfaces and use the dedicated + // TV artwork only as a compatibility fallback when no mobile poster exists. + val mobileSource = game.imageUrl + ?.takeIf { it.isNotBlank() } + ?.takeIf { !it.contains("img.nvidiagrid.net") || it.contains("/GAME_BOX_ART_") } + val source = mobileSource + ?: game.tvCardImageUrl?.takeIf { tvProfile && it.isNotBlank() } + ?: return null + return optimizedNvidiaImageUrl( + source, + width = requestWidth, + ) +} + +private const val TV_CARD_IMAGE_REQUEST_WIDTH = 272 +private const val MOBILE_CARD_IMAGE_REQUEST_WIDTH = 512 + +@Suppress("UNUSED_PARAMETER") +internal fun shouldOverlayCatalogCardTitle(tvProfile: Boolean): Boolean = false + +internal fun shouldUseArtworkOnlyCatalogCards(tvProfile: Boolean, controllerActionMode: Boolean): Boolean = + tvProfile || controllerActionMode + +internal fun catalogControllerActionMode( + tvProfile: Boolean, + landscapeLayout: Boolean, + physicalControllerConnected: Boolean, +): Boolean = physicalControllerConnected && (tvProfile || landscapeLayout) + +internal fun shouldShowCatalogFavoriteIcon(settings: AppSettings): Boolean = + settings.showFavoriteIconOnGameCards + +/** Titles may be captioned on touch handhelds; controller-first layouts suppress them upstream. */ +internal fun shouldShowCatalogCardTitles(tvProfile: Boolean, enabled: Boolean): Boolean = + enabled && !tvProfile + +@Composable +private fun GameCardTitleOverlay(title: String) { + Box( + Modifier + .fillMaxSize() + .background(GameCardOverlayGradient), + contentAlignment = Alignment.BottomStart, + ) { + Text( + text = title, + color = Color.White, + fontWeight = FontWeight.ExtraBold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + ) + } +} + +private fun handleCatalogControllerAction( + event: androidx.compose.ui.input.key.KeyEvent, + onFavorite: () -> Unit, + onPlay: () -> Unit, +): Boolean { + if (event.type != KeyEventType.KeyUp) return false + return when (event.key) { + Key.ButtonX -> { + onFavorite() + true + } + Key.ButtonY -> { + onPlay() + true + } + else -> false + } +} + +@Composable +internal fun ControllerCatalogRailActionHints(modifier: Modifier = Modifier) { + Surface( + modifier = modifier.padding(horizontal = 3.dp), + shape = RoundedCornerShape(8.dp), + color = Color.Black.copy(alpha = 0.8f), + tonalElevation = 2.dp, + shadowElevation = 2.dp, + ) { + Column( + Modifier.padding(horizontal = 4.dp, vertical = 5.dp), + verticalArrangement = Arrangement.spacedBy(3.dp), + ) { + ControllerCatalogActionHint( + button = "X", + label = stringResource(R.string.action_save), + buttonColor = Color(0xff4aa3ff), + ) + ControllerCatalogActionHint( + button = "Y", + label = stringResource(R.string.action_play), + buttonColor = Color(0xffffcf40), + ) + } + } +} + +@Composable +private fun ControllerCatalogActionHint( + button: String, + label: String, + buttonColor: Color, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Surface( + modifier = Modifier.size(18.dp), + shape = CircleShape, + color = buttonColor, + ) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + button, + color = Color.Black, + fontWeight = FontWeight.Black, + style = MaterialTheme.typography.labelSmall, + ) + } + } + Text( + label, + color = Color.White, + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +internal fun launcherBadgeForStoreKey(storeKey: String?): LauncherBadge = + when (storeKey) { + "STEAM" -> LauncherBadge(R.drawable.ic_store_steam, "Steam", Color(0xff17324d)) + "EPIC", "EGS", "EPIC_GAMES_STORE" -> LauncherBadge(R.drawable.ic_store_epic, "Epic", Color(0xff111111)) + "HOYO", "HOYOVERSE", "HOYOPLAY", "HOYO_PLAY", "MIHOYO" -> LauncherBadge(R.drawable.ic_store_hoyo, "HoYo", Color(0xff2b62d9)) + "XBOX", "XBOX_GAME_PASS", "GAME_PASS" -> LauncherBadge(R.drawable.ic_store_xbox, "Xbox", Color(0xff107c10)) + "MICROSOFT", "MICROSOFT_STORE" -> LauncherBadge(R.drawable.ic_store_microsoft, "Microsoft Store", Color(0xff0067b8)) + "UBISOFT", "UBISOFT_CONNECT", "UPLAY" -> LauncherBadge(R.drawable.ic_store_ubisoft, "Ubisoft Connect", Color(0xff006efc)) + "EA", "EA_APP", "ORIGIN" -> LauncherBadge(R.drawable.ic_store_ea, "EA app", Color(0xffff4747)) + "GOG", "GOG.COM", "GOG_COM" -> LauncherBadge(R.drawable.ic_store_gog, "GOG", Color(0xff6a35a8)) + "BATTLENET", "BATTLE.NET", "BATTLE_NET", "BLIZZARD" -> LauncherBadge(R.drawable.ic_store_battlenet, "Battle.net", Color(0xff148eff)) + "RIOT", "RIOT_CLIENT", "RIOT_GAMES" -> LauncherBadge(R.drawable.ic_store_riot, "Riot", Color(0xffd13639)) + "ROCKSTAR", "ROCKSTAR_GAMES", "ROCKSTAR_GAMES_LAUNCHER" -> LauncherBadge(R.drawable.ic_store_rockstar, "Rockstar", Color(0xffffc400), Color(0xff111111)) + "NCSOFT", "NC_SOFT", "PURPLE" -> LauncherBadge(R.drawable.ic_tab_store, "NCSOFT", Color(0xffb4822d), Color(0xff111111)) + "GOOGLE_PLAY", "PLAY_STORE", "ANDROID" -> LauncherBadge(R.drawable.ic_store_google_play, "Google Play", Color(0xff0f9d58)) + "AMAZON", "AMAZON_GAMES" -> LauncherBadge(R.drawable.ic_store_amazon, "Amazon Games", Color(0xffff9900), Color(0xff111111)) + else -> LauncherBadge(R.drawable.ic_tab_store, "GeForce NOW", Color.Black.copy(alpha = 0.72f)) + } + +private fun displayStoresForGame(game: GameInfo): String { + val stores = displayStoresForVariants(game.variants).ifEmpty { + game.availableStores.map(::gameStoreDisplayName) + }.distinctBy { normalizeGameStore(it) } + return stores.joinToString(", ").ifBlank { "GeForce NOW" } +} + +@Composable +private fun ZortosPlayMark( + modifier: Modifier = Modifier, + ringColor: Color = MaterialTheme.colorScheme.primary, + playColor: Color = ringColor, +) { + Canvas(modifier) { + val play = Path().apply { + moveTo(size.width * 0.35f, size.height * 0.25f) + lineTo(size.width * 0.35f, size.height * 0.75f) + lineTo(size.width * 0.75f, size.height * 0.5f) + close() + } + drawPath(play, playColor) + } +} + +@Composable +internal fun AnimatedLaunchOverlay( + modifier: Modifier = Modifier, + enterFromTop: Boolean = false, + content: @Composable () -> Unit, +) { + val visibleState = remember { + MutableTransitionState(false).apply { + targetState = true + } + } + AnimatedVisibility( + visibleState = visibleState, + enter = fadeIn() + + slideInVertically(initialOffsetY = { if (enterFromTop) -it / 4 else it / 4 }) + + scaleIn(initialScale = 0.94f), + exit = fadeOut() + + slideOutVertically(targetOffsetY = { if (enterFromTop) -it / 4 else it / 4 }) + + scaleOut(targetScale = 0.94f), + modifier = modifier, + ) { + content() + } +} + +internal suspend fun requestFocusWithRetry( + focusRequester: FocusRequester, + initialDelayMs: Long = 80L, + retryDelayMs: Long = 70L, + attempts: Int = 4, +): Boolean { + if (initialDelayMs > 0L) delay(initialDelayMs) + repeat(attempts.coerceAtLeast(1)) { attempt -> + if (runCatching { focusRequester.requestFocus() }.getOrDefault(false)) return true + if (attempt + 1 < attempts) delay(retryDelayMs.coerceAtLeast(0L)) + } + return false +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +internal fun GameDetailsSheet( + game: GameInfo, + favorite: Boolean, + defaultVariantId: String?, + fullScreen: Boolean, + safeAreaPadding: Dp, + onPlay: (GameInfo) -> Unit, + onChooseStore: (GameInfo) -> Unit, + onFavorite: (String) -> Unit, + connectedTvName: String?, + onPlayOnTv: (GameInfo) -> Unit, + onDismiss: () -> Unit, +) { + val transitionRegistry = LocalGameDetailsTransitionRegistry.current + val transitionOrigin = transitionRegistry?.originFor(game.id) + val cardTransitionOrigin = transitionOrigin + ?.takeIf { it.kind == GameDetailsTransitionKind.Card } + ?.bounds + val reduceMotion = LocalReduceMotion.current + val containerProgress = remember(game.id) { + Animatable(if (cardTransitionOrigin == null || reduceMotion) 1f else 0f) + } + LaunchedEffect(game.id, cardTransitionOrigin, reduceMotion) { + if (cardTransitionOrigin == null || reduceMotion) { + containerProgress.snapTo(1f) + } else { + containerProgress.snapTo(0f) + containerProgress.animateTo( + targetValue = 1f, + animationSpec = tween( + durationMillis = OpenNowMotion.DurationStandard, + easing = OpenNowMotion.EasingStandard, + ), + ) + } + } + DisposableEffect(game.id, transitionRegistry) { + onDispose { transitionRegistry?.clear(game.id) } + } + val gameFocusRequester = remember(game.id) { FocusRequester() } + val playFocusRequester = remember(game.id) { FocusRequester() } + LaunchedEffect(game.id, fullScreen) { + val initialRequester = if (shouldInitiallyFocusGameDetailsPlay(tvProfile = fullScreen)) { + playFocusRequester + } else { + gameFocusRequester + } + requestFocusWithRetry(initialRequester) + } + BackHandler(onBack = onDismiss) + // Drag-to-dismiss for the phone sheet. Everyone reaches for this gesture on a bottom sheet and + // previously nothing happened — there was no handle and no drag response at all. Implemented + // here rather than by switching to ModalBottomSheet so the sheet keeps its lockedFocusGroup and + // focus requesters, which the controller and TV navigation depend on. + val density = LocalDensity.current + var dragOffset by remember(game.id) { mutableFloatStateOf(0f) } + var dismissRequested by remember(game.id) { mutableStateOf(false) } + val dismissThresholdPx = with(density) { SHEET_DISMISS_DRAG_THRESHOLD.toPx() } + val dismissGestureGate = remember(game.id) { SheetDismissGestureGate() } + fun requestDismissOnce() { + if (dismissRequested) return + dismissRequested = true + onDismiss() + } + fun settleSheetDrag(velocity: Float = 0f) { + dismissGestureGate.reset() + if (dragOffset > dismissThresholdPx || velocity > SHEET_DISMISS_FLING_VELOCITY) { + requestDismissOnce() + } else { + dragOffset = 0f + } + } + LaunchedEffect(dragOffset, fullScreen) { + // Nested scrolling does not guarantee a fling callback on every OEM/input path. Closing as + // soon as the sheet crosses the threshold prevents an off-screen sheet from leaving only + // its modal scrim behind waiting for a second tap. + if (!fullScreen && dragOffset > dismissThresholdPx) requestDismissOnce() + } + val dragState = rememberDraggableState { delta -> + dragOffset = (dragOffset + delta).coerceAtLeast(0f) + } + val sheetNestedScroll = remember(game.id, fullScreen, dismissThresholdPx) { + object : NestedScrollConnection { + override fun onPreScroll(available: Offset, source: androidx.compose.ui.input.nestedscroll.NestedScrollSource): Offset { + if (fullScreen || dragOffset <= 0f || available.y >= 0f) return Offset.Zero + val consumed = available.y.coerceAtLeast(-dragOffset) + dragOffset += consumed + return Offset(0f, consumed) + } + + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: androidx.compose.ui.input.nestedscroll.NestedScrollSource, + ): Offset { + if (fullScreen) return Offset.Zero + val dismissDelta = dismissGestureGate.dismissDelta( + childConsumedY = consumed.y, + availableY = available.y, + ) + if (dismissDelta <= 0f) return Offset.Zero + dragOffset += dismissDelta + return Offset(0f, dismissDelta) + } + + override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { + if (!fullScreen && dragOffset > 0f) { + settleSheetDrag(available.y) + } else { + dismissGestureGate.reset() + } + return Velocity.Zero + } + } + } + BoxWithConstraints( + Modifier + .fillMaxSize() + .lockedFocusGroup() + .clickable(onClick = onDismiss), + contentAlignment = Alignment.BottomCenter, + ) { + val targetHeightPx = constraints.maxHeight.toFloat() * if (fullScreen) 1f else 0.92f + val targetBounds = Rect( + left = 0f, + top = constraints.maxHeight.toFloat() - targetHeightPx, + right = constraints.maxWidth.toFloat(), + bottom = constraints.maxHeight.toFloat(), + ) + val surfaceShape = if (fullScreen) { + RoundedCornerShape(0.dp) + } else { + RoundedCornerShape(topStart = OpenNowRadius.xl, topEnd = OpenNowRadius.xl) + } + // Reading Animatable state inside graphicsLayer invalidates only the render layer. Reading + // it in composition used to rebuild the complete details tree on every 120 Hz frame. + Box( + Modifier + .matchParentSize() + .graphicsLayer { + alpha = if (cardTransitionOrigin == null) { + 0.72f + } else { + 0.28f + (0.44f * containerProgress.value) + } + } + .background(Color.Black), + ) + Surface( + modifier = Modifier + .then( + if (fullScreen) { + Modifier.fillMaxSize() + } else { + Modifier + .fillMaxWidth() + .fillMaxHeight(0.92f) + .offset { IntOffset(0, dragOffset.roundToInt()) } + .nestedScroll(sheetNestedScroll) + .draggable( + state = dragState, + orientation = Orientation.Vertical, + onDragStopped = { velocity -> settleSheetDrag(velocity) }, + ) + }, + ) + .graphicsLayer { + if (cardTransitionOrigin != null) { + val transform = gameDetailsContainerTransform( + source = cardTransitionOrigin, + target = targetBounds, + progress = containerProgress.value, + ) + transformOrigin = TransformOrigin(0f, 0f) + scaleX = transform.scaleX + scaleY = transform.scaleY + translationX = transform.translationX + translationY = transform.translationY + clip = true + shape = surfaceShape + } + } + .clickable(onClick = {}), + shape = surfaceShape, + color = Panel, + tonalElevation = 8.dp, + ) { + Column(Modifier.fillMaxSize()) { + if (!fullScreen) { + Box( + Modifier + .fillMaxWidth() + .padding(vertical = OpenNowSpacing.md), + contentAlignment = Alignment.Center, + ) { + Box( + Modifier + .size(width = 34.dp, height = 4.dp) + .clip(CircleShape) + .background(TextMuted.copy(alpha = 0.45f)), + ) + } + } + BoxWithConstraints( + Modifier + .fillMaxSize() + .padding(if (fullScreen) safeAreaPadding else 0.dp), + ) { + val aspect = if (maxHeight.value > 0f) maxWidth.value / maxHeight.value else 1f + val landscapeTvLayout = maxWidth >= 720.dp && aspect >= 1.35f + val phoneLandscapeLayout = landscapeTvLayout && minOf(maxWidth, maxHeight) < PHONE_NAV_RAIL_MAX_SMALLEST_WIDTH + if (landscapeTvLayout) { + GameDetailsLandscapeContent( + game = game, + favorite = favorite, + defaultVariantId = defaultVariantId, + onPlay = onPlay, + onChooseStore = onChooseStore, + onFavorite = onFavorite, + connectedTvName = connectedTvName, + onPlayOnTv = onPlayOnTv, + onDismiss = onDismiss, + gameFocusRequester = gameFocusRequester, + playFocusRequester = playFocusRequester, + shortHeight = maxHeight <= 620.dp, + imageActionsOverlay = phoneLandscapeLayout, + ) + } else { + GameDetailsScrollableContent( + game = game, + favorite = favorite, + defaultVariantId = defaultVariantId, + onPlay = onPlay, + onChooseStore = onChooseStore, + onFavorite = onFavorite, + connectedTvName = connectedTvName, + onPlayOnTv = onPlayOnTv, + onDismiss = onDismiss, + gameFocusRequester = gameFocusRequester, + playFocusRequester = playFocusRequester, + ) + } + } + } + } + } +} + +/** How far the sheet must be dragged down before letting go dismisses it. */ +private val SHEET_DISMISS_DRAG_THRESHOLD = 140.dp + +/** A fast enough flick dismisses regardless of distance travelled. */ +private const val SHEET_DISMISS_FLING_VELOCITY = 1_200f + +/** + * A scroll that began below the top may finish scrolling the details, but it cannot immediately + * turn into a sheet dismissal. The reader must lift and start a fresh pull from the top. + */ +internal class SheetDismissGestureGate { + private var childScrolledDuringGesture = false + + fun dismissDelta(childConsumedY: Float, availableY: Float): Float { + if (childConsumedY > 0f) childScrolledDuringGesture = true + return availableY.takeIf { it > 0f && !childScrolledDuringGesture } ?: 0f + } + + fun reset() { + childScrolledDuringGesture = false + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun GameDetailsLandscapeContent( + game: GameInfo, + favorite: Boolean, + defaultVariantId: String?, + onPlay: (GameInfo) -> Unit, + onChooseStore: (GameInfo) -> Unit, + onFavorite: (String) -> Unit, + connectedTvName: String?, + onPlayOnTv: (GameInfo) -> Unit, + onDismiss: () -> Unit, + gameFocusRequester: FocusRequester, + playFocusRequester: FocusRequester, + shortHeight: Boolean, + imageActionsOverlay: Boolean, +) { + val description = gameDescriptionForDetails(game) + val context = LocalContext.current + val sideScrollState = rememberScrollState() + val detailsSpacing = if (shortHeight) 8.dp else 10.dp + var gameFocused by remember(game.id) { mutableStateOf(false) } + val gameImageInteraction = remember(game.id) { MutableInteractionSource() } + val gameImageHovered by gameImageInteraction.collectIsHoveredAsState() + Row( + Modifier + .fillMaxSize() + .padding(horizontal = if (shortHeight) 18.dp else 24.dp, vertical = if (shortHeight) 16.dp else 22.dp), + horizontalArrangement = Arrangement.spacedBy(if (shortHeight) 16.dp else 22.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + val imageShape = RoundedCornerShape(20.dp) + Box( + Modifier + .weight(0.92f) + .fillMaxHeight() + .focusRequester(gameFocusRequester) + .focusProperties { right = playFocusRequester } + .onFocusChanged { gameFocused = it.isFocused } + .hoverable(gameImageInteraction) + .clickable { + onDismiss() + onPlay(game) + }, + ) { + Box( + Modifier + .fillMaxSize() + .gameDetailsArtworkEntrance(game.id) + .border( + width = if (gameFocused) 3.dp else 1.dp, + color = catalogCardBorderColor( + LocalActiveSelectionColor.current, + LocalGameCardBordersEnabled.current, + ), + shape = imageShape, + ) + .clip(imageShape), + ) { + UrlImage(gameHeroImageUrl(context, game), Modifier.fillMaxSize()) + GameImageTitleOverlay( + game = game, + compact = shortHeight, + reserveEndSpace = imageActionsOverlay, + modifier = Modifier.align(Alignment.BottomStart), + ) + if (imageActionsOverlay) { + ImageCloseButton( + onClick = onDismiss, + modifier = Modifier + .align(Alignment.TopStart) + .padding(10.dp), + ) + } + if (imageActionsOverlay) { + Column( + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(14.dp) + .width(150.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + connectedTvName?.let { + OutlinedButton( + onClick = { + onDismiss() + onPlayOnTv(game) + }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.action_play_on_tv_generic), maxLines = 1) + } + } + LongPressPlayButton( + onClick = { + onDismiss() + onPlay(game) + }, + onLongClick = { + onDismiss() + onChooseStore(game) + }, + modifier = Modifier + .fillMaxWidth(), + focusRequester = playFocusRequester, + ) + } + } + } + AbsoluteCinemaEverywhereFrame( + visible = gameFocused || gameImageHovered, + cornerRadius = 20.dp, + ) + } + + Column( + Modifier + .weight(1.08f) + .fillMaxHeight(), + verticalArrangement = Arrangement.spacedBy(detailsSpacing), + ) { + if (imageActionsOverlay) { + Column( + Modifier + .fillMaxWidth() + .verticalScroll(sideScrollState), + verticalArrangement = Arrangement.spacedBy(detailsSpacing), + ) { + GameDetailsCompactInfoContent( + game = game, + defaultVariantId = defaultVariantId, + description = description, + ) + } + } else { + Column( + Modifier + .weight(1f) + .fillMaxWidth() + .verticalScroll(sideScrollState), + verticalArrangement = Arrangement.spacedBy(detailsSpacing), + ) { + GameDetailsCompactInfoContent( + game = game, + defaultVariantId = defaultVariantId, + description = description, + ) + } + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + var dismissFocused by remember { mutableStateOf(false) } + val accent = MaterialTheme.colorScheme.primary + OutlinedButton( + onClick = onDismiss, + border = BorderStroke(1.dp, if (dismissFocused) accent else MaterialTheme.colorScheme.outline), + modifier = Modifier + .weight(1f) + .height(48.dp) + .onFocusChanged { dismissFocused = it.isFocused } + ) { + Text( + stringResource(R.string.action_dismiss), + color = if (dismissFocused) accent else TextPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + LongPressPlayButton( + onClick = { + onDismiss() + onPlay(game) + }, + onLongClick = { + onDismiss() + onChooseStore(game) + }, + modifier = Modifier.weight(1f), + focusRequester = playFocusRequester, + ) + connectedTvName?.let { + OutlinedButton( + onClick = { + onDismiss() + onPlayOnTv(game) + }, + modifier = Modifier.weight(1f).height(48.dp), + ) { + Text(stringResource(R.string.action_play_on_tv_generic), maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } + } + } + } +} + +@Composable +private fun GameDetailsCompactInfoContent( + game: GameInfo, + defaultVariantId: String?, + description: String?, +) { + OwnershipStatusRow(game = game, compact = true) + GameGenreChips(game = game, compact = true) + GameScreenshotGallery(game = game, compact = true) + GameDescriptionDisclosure( + description = description, + compact = true, + ) + CompactDetailRows(game) + LaunchOptionsList( + game = game, + defaultVariantId = defaultVariantId, + compact = true, + ) +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun GameDetailsScrollableContent( + game: GameInfo, + favorite: Boolean, + defaultVariantId: String?, + onPlay: (GameInfo) -> Unit, + onChooseStore: (GameInfo) -> Unit, + onFavorite: (String) -> Unit, + connectedTvName: String?, + onPlayOnTv: (GameInfo) -> Unit, + onDismiss: () -> Unit, + gameFocusRequester: FocusRequester, + playFocusRequester: FocusRequester, +) { + val context = LocalContext.current + var gameFocused by remember(game.id) { mutableStateOf(false) } + val gameImageInteraction = remember(game.id) { MutableInteractionSource() } + val gameImageHovered by gameImageInteraction.collectIsHoveredAsState() + Column(Modifier.fillMaxSize()) { + LazyColumn( + modifier = Modifier.weight(1f), + contentPadding = PaddingValues(bottom = 18.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + item { + val imageShape = RoundedCornerShape(OpenNowRadius.lg) + Box( + Modifier + .fillMaxWidth() + // Scales with the screen instead of being pinned at 220dp, which was + // cramped on a tablet and oversized on a small phone. + .aspectRatio(16f / 9f) + .padding(horizontal = 10.dp, vertical = 6.dp) + .focusRequester(gameFocusRequester) + .focusProperties { down = playFocusRequester } + .onFocusChanged { gameFocused = it.isFocused } + .hoverable(gameImageInteraction) + .clickable { + onDismiss() + onPlay(game) + }, + ) { + Box( + Modifier + .fillMaxSize() + .gameDetailsArtworkEntrance(game.id) + .border( + width = if (gameFocused) 3.dp else 1.dp, + color = catalogCardBorderColor( + LocalActiveSelectionColor.current, + LocalGameCardBordersEnabled.current, + ), + shape = imageShape, + ) + .clip(imageShape), + ) { + UrlImage( + gameHeroImageUrl(context, game), + Modifier.fillMaxSize(), + ) + // Guarantees the title overlay stays legible over bright key art. + Box( + Modifier + .matchParentSize() + .background( + Brush.verticalGradient( + 0.4f to Color.Transparent, + 1f to Color.Black.copy(alpha = 0.75f), + ), + ), + ) + GameImageTitleOverlay( + game = game, + compact = false, + reserveEndSpace = false, + modifier = Modifier.align(Alignment.BottomStart), + ) + } + AbsoluteCinemaEverywhereFrame( + visible = gameFocused || gameImageHovered, + cornerRadius = OpenNowRadius.lg, + ) + } + } + item { + Column(Modifier.padding(horizontal = 18.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + val description = gameDescriptionForDetails(game) + OwnershipStatusRow(game = game, compact = false) + GameGenreChips(game = game, compact = false) + GameScreenshotGallery(game = game, compact = false) + GameDescriptionDisclosure( + description = description, + compact = false, + ) + DetailRows(game) + LaunchOptionsList( + game = game, + defaultVariantId = defaultVariantId, + compact = false, + ) + } + } + } + Surface(color = Panel.copy(alpha = 0.98f), tonalElevation = 8.dp) { + // Play is the point of the screen, so it takes the width. Dismiss and the secondary + // actions become fixed-size icons rather than equal-weight buttons that squeezed Play + // down to a third of the bar whenever a TV was connected. + Row( + Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + var dismissFocused by remember { mutableStateOf(false) } + val accent = MaterialTheme.colorScheme.primary + IconButton( + onClick = onDismiss, + modifier = Modifier + .size(48.dp) + .onFocusChanged { dismissFocused = it.isFocused }, + ) { + Icon( + painter = painterResource(R.drawable.ic_clear), + contentDescription = stringResource(R.string.action_dismiss), + tint = if (dismissFocused) accent else TextMuted, + ) + } + // favorite/onFavorite were already threaded into this composable but never used — + // on phones the only way to favourite a game was from the grid. + FavoriteIconButton( + favorite = favorite, + onClick = { onFavorite(game.id) }, + size = 48.dp, + ) + LongPressPlayButton( + onClick = { + onDismiss() + onPlay(game) + }, + onLongClick = { + onDismiss() + onChooseStore(game) + }, + modifier = Modifier.weight(1f), + focusRequester = playFocusRequester, + ) + connectedTvName?.let { tvName -> + IconButton( + onClick = { + onDismiss() + onPlayOnTv(game) + }, + modifier = Modifier.size(48.dp), + ) { + Icon( + imageVector = Icons.Outlined.Cast, + contentDescription = stringResource(R.string.action_play_on_tv, tvName), + tint = TextPrimary, + ) + } + } + } + } + } +} + +@Composable +private fun LaunchOptionsList( + game: GameInfo, + defaultVariantId: String?, + compact: Boolean, +) { + val variants = launchableGameVariants(game.variants) + if (variants.isEmpty()) return + Column(verticalArrangement = Arrangement.spacedBy(if (compact) 6.dp else 8.dp)) { + Text( + stringResource(R.string.store_selector_launchers), + color = TextMuted, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + ) + variants.take(if (compact) 3 else variants.size).forEach { variant -> + val isDefault = variant.id == defaultVariantId + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(if (compact) 12.dp else 14.dp), + color = if (isDefault) MaterialTheme.colorScheme.primary.copy(alpha = 0.18f) else PanelAlt, + contentColor = TextPrimary, + ) { + Row( + Modifier.padding(horizontal = if (compact) 10.dp else 12.dp, vertical = if (compact) 8.dp else 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + ConnectorStoreIcon( + launcherBadgeForStoreKey(splitGameStoreKeys(variant.store).firstOrNull()), + ) + Column(Modifier.weight(1f)) { + Text(gameStoreDisplayName(variant.store), fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) + val details = variantDetailsText(variant) + Text( + if (isDefault) { + listOf(stringResource(R.string.store_selector_default), details).filter { it.isNotBlank() }.joinToString(" - ") + } else { + details.ifBlank { stringResource(R.string.store_selector_available_launcher) } + }, + color = TextMuted, + style = MaterialTheme.typography.bodySmall, + maxLines = if (compact) 1 else 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun LongPressPlayButton( + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier = Modifier, + focusRequester: FocusRequester? = null, +) { + val controllerFocusEnabled = LocalControllerFocusEnabled.current + var focused by remember { mutableStateOf(false) } + val interaction = remember { MutableInteractionSource() } + val hovered by interaction.collectIsHoveredAsState() + val pressed by interaction.collectIsPressedAsState() + val controllerFocused = focused && controllerFocusEnabled + val shape = RoundedCornerShape(999.dp) + val accent = MaterialTheme.colorScheme.primary + val focusScale = animateFloatAsState( + targetValue = if (pressed) 0.95f else gameDetailsPlayFocusScale(controllerFocused), + animationSpec = tween( + durationMillis = OpenNowMotion.DurationFast, + easing = OpenNowMotion.EasingStandard, + ), + label = "game-details-play-focus-scale", + ) + val containerColor by animateColorAsState( + targetValue = if (controllerFocused) Color.White else accent, + animationSpec = tween(durationMillis = 120), + label = "game-details-play-focus-color", + ) + Box( + modifier = modifier.graphicsLayer { + scaleX = focusScale.value + scaleY = focusScale.value + }, + ) { + Surface( + modifier = Modifier + .fillMaxWidth() + .height(48.dp) + .then( + focusRequester?.let { requester -> Modifier.focusRequester(requester) } + ?: Modifier, + ) + .onFocusChanged { focusState -> focused = focusState.isFocused } + .hoverable(interaction) + .onPreviewKeyEvent { event -> + if (isTvActivateKey(event)) { + onClick() + true + } else { + false + } + } + .focusable() + .combinedClickable( + interactionSource = interaction, + indication = null, + onClick = onClick, + onLongClick = onLongClick, + onLongClickLabel = stringResource(R.string.store_selector_play_long_press), + ) + .then( + if (controllerFocused) { + Modifier.border( + width = gameDetailsPlayFocusBorderWidthDp(controllerFocused).dp, + color = accent, + shape = shape, + ) + } else { + Modifier + }, + ), + shape = shape, + color = containerColor, + tonalElevation = 0.dp, + shadowElevation = if (controllerFocused) 12.dp else 0.dp, + ) { + Row( + Modifier.fillMaxSize().padding(horizontal = 18.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + ZortosPlayMark( + modifier = Modifier.size(20.dp), + ringColor = Color.Black, + ) + Spacer(Modifier.width(8.dp)) + Text( + stringResource(R.string.action_play), + color = Color.Black, + fontWeight = if (controllerFocused) FontWeight.ExtraBold else FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + ControllerFocusFrame( + visible = + (controllerFocused && LocalAbsoluteCinemaEffects.current) || + (hovered && LocalAbsoluteCinemaEverywhere.current), + cornerRadius = 24.dp, + tint = LocalActiveSelectionColor.current, + secondaryTint = LocalActiveSelectionSecondaryColor.current, + ) + } +} + +internal fun gameDetailsPlayFocusScale(focused: Boolean): Float = if (focused) 1.06f else 1f + +internal fun gameDetailsPlayFocusBorderWidthDp(focused: Boolean): Float = if (focused) 4f else 0f + +private fun variantDetailsText(variant: GameVariant): String = + listOfNotNull( + variant.libraryStatus?.takeIf { it.isNotBlank() }?.let(::formatGameMetadataLabel), + variant.supportedControls.takeIf { it.isNotEmpty() }?.joinToString(", ") { formatGameMetadataLabel(it) }, + variant.lastPlayedDate?.takeIf { it.isNotBlank() }?.let { "Last played $it" }, + ).joinToString(" - ") + +@Composable +private fun ImageCloseButton(onClick: () -> Unit, modifier: Modifier = Modifier) { + var focused by remember { mutableStateOf(false) } + val accent = MaterialTheme.colorScheme.primary + Surface( + modifier = modifier + .size(44.dp) + .onFocusChanged { focused = it.isFocused } + .border( + width = 2.dp, + color = if (focused) accent else Color.Transparent, + shape = CircleShape + ) + .clickable(onClick = onClick), + shape = CircleShape, + color = Color.Black.copy(alpha = 0.58f), + tonalElevation = 3.dp, + ) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Icon( + painter = painterResource(R.drawable.ic_clear), + contentDescription = stringResource(R.string.action_cancel), + tint = TextPrimary, + modifier = Modifier.size(20.dp), + ) + } + } +} + +@Composable +private fun FavoriteIconButton(favorite: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier, size: Dp = 44.dp) { + val label = stringResource(if (favorite) R.string.action_saved else R.string.action_save) + var focused by remember { mutableStateOf(false) } + val accent = MaterialTheme.colorScheme.primary + Surface( + modifier = modifier + .minimumInteractiveComponentSize() + .size(size) + .onFocusChanged { focused = it.isFocused } + .semantics { + contentDescription = label + role = Role.Button + } + .clickable(onClick = onClick) + .focusable() + .then( + if (focused) Modifier.border(2.dp, accent, CircleShape) else Modifier + ), + shape = CircleShape, + color = Color.Black.copy(alpha = 0.35f), + tonalElevation = 0.dp, + border = if (LocalAbsoluteCinemaEffects.current) BorderStroke(1.dp, accent) else null, + ) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Icon( + painter = painterResource(if (favorite) R.drawable.ic_save_filled else R.drawable.ic_save), + contentDescription = null, + tint = if (favorite) MaterialTheme.colorScheme.primary else TextPrimary, + modifier = Modifier.size(size * 0.5f), + ) + } + } +} + +internal fun gameDescriptionForDetails(game: GameInfo): String? = + game.description?.takeIf { it.isNotBlank() } + ?: game.longDescription?.takeIf { it.isNotBlank() } + +private fun gameHeroImageUrl(context: Context, game: GameInfo?): String? { + val url = game?.screenshotUrl?.takeIf { it.isNotBlank() } + ?: game?.tvBannerUrl?.takeIf { it.isNotBlank() } + ?: game?.screenshotUrls?.firstOrNull { it.isNotBlank() } + ?: game?.imageUrl?.takeIf { it.isNotBlank() } + ?: return null + return optimizedNvidiaImageUrl(url, wideImageRequestWidth(context)) +} + +internal fun gameTvBannerImageUrl(context: Context, game: GameInfo?): String? { + val url = game?.tvBannerUrl?.takeIf { it.isNotBlank() } + ?: game?.screenshotUrl?.takeIf { it.isNotBlank() } + ?: game?.imageUrl?.takeIf { it.isNotBlank() } + ?: return null + return optimizedNvidiaImageUrl(url, wideImageRequestWidth(context)) +} + +private fun optimizedNvidiaImageUrl(url: String, width: Int): String { + if (!url.contains("img.nvidiagrid.net")) return url + val base = url + .substringBefore(";f=") + .substringBefore(";w=") + .substringBefore(";h=") + .substringBefore(";dpr=") + return "$base;f=webp;w=$width" +} + +/** + * Cached because the measurement below is a binder round trip to the system server, and the URL + * builders that need it are called from composable bodies — once per artwork, per recomposition. + * A carousel that re-runs on a timer turned that into a steady drip of IPC for a number that only + * changes when the network does. + */ +private object ImageRequestWidthCache { + private const val TTL_MS = 30_000L + + @Volatile + private var cachedWidth = 0 + + @Volatile + private var cachedAtMs = 0L + + @Volatile + private var cachedDisplayWidth = 0 + + fun width(context: Context): Int { + val now = SystemClock.elapsedRealtime() + val displayWidth = context.resources.displayMetrics.widthPixels + val cached = cachedWidth + if (cached != 0 && displayWidth == cachedDisplayWidth && now - cachedAtMs < TTL_MS) return cached + val measured = measureWideImageRequestWidth(context, displayWidth) + cachedWidth = measured + cachedDisplayWidth = displayWidth + cachedAtMs = now + return measured + } +} + +private fun wideImageRequestWidth(context: Context): Int = ImageRequestWidthCache.width(context) + +private fun measureWideImageRequestWidth(context: Context, displayWidth: Int): Int { + val connectivity = context.applicationContext.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager + val capabilities = connectivity?.getNetworkCapabilities(connectivity.activeNetwork) + val downstreamKbps = capabilities?.linkDownstreamBandwidthKbps ?: 0 + val networkWidth = when { + downstreamKbps >= 25_000 -> 1920 + downstreamKbps in 10_000 until 25_000 -> 1600 + downstreamKbps in 3_000 until 10_000 -> 1280 + downstreamKbps in 1 until 3_000 -> 960 + capabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED) == true -> 1600 + capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) == true -> 960 + else -> 1280 + } + return boundedWideImageRequestWidth(networkWidth, displayWidth) +} + +/** + * Keeps detail and hero decodes near the physical display size while retaining a little headroom + * for crop and scale. Fixed buckets also preserve CDN and disk-cache reuse across nearby devices. + */ +internal fun boundedWideImageRequestWidth(networkWidth: Int, displayWidth: Int): Int { + if (displayWidth <= 0) return networkWidth + val displayTarget = when { + displayWidth <= 720 -> 960 + displayWidth <= 1080 -> 1280 + displayWidth <= 1440 -> 1600 + else -> 1920 + } + return minOf(networkWidth, displayTarget) +} + +@Composable +private fun GameImageTitleOverlay( + game: GameInfo, + compact: Boolean, + reserveEndSpace: Boolean, + modifier: Modifier = Modifier, +) { + val textShadow = Shadow( + color = Color.Black, + offset = Offset(0f, 3f), + blurRadius = 14f, + ) + Column( + modifier + .fillMaxWidth() + .padding( + start = if (compact) 12.dp else 16.dp, + top = if (compact) 9.dp else 12.dp, + end = if (reserveEndSpace) 154.dp else if (compact) 12.dp else 16.dp, + bottom = if (compact) 10.dp else 14.dp, + ), + verticalArrangement = Arrangement.spacedBy(3.dp), + ) { + Text( + game.title, + color = TextPrimary, + style = (if (compact) MaterialTheme.typography.titleLarge else MaterialTheme.typography.headlineSmall).copy( + shadow = textShadow, + ), + fontWeight = FontWeight.Bold, + maxLines = if (compact) 2 else 2, + overflow = TextOverflow.Ellipsis, + ) + Text( + game.publisherName?.takeIf { it.isNotBlank() } ?: stringResource(R.string.catalog_unknown_publisher), + color = TextPrimary.copy(alpha = 0.88f), + style = MaterialTheme.typography.bodyMedium.copy(shadow = textShadow), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun GameTitleBlock(game: GameInfo, compact: Boolean) { + Column(verticalArrangement = Arrangement.spacedBy(if (compact) 3.dp else 5.dp)) { + Text( + game.title, + color = TextPrimary, + style = if (compact) MaterialTheme.typography.titleLarge else MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + maxLines = if (compact) 2 else 3, + overflow = TextOverflow.Ellipsis, + ) + Text( + game.publisherName?.takeIf { it.isNotBlank() } ?: stringResource(R.string.catalog_unknown_publisher), + color = TextMuted, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun OwnershipStatusRow(game: GameInfo, compact: Boolean) { + val ownedStores = ownedStoreLabels(game) + val shape = RoundedCornerShape(if (compact) 12.dp else 14.dp) + if (ownedStores.isEmpty()) { + val availableStores = availableStoreLabels(game) + Surface( + modifier = Modifier.fillMaxWidth(), + shape = shape, + color = Color(0xff4a1216), + tonalElevation = 0.dp, + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = if (compact) 8.dp else 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + stringResource(R.string.catalog_not_owned), + color = OpenNowPalette.OnErrorContainer, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + availableStores.forEach { store -> + ConnectorStoreIcon(launcherBadgeForStoreKey(normalizeGameStore(store))) + } + } + } + return + } + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + ownedStores.forEach { store -> + val badge = launcherBadgeForStoreKey(normalizeGameStore(store)) + Surface( + shape = shape, + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.16f), + tonalElevation = 0.dp, + ) { + Row( + Modifier.padding(horizontal = 10.dp, vertical = if (compact) 6.dp else 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + ConnectorStoreIcon(badge) + Text( + "Owned on $store", + color = TextPrimary, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } +} + +private fun ownedStoreLabels(game: GameInfo): List = + libraryStoreDisplayNames(game).ifEmpty { + if (isGameInLibrary(game)) listOf("GeForce NOW") else emptyList() + } + +private fun availableStoreLabels(game: GameInfo): List = + displayStoresForVariants(game.variants).ifEmpty { + game.availableStores.map(::gameStoreDisplayName) + } + .map(String::trim) + .filter { it.isNotBlank() && !it.equals("none", ignoreCase = true) } + .distinctBy(::normalizeGameStore) + +@Composable +private fun GameGenreChips(game: GameInfo, compact: Boolean) { + val genres = game.genres + .map { it.trim() } + .filter { it.isNotBlank() } + .map(::formatGameMetadataLabel) + .filterNot(::isNoisyGameTag) + .distinctBy { it.lowercase(Locale.US) } + .take(if (compact) 12 else 20) + if (genres.isEmpty()) return + LazyRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(if (compact) 6.dp else 7.dp), + contentPadding = PaddingValues(end = if (compact) 6.dp else 8.dp), + ) { + items(genres, key = { it }) { label -> + AssistChip(onClick = {}, label = { Text(label, maxLines = 1, overflow = TextOverflow.Ellipsis) }) + } + } +} + +@Composable +private fun GameScreenshotGallery(game: GameInfo, compact: Boolean) { + val screenshots = game.screenshotUrls + .map(String::trim) + .filter(String::isNotBlank) + .distinct() + if (screenshots.isEmpty()) return + val context = LocalContext.current + val requestWidth = remember(context) { wideImageRequestWidth(context).coerceAtLeast(960) } + var fullscreenIndex by remember(screenshots) { mutableStateOf(null) } + Column( + Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(if (compact) 7.dp else 9.dp), + ) { + Text( + stringResource(R.string.catalog_screenshots), + color = TextPrimary, + style = if (compact) MaterialTheme.typography.labelLarge else MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + LazyRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(if (compact) 8.dp else 10.dp), + contentPadding = PaddingValues(end = 8.dp), + ) { + itemsIndexed(screenshots, key = { _, screenshot -> screenshot }) { index, screenshot -> + val hoverInteraction = remember(screenshot) { MutableInteractionSource() } + val hovered by hoverInteraction.collectIsHoveredAsState() + Box( + modifier = Modifier + .width(if (compact) 224.dp else 288.dp) + .aspectRatio(16f / 9f), + ) { + Surface( + modifier = Modifier + .matchParentSize() + .hoverable(hoverInteraction) + .clickable { fullscreenIndex = index }, + shape = RoundedCornerShape(if (compact) 12.dp else 14.dp), + color = Color.Black, + border = if (LocalAbsoluteCinemaEffects.current) { + BorderStroke(1.dp, LocalActiveSelectionColor.current) + } else { + null + }, + ) { + UrlImage( + url = optimizedNvidiaImageUrl(screenshot, requestWidth), + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Fit, + ) + } + AbsoluteCinemaEverywhereFrame( + visible = hovered, + cornerRadius = if (compact) 12.dp else 14.dp, + ) + } + } + } + } + fullscreenIndex?.let { initialIndex -> + FullscreenScreenshotViewer( + screenshots = screenshots, + initialIndex = initialIndex, + requestWidth = requestWidth.coerceAtLeast(1920), + onDismiss = { fullscreenIndex = null }, + ) + } +} + +@Composable +private fun FullscreenScreenshotViewer( + screenshots: List, + initialIndex: Int, + requestWidth: Int, + onDismiss: () -> Unit, +) { + val context = LocalContext.current + var index by remember(screenshots, initialIndex) { mutableIntStateOf(initialIndex.coerceIn(screenshots.indices)) } + var horizontalDrag by remember { mutableFloatStateOf(0f) } + val swipeThreshold = with(LocalDensity.current) { 56.dp.toPx() } + val dragState = rememberDraggableState { delta -> horizontalDrag += delta } + LaunchedEffect(screenshots, index, requestWidth) { + val imageLoader = SingletonImageLoader.get(context) + listOf(index - 1, index + 1) + .filter { it in screenshots.indices } + .forEach { adjacentIndex -> + imageLoader.execute( + ImageRequest.Builder(context) + .data(optimizedNvidiaImageUrl(screenshots[adjacentIndex], requestWidth)) + .build(), + ) + } + } + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties( + dismissOnBackPress = true, + dismissOnClickOutside = false, + usePlatformDefaultWidth = false, + ), + ) { + Box( + Modifier + .fillMaxSize() + .background(Color.Black) + .draggable( + state = dragState, + orientation = Orientation.Horizontal, + enabled = screenshots.size > 1, + onDragStarted = { horizontalDrag = 0f }, + onDragStopped = { + if (abs(horizontalDrag) >= swipeThreshold) { + index = if (horizontalDrag < 0f) { + (index + 1).coerceAtMost(screenshots.lastIndex) + } else { + (index - 1).coerceAtLeast(0) + } + } + horizontalDrag = 0f + }, + ) + .onPreviewKeyEvent { event -> + if (event.type != KeyEventType.KeyUp) return@onPreviewKeyEvent false + when (event.key) { + Key.DirectionLeft -> { + index = (index - 1).coerceAtLeast(0) + true + } + Key.DirectionRight -> { + index = (index + 1).coerceAtMost(screenshots.lastIndex) + true + } + else -> false + } + }, + contentAlignment = Alignment.Center, + ) { + UrlImage( + url = optimizedNvidiaImageUrl(screenshots[index], requestWidth), + modifier = Modifier + .fillMaxSize() + .padding(12.dp), + contentScale = ContentScale.Fit, + ) + if (screenshots.size > 1) { + Surface( + modifier = Modifier + .align(Alignment.TopCenter) + .padding(top = 18.dp), + shape = CircleShape, + color = Color.Black.copy(alpha = 0.64f), + ) { + Text( + "${index + 1} / ${screenshots.size}", + color = Color.White, + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 7.dp), + ) + } + } + ImageCloseButton( + onClick = onDismiss, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(18.dp), + ) + } + } +} + +@Composable +private fun GameDescriptionDisclosure( + description: String?, + compact: Boolean, +) { + var expanded by remember(description) { mutableStateOf(true) } + val text = description?.takeIf { it.isNotBlank() } ?: stringResource(R.string.catalog_no_description) + var focused by remember { mutableStateOf(false) } + val hoverInteraction = remember { MutableInteractionSource() } + val hovered by hoverInteraction.collectIsHoveredAsState() + val shape = RoundedCornerShape(if (compact) 12.dp else 14.dp) + Box(Modifier.fillMaxWidth()) { + Surface( + modifier = Modifier + .fillMaxWidth() + .onFocusChanged { focused = it.isFocused } + .hoverable(hoverInteraction) + .clickable { expanded = !expanded }, + shape = shape, + color = if (focused) PanelAlt.copy(alpha = 0.85f) else PanelAlt, + border = if (focused && LocalAbsoluteCinemaEffects.current) { + BorderStroke(1.dp, LocalActiveSelectionColor.current) + } else { + null + }, + tonalElevation = 0.dp, + ) { + Column(Modifier.padding(horizontal = 12.dp, vertical = if (compact) 8.dp else 10.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + stringResource(R.string.catalog_description), + color = TextPrimary, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f), + ) + IconButton(onClick = { expanded = !expanded }, modifier = Modifier.size(36.dp)) { + Icon( + painter = painterResource(R.drawable.ic_chevron_right), + contentDescription = if (expanded) { + stringResource(R.string.control_hide_description) + } else { + stringResource(R.string.control_show_description) + }, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier + .size(20.dp) + .graphicsLayer(rotationZ = if (expanded) 90f else 0f), + ) + } + } + if (expanded) { + Text( + text, + color = if (description == null) TextMuted else TextPrimary, + style = MaterialTheme.typography.bodyMedium, + maxLines = if (compact) 8 else Int.MAX_VALUE, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + AbsoluteCinemaEverywhereFrame( + visible = focused || hovered, + cornerRadius = if (compact) 12.dp else 14.dp, + ) + } +} + +private fun formatGameMetadataLabel(raw: String): String { + val compact = raw.trim() + .removePrefix("GFN_") + .removePrefix("GAME_") + .replace(METADATA_SEPARATOR_RUN, " ") + .replace(SEARCH_WHITESPACE_RUN, " ") + .trim() + if (compact.isBlank()) return "" + val lower = compact.lowercase(Locale.US) + return when (lower) { + "full game" -> "Full game" + "single player" -> "Single-player" + "multi player", "multiplayer" -> "Multiplayer" + "controller", "gamepad" -> "Controller" + "keyboard mouse", "mouse keyboard" -> "Mouse and keyboard" + else -> compact.split(" ").joinToString(" ") { word -> + if (word.length <= 3 && word.all { it.isUpperCase() || it.isDigit() }) { + word + } else { + word.lowercase(Locale.US).replaceFirstChar { char -> char.titlecase(Locale.US) } + } + } + } +} + +private fun isNoisyGameTag(label: String): Boolean { + val normalized = label.trim().lowercase(Locale.US) + return normalized.isBlank() || + normalized == "unknown" || + normalized == "gfn" || + normalized == "nvidia" || + normalized.contains("sku based tag") || + normalized.contains("catalog") +} + +@Composable +private fun CompactDetailRows(game: GameInfo) { + val allRows = gameDetailRows(game) + val rows = (allRows.take(4) + allRows.drop(4).filter { it.actionUrl != null }).distinct() + if (rows.isEmpty()) return + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + rows.forEach { row -> + DetailRow(row = row, compact = true) + } + } +} + +@Composable +private fun DetailRows(game: GameInfo) { + val rows = gameDetailRows(game) + if (rows.isEmpty()) return + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + rows.forEach { row -> + DetailRow(row = row, compact = false) + } + } +} + +private data class GameDetailRow( + val label: String, + val value: String, + val copyValue: String = value, + val actionUrl: String? = null, +) + +internal data class GameStoreDetail( + val label: String, + val url: String?, +) + +internal fun validExternalStoreUrl(rawUrl: String?): String? { + val value = rawUrl?.trim()?.takeIf { it.isNotBlank() } ?: return null + val uri = runCatching { java.net.URI(value) }.getOrNull() ?: return null + return value.takeIf { + uri.scheme.equals("https", ignoreCase = true) && !uri.host.isNullOrBlank() + } +} + +internal fun gameStoreDetails(game: GameInfo): List { + val variantDetails = launchableGameVariants(game.variants) + .map { variant -> + GameStoreDetail( + label = gameStoreDisplayName(variant.store), + url = validExternalStoreUrl(variant.storeUrl), + ) + } + .filter { it.label.isNotBlank() } + .distinctBy { normalizeGameStore(it.label) } + if (variantDetails.none { it.url != null }) { + return game.availableStores + .map(::gameStoreDisplayName) + .distinctBy(::normalizeGameStore) + .takeIf { it.isNotEmpty() } + ?.let { stores -> listOf(GameStoreDetail(stores.joinToString(", "), null)) } + .orEmpty() + } + + val variantStoreKeys = variantDetails.mapTo(mutableSetOf()) { normalizeGameStore(it.label) } + val fallbackDetails = game.availableStores + .map(::gameStoreDisplayName) + .filter { normalizeGameStore(it) !in variantStoreKeys } + .distinctBy(::normalizeGameStore) + .map { GameStoreDetail(it, null) } + return variantDetails + fallbackDetails +} + +private fun gameDetailRows(game: GameInfo): List = buildList { + addAll( + listOfNotNull( + game.playabilityState?.takeIf { it.isNotBlank() }?.let { GameDetailRow("Status", formatGameMetadataLabel(it)) }, + game.publisherName?.takeIf { it.isNotBlank() }?.let { GameDetailRow("Publisher", it) }, + game.playType?.takeIf { it.isNotBlank() }?.let { GameDetailRow("Play type", formatGameMetadataLabel(it)) }, + supportedControlLabels(game).takeIf { it.isNotEmpty() }?.joinToString(", ")?.let { GameDetailRow("Controls", it) }, + game.featureLabels + .map(::formatGameMetadataLabel) + .filterNot(::isNoisyGameTag) + .filterNot { feature -> game.genres.any { genre -> feature.equals(formatGameMetadataLabel(genre), ignoreCase = true) } } + .distinctBy { it.lowercase(Locale.US) } + .take(8) + .takeIf { it.isNotEmpty() } + ?.joinToString(", ") + ?.let { GameDetailRow("Features", it) }, + game.membershipTierLabel?.takeIf { it.isNotBlank() }?.let { GameDetailRow("Membership", formatGameMetadataLabel(it)) }, + game.contentRatings.takeIf { it.isNotEmpty() }?.joinToString(", ")?.let { GameDetailRow("Rating", it) }, + game.lastPlayed?.takeIf { it.isNotBlank() }?.let { GameDetailRow("Last played", it) }, + ), + ) + val stores = gameStoreDetails(game) + addAll(stores.map { store -> + GameDetailRow( + label = if (store.url == null && stores.size == 1) "Stores" else "Store", + value = store.label, + actionUrl = store.url, + ) + }) + gameAppIdForDetails(game)?.let { add(GameDetailRow("App ID", it)) } +} + +internal fun supportedControlLabels(game: GameInfo): List = + game.variants + .flatMap { it.supportedControls } + .map(::formatGameMetadataLabel) + .filter(String::isNotBlank) + .distinctBy { it.lowercase(Locale.US) } + +private fun gameAppIdForDetails(game: GameInfo): String? = + game.launchAppId?.takeIf { it.isNotBlank() } + ?: game.variants.firstNotNullOfOrNull { variant -> variant.id.takeIf { it.isNotBlank() && it.all(Char::isDigit) } } + ?: game.uuid?.takeIf { it.isNotBlank() } + ?: game.id.takeIf { it.isNotBlank() } + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun DetailRow(row: GameDetailRow, compact: Boolean) { + val clipboard = LocalClipboardManager.current + val context = LocalContext.current + val shape = RoundedCornerShape(if (compact) 10.dp else 12.dp) + Row( + Modifier + .fillMaxWidth() + .clip(shape) + .background(PanelAlt) + .combinedClickable( + role = if (row.actionUrl != null) Role.Button else null, + onClick = { + val url = row.actionUrl ?: return@combinedClickable + runCatching { + context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) + }.onFailure { + Toast.makeText(context, context.getString(R.string.error_open_store_page), Toast.LENGTH_SHORT).show() + } + }, + onLongClick = { + clipboard.setText(AnnotatedString(row.copyValue)) + Toast.makeText(context, "${row.label} copied", Toast.LENGTH_SHORT).show() + }, + ) + .padding(horizontal = if (compact) 10.dp else 12.dp, vertical = if (compact) 7.dp else 10.dp), + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(if (compact) 10.dp else 12.dp), + ) { + Text( + row.label, + color = TextMuted, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.width(if (compact) 82.dp else 92.dp), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + row.value, + color = TextPrimary, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.weight(1f), + maxLines = if (compact) 1 else 2, + overflow = TextOverflow.Ellipsis, + ) + if (row.actionUrl != null) { + Icon( + imageVector = Icons.AutoMirrored.Outlined.OpenInNew, + contentDescription = "Open ${row.value} store page", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(if (compact) 17.dp else 19.dp), + ) + } + } +} + +private val SEARCH_WHITESPACE_RUN = Regex("\\s+") +private val METADATA_SEPARATOR_RUN = Regex("[_-]+") + +/** + * Splits a raw search box value into the terms [gameMatchesSearch] tests against. + * + * Filtering runs this once per query rather than once per game: the old shape compiled a fresh + * `Regex` and re-split the query inside the per-game predicate, so a keystroke over a large + * library paid for both several thousand times. + */ +internal fun searchTermsFor(query: String): List { + val normalized = query.trim().lowercase() + if (normalized.isEmpty()) return emptyList() + return normalized.split(SEARCH_WHITESPACE_RUN) +} + +internal fun gameMatchesSearch(game: GameInfo, terms: List): Boolean { + if (terms.isEmpty()) return true + val haystack = buildString { + append(game.title).append(' ') + append(game.description.orEmpty()).append(' ') + append(game.longDescription.orEmpty()).append(' ') + append(game.publisherName.orEmpty()).append(' ') + append(game.genres.joinToString(" ")).append(' ') + append(game.featureLabels.joinToString(" ")).append(' ') + append(displayStoresForGame(game)) + }.lowercase() + return terms.all { it in haystack } +} + +internal fun gameMatchesSearch(game: GameInfo, query: String): Boolean = + gameMatchesSearch(game, searchTermsFor(query)) + +internal fun favoriteOrderedGames(games: List, favoriteIds: List): List { + val favorites = games.filter { it.id in favoriteIds } + return if (favorites.isNotEmpty()) favorites + games.filterNot { it.id in favoriteIds } else games +} + +@Composable +@OptIn(ExperimentalLayoutApi::class) +internal fun StoreLaunchSelector( + game: GameInfo, + defaultVariantId: String?, + onLaunch: (GameInfo, GameVariant) -> Unit, + onSetDefaultStore: (String, String?) -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + val variants = remember(game) { launchableGameVariants(game.variants) } + val context = LocalContext.current + val initialVariantId = remember(game.id, defaultVariantId, variants) { + defaultVariantId?.takeIf { savedId -> variants.any { it.id == savedId } } + ?: variants.firstOrNull()?.id + } + var selectedVariantId by remember(game.id, initialVariantId) { mutableStateOf(initialVariantId) } + var rememberDefaultStore by remember(game.id, defaultVariantId) { mutableStateOf(defaultVariantId != null) } + val selectedVariant = variants.firstOrNull { it.id == selectedVariantId } + val continueFocusRequester = remember(game.id) { FocusRequester() } + BackHandler(onBack = onDismiss) + LaunchedEffect(game.id, variants.size) { + if (variants.isNotEmpty()) { + requestFocusWithRetry(continueFocusRequester) + } + } + BoxWithConstraints( + Modifier + .fillMaxSize() + .lockedFocusGroup() + .background(Color.Black.copy(alpha = 0.72f)) + .clickable(enabled = false) {}, + ) { + val phoneLandscape = isPhoneLandscape(maxWidth, maxHeight) + val landscape = maxWidth > maxHeight + Box( + Modifier.fillMaxSize(), + contentAlignment = if (phoneLandscape) Alignment.CenterEnd else Alignment.Center, + ) { + Card( + modifier = modifier + .then( + if (phoneLandscape) { + Modifier + .padding(end = 12.dp) + .fillMaxWidth(0.9f) + .fillMaxHeight(0.9f) + } else { + Modifier + .fillMaxWidth(if (landscape) 0.78f else 0.92f) + .fillMaxHeight(if (landscape) 0.86f else 0.64f) + }, + ), + colors = CardDefaults.cardColors(containerColor = Panel, contentColor = TextPrimary), + shape = RoundedCornerShape(22.dp), + ) { + if (phoneLandscape) { + Row( + Modifier.fillMaxSize().padding(14.dp), + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + LaunchGameSummary( + game = game, + subtitle = stringResource(R.string.store_selector_choose_launcher), + modifier = Modifier + .width(190.dp) + .fillMaxHeight(), + ) + StoreLaunchOptionsColumn( + variants = variants, + selectedVariantId = selectedVariantId, + defaultVariantId = defaultVariantId, + rememberDefaultStore = rememberDefaultStore, + selectedVariant = selectedVariant, + continueFocusRequester = continueFocusRequester, + onSelectVariant = { selectedVariantId = it }, + onRememberDefaultStoreChange = { rememberDefaultStore = it }, + onDismiss = onDismiss, + onContinue = { variant -> + if (rememberDefaultStore || defaultVariantId != null) { + onSetDefaultStore(game.id, if (rememberDefaultStore) variant.id else null) + } + if (rememberDefaultStore) { + Toast.makeText(context, context.getString(R.string.store_selector_long_press_tip), Toast.LENGTH_LONG).show() + } + onLaunch(game, variant) + }, + modifier = Modifier + .weight(1f) + .fillMaxHeight(), + ) + } + } else { + Column(Modifier.fillMaxSize().padding(18.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + UrlImage( + game.imageUrl, + Modifier + .width(58.dp) + .height(76.dp) + .clip(RoundedCornerShape(12.dp)), + ) + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f)) { + Text(game.title, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(stringResource(R.string.store_selector_choose_launcher), color = TextMuted, style = MaterialTheme.typography.bodySmall) + } + } + StoreLaunchOptionsColumn( + variants = variants, + selectedVariantId = selectedVariantId, + defaultVariantId = defaultVariantId, + rememberDefaultStore = rememberDefaultStore, + selectedVariant = selectedVariant, + continueFocusRequester = continueFocusRequester, + onSelectVariant = { selectedVariantId = it }, + onRememberDefaultStoreChange = { rememberDefaultStore = it }, + onDismiss = onDismiss, + onContinue = { variant -> + if (rememberDefaultStore || defaultVariantId != null) { + onSetDefaultStore(game.id, if (rememberDefaultStore) variant.id else null) + } + if (rememberDefaultStore) { + Toast.makeText(context, context.getString(R.string.store_selector_long_press_tip), Toast.LENGTH_LONG).show() + } + onLaunch(game, variant) + }, + modifier = Modifier.weight(1f), + ) + } + } + } + } + } +} + +@Composable +private fun LaunchGameSummary(game: GameInfo, subtitle: String, modifier: Modifier = Modifier) { + Column(modifier, verticalArrangement = Arrangement.spacedBy(10.dp)) { + UrlImage( + game.imageUrl, + Modifier + .fillMaxWidth() + .weight(1f) + .clip(RoundedCornerShape(16.dp)), + ) + Column(verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text(game.title, fontWeight = FontWeight.Bold, maxLines = 2, overflow = TextOverflow.Ellipsis) + Text(subtitle, color = TextMuted, style = MaterialTheme.typography.bodySmall, maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } +} + +@Composable +private fun StoreLaunchOptionsColumn( + variants: List, + selectedVariantId: String?, + defaultVariantId: String?, + rememberDefaultStore: Boolean, + selectedVariant: GameVariant?, + continueFocusRequester: FocusRequester, + onSelectVariant: (String) -> Unit, + onRememberDefaultStoreChange: (Boolean) -> Unit, + onDismiss: () -> Unit, + onContinue: (GameVariant) -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier, verticalArrangement = Arrangement.spacedBy(10.dp)) { + LazyColumn( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(variants, key = { it.id }) { variant -> + StoreLaunchVariantRow( + variant = variant, + selected = variant.id == selectedVariantId, + savedDefault = variant.id == defaultVariantId, + onClick = { onSelectVariant(variant.id) }, + ) + } + } + var checkFocused by remember { mutableStateOf(false) } + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .onFocusChanged { checkFocused = it.isFocused } + .background(if (checkFocused) Color.White.copy(alpha = 0.08f) else Color.Transparent) + .border( + width = 1.dp, + color = if (checkFocused) MaterialTheme.colorScheme.primary else Color.Transparent, + shape = RoundedCornerShape(14.dp) + ) + .clickable { onRememberDefaultStoreChange(!rememberDefaultStore) } + .padding(vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Checkbox( + checked = rememberDefaultStore, + onCheckedChange = onRememberDefaultStoreChange, + ) + Text( + stringResource(R.string.store_selector_default_checkbox), + color = TextPrimary, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.weight(1f), + ) + } + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedButton(onClick = onDismiss, modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.action_cancel)) + } + Button( + onClick = { + val variant = selectedVariant ?: return@Button + onContinue(variant) + }, + enabled = selectedVariant != null, + modifier = Modifier + .weight(1f) + .focusRequester(continueFocusRequester), + ) { + Text(stringResource(R.string.action_continue), maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } +} + +@Composable +private fun StoreLaunchVariantRow( + variant: GameVariant, + selected: Boolean, + savedDefault: Boolean, + onClick: () -> Unit, +) { + val badge = launcherBadgeForStoreKey(splitGameStoreKeys(variant.store).firstOrNull()) + var focused by remember { mutableStateOf(false) } + val shape = RoundedCornerShape(14.dp) + Box(Modifier.fillMaxWidth()) { + Surface( + modifier = Modifier + .fillMaxWidth() + .onFocusChanged { focused = it.isFocused } + .border( + width = if (focused || selected) 2.dp else 1.dp, + color = cinemaBorderColor( + LocalAbsoluteCinemaEffects.current, + LocalActiveSelectionColor.current, + ), + shape = shape, + ) + .clickable { onClick() }, + shape = shape, + color = if (focused) Color.White.copy(alpha = 0.12f) else if (selected) LocalSelectionTintColor.current.copy(alpha = 0.18f) else PanelAlt, + contentColor = TextPrimary, + ) { + Row( + Modifier.padding(14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + ConnectorStoreIcon(badge) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + gameStoreDisplayName(variant.store), + fontWeight = if (selected) FontWeight.ExtraBold else FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + val details = listOf( + if (savedDefault) stringResource(R.string.store_selector_default) else "", + variantDetailsText(variant), + ).filter { it.isNotBlank() }.joinToString(" - ") + if (details.isNotBlank()) { + Text(details, color = TextMuted, style = MaterialTheme.typography.bodySmall, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + if (selected) { + Text( + stringResource(R.string.store_selector_selected), + color = LocalSelectionTintColor.current, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.ExtraBold, + maxLines = 1, + ) + } + } + } + ControllerFocusFrame( + visible = selected && LocalActiveSelectionEnabled.current, + cornerRadius = 14.dp, + tint = LocalActiveSelectionColor.current, + secondaryTint = LocalActiveSelectionSecondaryColor.current, + ) + } +} diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowCatalogWallpaper.kt b/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowCatalogWallpaper.kt new file mode 100644 index 000000000..92bacc111 --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowCatalogWallpaper.kt @@ -0,0 +1,365 @@ +package com.opencloudgaming.opennow + +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.opencloudgaming.opennow.ui.theme.OpenNowPalette +import java.io.File +import java.util.UUID +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +internal sealed interface CatalogWallpaperSelection { + data class BuiltIn(val preset: CatalogBackgroundPreset) : CatalogWallpaperSelection + data class Custom(val source: String) : CatalogWallpaperSelection +} + +internal fun catalogWallpaperSelection( + preset: CatalogBackgroundPreset, + customSource: String?, +): CatalogWallpaperSelection = + customSource + ?.trim() + ?.takeIf { it.isNotBlank() } + ?.let(CatalogWallpaperSelection::Custom) + ?: CatalogWallpaperSelection.BuiltIn(preset) + +internal fun shouldShowCatalogWallpaper(settings: AppSettings): Boolean = + settings.nerdCatalogBackground + +@Composable +internal fun CatalogWallpaperBackdrop( + settings: AppSettings, + tvProfile: Boolean, + width: Dp, + height: Dp, +) { + val showBackdrop = shouldShowCatalogWallpaper(settings) + if (!showBackdrop) { + return + } + val wallpaper = catalogWallpaperSelection( + preset = settings.catalogBackgroundPreset, + customSource = settings.nerdCatalogBackgroundUri, + ) + val scrimAlpha = when { + tvProfile -> 0.48f + width > height -> 0.28f + else -> 0.36f + } + Box(Modifier.fillMaxSize().clipToBounds()) { + when (wallpaper) { + is CatalogWallpaperSelection.BuiltIn -> { + CatalogBuiltInWallpaperBackdrop(wallpaper.preset, Modifier.matchParentSize()) + } + is CatalogWallpaperSelection.Custom -> { + val fallbackPainter = painterResource(settings.catalogBackgroundPreset.drawableRes) + AsyncImage( + model = imageDataForSource(wallpaper.source), + contentDescription = null, + modifier = Modifier.matchParentSize(), + contentScale = ContentScale.Crop, + placeholder = fallbackPainter, + error = fallbackPainter, + fallback = fallbackPainter, + ) + } + } + Box( + Modifier + .matchParentSize() + .background(Color.Black.copy(alpha = scrimAlpha)), + ) + } +} + +internal val CatalogBackgroundPreset.drawableRes: Int + get() = when (this) { + CatalogBackgroundPreset.ColorfulAbstract -> R.drawable.catalog_colorful_abstract_background + CatalogBackgroundPreset.Original -> R.drawable.catalog_default_background + CatalogBackgroundPreset.AbsoluteCinema -> R.drawable.catalog_absolute_cinema_background + } + +@Composable +private fun CatalogBuiltInWallpaperBackdrop( + preset: CatalogBackgroundPreset, + modifier: Modifier = Modifier, +) { + Image( + painter = painterResource(preset.drawableRes), + contentDescription = null, + modifier = modifier.background(OpenNowPalette.WallpaperBackdrop), + contentScale = ContentScale.Crop, + ) +} + +internal const val CATALOG_BACKGROUND_IMAGE_FILE_PREFIX = "catalog_background_image" + +@Composable +internal fun catalogBackgroundPresetLabel(preset: CatalogBackgroundPreset): String = when (preset) { + CatalogBackgroundPreset.ColorfulAbstract -> stringResource(R.string.catalog_background_colorful_abstract) + CatalogBackgroundPreset.Original -> stringResource(R.string.catalog_background_original) + CatalogBackgroundPreset.AbsoluteCinema -> stringResource(R.string.catalog_background_absolute_cinema) +} + +/** + * Imports a picture from the device and makes it the catalog backdrop. + * + * Returns the launcher so a caller can hang it off whatever control it likes — a settings row, a + * tile in first-run setup — without any of them re-deriving the copy/prune/permission dance. + */ +@Composable +internal fun rememberCatalogBackgroundImagePicker( + settings: AppSettings, + onSettingsChange: (AppSettings) -> Unit, +): () -> Unit { + val context = LocalContext.current + val appContext = context.applicationContext + val currentSettings by rememberUpdatedState(settings) + val currentOnSettingsChange by rememberUpdatedState(onSettingsChange) + val scope = rememberCoroutineScope() + val picker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + if (uri == null) return@rememberLauncherForActivityResult + takePersistableImageReadPermission(context, uri) + scope.launch { + val newUri = withContext(Dispatchers.IO) { + persistCatalogBackgroundImage(appContext, uri) + } + val previousUri = currentSettings.nerdCatalogBackgroundUri + if (newUri != uri.toString()) { + releasePersistableImageReadPermission(context, uri.toString()) + } + currentOnSettingsChange( + currentSettings.copy( + nerdCatalogBackground = true, + nerdCatalogBackgroundUri = newUri, + ), + ) + if (!previousUri.isNullOrBlank() && previousUri != newUri) { + releasePersistableImageReadPermission(context, previousUri) + } + pruneStoredCatalogBackgroundImages(appContext, keepUri = newUri) + } + } + return remember(picker) { { picker.launch(arrayOf("image/*")) } } +} + +/** + * Switches the backdrop to a bundled preset, dropping any imported picture. + * + * Choosing a backdrop turns the backdrop on. Leaving these choices reachable while it is off is + * deliberate — a user browsing them should not have to find a separate switch first. + */ +internal fun applyCatalogBackgroundPreset( + context: Context, + settings: AppSettings, + preset: CatalogBackgroundPreset, + onSettingsChange: (AppSettings) -> Unit, +) { + val previousUri = settings.nerdCatalogBackgroundUri?.takeIf { it.isNotBlank() } + onSettingsChange( + settings.copy( + nerdCatalogBackground = true, + catalogBackgroundPreset = preset, + nerdCatalogBackgroundUri = null, + ), + ) + previousUri?.let { releasePersistableImageReadPermission(context, it) } + pruneStoredCatalogBackgroundImages(context.applicationContext) +} + +/** Drops the imported picture and falls back to the selected preset. */ +internal fun clearCatalogBackgroundImage( + context: Context, + settings: AppSettings, + onSettingsChange: (AppSettings) -> Unit, +) { + val previousUri = settings.nerdCatalogBackgroundUri?.takeIf { it.isNotBlank() } + onSettingsChange(settings.copy(nerdCatalogBackgroundUri = null)) + previousUri?.let { releasePersistableImageReadPermission(context, it) } + pruneStoredCatalogBackgroundImages(context.applicationContext) +} + +/** Settings > Interface: the backdrop preset row and custom-image buttons. */ +@Composable +internal fun CatalogBackgroundPicker( + settings: AppSettings, + onSettingsChange: (AppSettings) -> Unit, +) { + val context = LocalContext.current + val launchImagePicker = rememberCatalogBackgroundImagePicker(settings, onSettingsChange) + val hasCustomBackground = !settings.nerdCatalogBackgroundUri.isNullOrBlank() + val presetOptions = CatalogBackgroundPreset.entries.map { it to catalogBackgroundPresetLabel(it) } + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.76f), + ) { + Column( + Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text( + stringResource(R.string.settings_catalog_background_image), + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + if (hasCustomBackground) { + stringResource(R.string.settings_catalog_background_image_custom) + } else { + presetOptions.first { it.first == settings.catalogBackgroundPreset }.second + }, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + ChoiceRow( + label = stringResource(R.string.settings_catalog_background_built_in), + options = presetOptions.map { it.second }, + selected = presetOptions.first { it.first == settings.catalogBackgroundPreset }.second, + ) { selectedLabel -> + val selectedPreset = presetOptions.firstOrNull { it.second == selectedLabel } + ?.first + ?: return@ChoiceRow + applyCatalogBackgroundPreset(context, settings, selectedPreset, onSettingsChange) + } + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedButton( + onClick = launchImagePicker, + modifier = Modifier.weight(1f), + ) { + Text(stringResource(R.string.action_choose_image), maxLines = 1, overflow = TextOverflow.Ellipsis) + } + if (hasCustomBackground) { + OutlinedButton( + onClick = { clearCatalogBackgroundImage(context, settings, onSettingsChange) }, + modifier = Modifier.weight(1f), + ) { + Text(stringResource(R.string.action_use_default), maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } + } + } +} + +private fun takePersistableImageReadPermission(context: Context, uri: Uri) { + runCatching { + context.contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) + } +} + +private fun persistCatalogBackgroundImage(context: Context, uri: Uri): String { + val uniqueId = UUID.randomUUID().toString() + val target = File(context.filesDir, "$CATALOG_BACKGROUND_IMAGE_FILE_PREFIX-$uniqueId") + val temp = File(context.cacheDir, "$CATALOG_BACKGROUND_IMAGE_FILE_PREFIX-$uniqueId.tmp") + return try { + val input = context.contentResolver.openInputStream(uri) ?: return uri.toString() + input.use { + temp.outputStream().use { output -> + it.copyTo(output) + } + } + if (!temp.renameTo(target)) { + temp.copyTo(target, overwrite = true) + } + Uri.fromFile(target).toString() + } catch (_: Exception) { + target.delete() + uri.toString() + } finally { + temp.delete() + } +} + +internal fun isManagedCatalogBackgroundImageFile(filesDir: File, candidate: File): Boolean { + val normalizedFilesDir = runCatching { filesDir.canonicalFile }.getOrElse { filesDir.absoluteFile } + val normalizedCandidate = runCatching { candidate.canonicalFile }.getOrElse { candidate.absoluteFile } + val managedName = normalizedCandidate.name == CATALOG_BACKGROUND_IMAGE_FILE_PREFIX || + normalizedCandidate.name.startsWith("$CATALOG_BACKGROUND_IMAGE_FILE_PREFIX-") + return normalizedCandidate.parentFile == normalizedFilesDir && managedName +} + +private fun pruneStoredCatalogBackgroundImages(context: Context, keepUri: String? = null) { + val keepFile = keepUri + ?.let { runCatching { Uri.parse(it) }.getOrNull() } + ?.takeIf { it.scheme.equals("file", ignoreCase = true) } + ?.path + ?.let(::File) + ?.let { file -> runCatching { file.canonicalFile }.getOrElse { file.absoluteFile } } + runCatching { + context.filesDir.listFiles() + .orEmpty() + .asSequence() + .filter { isManagedCatalogBackgroundImageFile(context.filesDir, it) } + .filterNot { candidate -> + val normalized = runCatching { candidate.canonicalFile }.getOrElse { candidate.absoluteFile } + normalized == keepFile + } + .forEach(File::delete) + context.cacheDir.listFiles() + .orEmpty() + .asSequence() + .filter { + it.name == "$CATALOG_BACKGROUND_IMAGE_FILE_PREFIX.tmp" || + (it.name.startsWith("$CATALOG_BACKGROUND_IMAGE_FILE_PREFIX-") && it.name.endsWith(".tmp")) + } + .forEach(File::delete) + } +} + +private fun releasePersistableImageReadPermission(context: Context, uriString: String) { + val uri = runCatching { Uri.parse(uriString) }.getOrNull() ?: return + runCatching { + context.contentResolver.releasePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) + } +} diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowCommunity.kt b/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowCommunity.kt new file mode 100644 index 000000000..c25905b47 --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowCommunity.kt @@ -0,0 +1,81 @@ +package com.opencloudgaming.opennow + +import android.content.ClipData +import android.content.ClipboardManager +import android.widget.Toast +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp + +internal const val OPENNOW_DISCORD_COMMUNITY_URL = "https://discord.gg/euSABw8CX8" + +@Composable +internal fun DiscordCommunityLink( + summary: String, + modifier: Modifier = Modifier, + containerColor: Color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.72f), +) { + val context = LocalContext.current + Surface( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + color = containerColor, + ) { + Row( + modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + stringResource(R.string.discord_community_title), + fontWeight = FontWeight.SemiBold, + ) + Text( + summary, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, + ) + } + OutlinedButton( + onClick = { + if (!openExternalUrl(context, OPENNOW_DISCORD_COMMUNITY_URL)) { + context.getSystemService(ClipboardManager::class.java)?.setPrimaryClip( + ClipData.newPlainText( + context.getString(R.string.discord_community_title), + OPENNOW_DISCORD_COMMUNITY_URL, + ), + ) + Toast.makeText( + context, + context.getString(R.string.discord_community_link_copied), + Toast.LENGTH_SHORT, + ).show() + } + }, + ) { + Text( + stringResource(R.string.discord_community_action), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowDeveloperOptions.kt b/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowDeveloperOptions.kt new file mode 100644 index 000000000..0d724104e --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowDeveloperOptions.kt @@ -0,0 +1,408 @@ +package com.opencloudgaming.opennow + +import android.content.Context +import android.os.Build +import android.widget.Toast +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.opencloudgaming.opennow.ui.controls.ControlActionRow +import com.opencloudgaming.opennow.ui.controls.ControlSection +import com.opencloudgaming.opennow.ui.theme.OpenNowPalette +import com.opencloudgaming.opennow.ui.theme.OpenNowRadius +import com.opencloudgaming.opennow.ui.theme.OpenNowSpacing +import kotlinx.coroutines.launch + +/** + * Settings > Developer options. + * + * Revealed by the build-number gesture in About, and built from the same `ControlSection` / + * `ControlActionRow` kit as every other settings page so it does not read as a bolted-on debug + * screen — a developer using it is still using OpenNOW. + * + * The unlock gesture, and every transform an action applies to [AppSettings], live in + * `AndroidDeveloperOptions.kt` where they are unit tested. + * + * Scope is deliberately narrow, and stays inside what a store build may ship: each action either + * resets local state the user can already reach elsewhere, or shows information already present in + * the diagnostics export. Nothing here grants entitlements, alters what is reported about the user, + * or reaches outside OpenNOW's own data. + */ +@Composable +internal fun DeveloperOptionsPanel(state: OpenNowUiState, viewModel: OpenNowViewModel) { + val settings = state.settings + val context = LocalContext.current + val clipboard = LocalClipboardManager.current + val scope = rememberCoroutineScope() + var pendingDestructive by remember { mutableStateOf(null) } + + fun update(transform: (AppSettings) -> AppSettings, message: String) { + viewModel.updateSettings(transform(settings)) + Toast.makeText(context, message, Toast.LENGTH_SHORT).show() + } + + pendingDestructive?.let { action -> + AlertDialog( + onDismissRequest = { pendingDestructive = null }, + title = { Text(action.title) }, + text = { Text(action.body) }, + confirmButton = { + Button( + onClick = { + pendingDestructive = null + action.run() + }, + ) { + Text(action.confirmLabel) + } + }, + dismissButton = { + TextButton(onClick = { pendingDestructive = null }) { + Text(stringResource(R.string.action_cancel)) + } + }, + ) + } + + Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(OpenNowSpacing.md)) { + DeveloperOptionsNotice() + + ControlSection(stringResource(R.string.dev_section_flows)) { + ControlActionRow( + label = stringResource(R.string.dev_replay_first_launch), + value = stringResource(R.string.dev_replay_first_launch_desc), + actionLabel = stringResource(R.string.dev_action_replay), + onClick = { + pendingDestructive = DeveloperDestructiveAction( + title = context.getString(R.string.dev_replay_first_launch), + body = context.getString(R.string.dev_replay_first_launch_confirm), + confirmLabel = context.getString(R.string.dev_action_replay), + ) { + update({ it.replayingFirstLaunch() }, context.getString(R.string.dev_toast_first_launch)) + } + }, + ) + ControlActionRow( + label = stringResource(R.string.dev_reset_setup), + value = stringResource(R.string.dev_reset_setup_desc), + actionLabel = stringResource(R.string.dev_action_reset), + onClick = { update({ it.restartingSetupFlow() }, context.getString(R.string.dev_toast_setup)) }, + ) + ControlActionRow( + label = stringResource(R.string.dev_reset_stream_guide), + value = stringResource(R.string.dev_reset_stream_guide_desc), + actionLabel = stringResource(R.string.dev_action_reset), + onClick = { update({ it.resettingStreamGuide() }, context.getString(R.string.dev_toast_stream_guide)) }, + ) + ControlActionRow( + label = stringResource(R.string.dev_reset_controller_prompt), + value = stringResource(R.string.dev_reset_controller_prompt_desc), + actionLabel = stringResource(R.string.dev_action_reset), + onClick = { + update({ it.resettingControllerPrompt() }, context.getString(R.string.dev_toast_controller_prompt)) + }, + ) + ControlActionRow( + label = stringResource(R.string.dev_reset_analytics_consent), + value = stringResource(R.string.dev_reset_analytics_consent_desc), + actionLabel = stringResource(R.string.dev_action_reset), + onClick = { + update({ it.resettingAnalyticsConsent() }, context.getString(R.string.dev_toast_analytics_consent)) + }, + ) + ControlActionRow( + label = stringResource(R.string.dev_reset_migrations), + value = stringResource(R.string.dev_reset_migrations_desc), + actionLabel = stringResource(R.string.dev_action_reset), + onClick = { update({ it.resettingProfileMigrations() }, context.getString(R.string.dev_toast_migrations)) }, + ) + } + + ControlSection(stringResource(R.string.dev_section_catalog)) { + ControlActionRow( + label = stringResource(R.string.dev_clear_catalog_cache), + value = stringResource(R.string.dev_clear_catalog_cache_desc), + actionLabel = stringResource(R.string.dev_action_clear), + onClick = viewModel::clearCatalogCache, + ) + ControlActionRow( + label = stringResource(R.string.dev_refresh_catalog), + value = stringResource(R.string.dev_refresh_catalog_desc), + actionLabel = stringResource(R.string.dev_action_run), + onClick = viewModel::refreshGames, + ) + ControlActionRow( + label = stringResource(R.string.dev_reset_browsing), + value = stringResource(R.string.dev_reset_browsing_desc), + actionLabel = stringResource(R.string.dev_action_reset), + onClick = { update({ it.resettingCatalogBrowsing() }, context.getString(R.string.dev_toast_browsing)) }, + ) + ControlActionRow( + label = stringResource(R.string.dev_clear_store_preferences), + value = stringResource(R.string.dev_clear_store_preferences_desc), + actionLabel = stringResource(R.string.dev_action_clear), + onClick = { update({ it.clearingStorePreferences() }, context.getString(R.string.dev_toast_store_preferences)) }, + ) + ControlActionRow( + label = stringResource(R.string.dev_clear_favorites), + value = stringResource(R.string.dev_clear_favorites_count, settings.favoriteGameIds.size), + actionLabel = stringResource(R.string.dev_action_clear), + onClick = { + pendingDestructive = DeveloperDestructiveAction( + title = context.getString(R.string.dev_clear_favorites), + body = context.getString(R.string.dev_clear_favorites_confirm), + confirmLabel = context.getString(R.string.dev_action_clear), + ) { + update({ it.clearingFavorites() }, context.getString(R.string.dev_toast_favorites)) + } + }, + ) + if (BuildConfig.LOCAL_APP_LAUNCHER_SUPPORTED) { + ControlActionRow( + label = stringResource(R.string.dev_clear_local_apps), + value = stringResource(R.string.dev_clear_local_apps_count, settings.localAppPackageNames.size), + actionLabel = stringResource(R.string.dev_action_clear), + onClick = { update({ it.clearingLocalAppShelf() }, context.getString(R.string.dev_toast_local_apps)) }, + ) + } + } + + ControlSection(stringResource(R.string.dev_section_stream)) { + ControlActionRow( + label = stringResource(R.string.dev_apply_recommended), + value = state.recommendedStreamSettings?.recommendationSummary() + ?: stringResource(R.string.setup_streaming_measuring), + actionLabel = stringResource(R.string.dev_action_apply), + onClick = { + viewModel.applyStreamPreset(StreamPreset.Recommended) + Toast.makeText(context, context.getString(R.string.dev_toast_recommended), Toast.LENGTH_SHORT).show() + }, + ) + ControlActionRow( + label = stringResource(R.string.dev_reset_touch_layout), + value = stringResource(R.string.dev_reset_touch_layout_desc), + actionLabel = stringResource(R.string.dev_action_reset), + onClick = { update({ it.resettingTouchLayout() }, context.getString(R.string.dev_toast_touch_layout)) }, + ) + ControlActionRow( + label = stringResource(R.string.dev_refresh_servers), + value = stringResource(R.string.dev_refresh_servers_desc), + actionLabel = stringResource(R.string.dev_action_run), + onClick = viewModel::refreshPrintedWasteQueues, + ) + } + + ControlSection(stringResource(R.string.dev_section_interface)) { + ControlActionRow( + label = stringResource(R.string.dev_reset_interface), + value = stringResource(R.string.dev_reset_interface_desc), + actionLabel = stringResource(R.string.dev_action_reset), + onClick = { update({ it.resettingInterface() }, context.getString(R.string.dev_toast_interface)) }, + ) + } + + ControlSection(stringResource(R.string.dev_section_diagnostics)) { + DeveloperEnvironmentCard(state) + ControlActionRow( + label = stringResource(R.string.dev_copy_diagnostics), + value = stringResource(R.string.dev_copy_diagnostics_desc), + actionLabel = stringResource(R.string.dev_action_copy), + onClick = { + scope.launch { + clipboard.setText(AnnotatedString(viewModel.sanitizedDebugLogText())) + Toast.makeText(context, context.getString(R.string.dev_toast_diagnostics_copied), Toast.LENGTH_SHORT).show() + } + }, + ) + ControlActionRow( + label = stringResource(R.string.dev_copy_environment), + value = stringResource(R.string.dev_copy_environment_desc), + actionLabel = stringResource(R.string.dev_action_copy), + onClick = { + clipboard.setText(AnnotatedString(developerEnvironmentSummary(context, state))) + Toast.makeText(context, context.getString(R.string.dev_toast_environment_copied), Toast.LENGTH_SHORT).show() + }, + ) + ControlActionRow( + label = stringResource(R.string.dev_check_update), + value = stringResource(R.string.dev_check_update_desc), + actionLabel = stringResource(R.string.dev_action_run), + onClick = viewModel::checkAndroidUpdate, + ) + } + + ControlSection(stringResource(R.string.dev_section_danger)) { + ControlActionRow( + label = stringResource(R.string.dev_sign_out_all), + value = stringResource(R.string.dev_sign_out_all_desc, state.savedAccounts.size), + actionLabel = stringResource(R.string.dev_action_run), + onClick = { + pendingDestructive = DeveloperDestructiveAction( + title = context.getString(R.string.dev_sign_out_all), + body = context.getString(R.string.dev_sign_out_all_confirm), + confirmLabel = context.getString(R.string.dev_action_run), + run = viewModel::logoutAll, + ) + }, + ) + ControlActionRow( + label = stringResource(R.string.dev_wipe_app_data), + value = stringResource(R.string.dev_wipe_app_data_desc), + actionLabel = stringResource(R.string.dev_action_run), + onClick = { + pendingDestructive = DeveloperDestructiveAction( + title = context.getString(R.string.dev_wipe_app_data), + body = context.getString(R.string.dev_wipe_app_data_confirm), + confirmLabel = context.getString(R.string.dev_action_run), + run = viewModel::resetSettings, + ) + }, + ) + ControlActionRow( + label = stringResource(R.string.dev_lock), + value = stringResource(R.string.dev_lock_desc), + actionLabel = stringResource(R.string.dev_action_lock), + onClick = { update({ it.lockingDeveloperOptions() }, context.getString(R.string.dev_toast_locked)) }, + ) + } + } +} + +private class DeveloperDestructiveAction( + val title: String, + val body: String, + val confirmLabel: String, + val run: () -> Unit, +) + +@Composable +private fun DeveloperOptionsNotice() { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(OpenNowRadius.lg), + color = OpenNowPalette.StatusNotice.copy(alpha = 0.10f), + contentColor = SettingsText, + ) { + Column( + Modifier.fillMaxWidth().padding(OpenNowSpacing.md), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + stringResource(R.string.dev_notice_title), + color = OpenNowPalette.StatusNotice, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + ) + Text( + stringResource(R.string.dev_notice_body), + color = SettingsTextMuted, + style = MaterialTheme.typography.bodySmall, + ) + } + } +} + +@Composable +private fun DeveloperEnvironmentCard(state: OpenNowUiState) { + val context = LocalContext.current + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(OpenNowRadius.md), + color = SettingsPanelAlt, + ) { + Column( + Modifier.fillMaxWidth().padding(OpenNowSpacing.md), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + developerEnvironmentRows(context, state).forEach { (label, value) -> + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(OpenNowSpacing.sm), + ) { + Text( + label, + Modifier.weight(1f), + color = SettingsTextMuted, + style = MaterialTheme.typography.bodySmall, + ) + Text( + value, + Modifier.weight(1.4f), + color = SettingsText, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Medium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } +} + +/** + * The runtime facts a bug report almost always starts by asking for. + * + * Everything here is already in the diagnostics export; this is the same data at a glance so a + * developer does not have to export and read a log to answer "which build, which tier, which + * decoder". + */ +private fun developerEnvironmentRows( + context: Context, + state: OpenNowUiState, +): List> { + val tier = state.subscriptionInfo?.membershipTier?.takeIf { it.isNotBlank() } + ?: state.authSession?.user?.membershipTier?.takeIf { it.isNotBlank() } + ?: context.getString(R.string.dev_value_signed_out) + return listOf( + context.getString(R.string.dev_env_build) to "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})", + context.getString(R.string.dev_env_flavor) to buildString { + append(if (BuildConfig.DEBUG) "debug" else "release") + if (BuildConfig.PLAY_STORE_RELEASE) append(" · play") + }, + context.getString(R.string.dev_env_device) to "${Build.MANUFACTURER} ${Build.MODEL}", + context.getString(R.string.dev_env_android) to "${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})", + context.getString(R.string.dev_env_profile) to context.getString( + if (state.androidTvProfile) R.string.dev_value_tv else R.string.dev_value_handheld, + ), + context.getString(R.string.dev_env_tier) to tier, + context.getString(R.string.dev_env_provider) to + (state.authSession?.provider?.code ?: context.getString(R.string.dev_value_none)), + context.getString(R.string.dev_env_stream) to state.settings.stream.recommendationSummary(), + context.getString(R.string.dev_env_decoder) to ( + state.codecReport?.capabilities + ?.filter { it.hardwareDecoder } + ?.joinToString(", ") { it.codec.name } + ?.takeIf { it.isNotBlank() } + ?: context.getString(R.string.dev_value_none) + ), + context.getString(R.string.dev_env_catalog) to + "${state.games.size} / ${state.libraryGames.size}", + ) +} + +private fun developerEnvironmentSummary(context: Context, state: OpenNowUiState): String = + developerEnvironmentRows(context, state).joinToString("\n") { (label, value) -> "$label: $value" } diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowLocalApps.kt b/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowLocalApps.kt new file mode 100644 index 000000000..71b55edb0 --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowLocalApps.kt @@ -0,0 +1,683 @@ +package com.opencloudgaming.opennow + +import android.app.role.RoleManager +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.content.pm.ResolveInfo +import android.graphics.drawable.Drawable +import android.os.Build +import android.provider.Settings +import android.widget.Toast +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +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.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.core.graphics.drawable.toBitmap +import com.opencloudgaming.opennow.ui.controls.ControlRow +import com.opencloudgaming.opennow.ui.controls.ControlRowLabels +import com.opencloudgaming.opennow.ui.controls.controlRowStyle +import java.util.Locale +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext + +internal data class LocalAppEntry( + val packageName: String, + val label: String, + val icon: Drawable, +) + +internal fun normalizeLocalAppPackageNames(packageNames: List): List = + packageNames.map(String::trim).filter(String::isNotEmpty).distinct() + +/** + * Icon-sized so the shelf reads as a different kind of thing from the poster grid below it. + * + * A cloud game is a 628x888 key art poster; an Android app is a launcher icon with no artwork + * behind it. Blowing that icon up to poster size made every tile look like a mis-cropped game, so + * the tile is sized to the icon instead and the shelf sits visibly above the catalogue rather than + * pretending to be the first row of it. + */ +private val LOCAL_APP_ICON_SIZE = 56.dp +private val LOCAL_APP_TILE_SIZE = 72.dp +private val LOCAL_APP_TILE_WIDTH = 78.dp + +@OptIn(ExperimentalFoundationApi::class) +@Composable +internal fun LocalAppsShelf( + packageNames: List, + collapsed: Boolean, + onCollapsedChange: (Boolean) -> Unit, + onAddPackage: (String) -> Unit, + onRemovePackage: (String) -> Unit, + horizontalPadding: Dp, + headerFocusRequester: FocusRequester? = null, + focusRequester: FocusRequester? = null, + topFocusRequester: FocusRequester? = null, +) { + val context = LocalContext.current + val packageManager = context.packageManager + val normalizedPackages = remember(packageNames) { normalizeLocalAppPackageNames(packageNames) } + val apps = remember(normalizedPackages) { + normalizedPackages.mapNotNull { packageName -> + runCatching { + val info = packageManager.getApplicationInfo(packageName, 0) + LocalAppEntry( + packageName = packageName, + label = packageManager.getApplicationLabel(info).toString(), + icon = packageManager.getApplicationIcon(info), + ) + }.getOrNull() + } + } + var pickerOpen by remember { mutableStateOf(false) } + var pendingRemoval by remember { mutableStateOf(null) } + + Column( + Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + LocalAppsSectionHeader( + collapsed = collapsed, + appCount = apps.size, + onToggle = { onCollapsedChange(!collapsed) }, + focusRequester = headerFocusRequester, + topFocusRequester = topFocusRequester, + modifier = Modifier.padding(horizontal = horizontalPadding), + ) + AnimatedVisibility(visible = !collapsed) { + LazyRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(horizontal = horizontalPadding), + ) { + item(key = "add-local-app") { + AddLocalAppTile( + onClick = { pickerOpen = true }, + focusRequester = focusRequester, + topFocusRequester = headerFocusRequester ?: topFocusRequester, + ) + } + items(apps, key = { it.packageName }) { app -> + LocalAppTile( + app = app, + onLaunch = { + if (!launchLocalApp(context, app.packageName)) { + Toast.makeText(context, R.string.library_local_app_unavailable, Toast.LENGTH_SHORT).show() + } + }, + onRemove = { pendingRemoval = app }, + topFocusRequester = headerFocusRequester ?: topFocusRequester, + ) + } + } + } + } + + if (pickerOpen) { + LocalAppPickerDialog( + existingPackages = normalizedPackages.toSet(), + onDismiss = { pickerOpen = false }, + onSelect = { packageName -> + onAddPackage(packageName) + pickerOpen = false + }, + ) + } + + pendingRemoval?.let { app -> + AlertDialog( + onDismissRequest = { pendingRemoval = null }, + title = { Text(stringResource(R.string.library_remove_local_app, app.label), color = TextPrimary) }, + text = { Text(stringResource(R.string.library_remove_local_app_body), color = TextMuted) }, + confirmButton = { + TextButton(onClick = { + onRemovePackage(app.packageName) + pendingRemoval = null + }) { Text(stringResource(R.string.library_remove_local_app_confirm)) } + }, + dismissButton = { + TextButton(onClick = { pendingRemoval = null }) { Text(stringResource(R.string.action_cancel)) } + }, + containerColor = Panel, + ) + } +} + +/** The whole header is the fold control, so it stays a single focus stop on a controller. */ +@Composable +private fun LocalAppsSectionHeader( + collapsed: Boolean, + appCount: Int, + onToggle: () -> Unit, + focusRequester: FocusRequester?, + topFocusRequester: FocusRequester?, + modifier: Modifier = Modifier, +) { + var focused by remember { mutableStateOf(false) } + val haptics = LocalOpenNowHaptics.current + val shape = RoundedCornerShape(10.dp) + val toggle = { + haptics?.play(HapticCue.Activate) + onToggle() + } + // One chevron drawable, rotated: right when folded, down when open. + val chevronRotation by animateFloatAsState( + targetValue = if (collapsed) 0f else 90f, + label = "local-apps-chevron", + ) + val description = stringResource( + if (collapsed) R.string.library_local_apps_show else R.string.library_local_apps_hide, + appCount, + ) + Box(modifier) { + Row( + Modifier + .then(focusRequester?.let { Modifier.focusRequester(it) } ?: Modifier) + .then( + topFocusRequester?.let { top -> Modifier.focusProperties { up = top } } ?: Modifier, + ) + .onFocusChanged { focused = it.isFocused } + .focusMoveHaptics() + .clip(shape) + .semantics { + role = Role.Button + contentDescription = description + } + .clickable(onClick = toggle) + .onPreviewKeyEvent { event -> + if (isTvActivateKey(event)) { + toggle() + true + } else { + false + } + } + .focusable() + .padding(horizontal = 6.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + painter = painterResource(R.drawable.ic_chevron_right), + contentDescription = null, + tint = if (focused) Color.White else TextMuted, + modifier = Modifier.size(18.dp).rotate(chevronRotation), + ) + Text( + stringResource(R.string.library_local_apps), + color = TextPrimary, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.titleMedium, + ) + if (appCount > 0) { + Text( + appCount.toString(), + color = TextMuted, + style = MaterialTheme.typography.labelMedium, + ) + } + } + InteractionFocusFrame( + visible = focused, + cornerRadius = 10.dp, + cinemaEffectEnabled = LocalAbsoluteCinemaEverywhere.current, + ) + } +} + +/** Prefer the TV entry point on televisions, with the regular launcher activity as fallback. */ +internal fun launchLocalApp(context: Context, packageName: String): Boolean { + val packageManager = context.packageManager + val launchIntent = packageManager.getLeanbackLaunchIntentForPackage(packageName) + ?: packageManager.getLaunchIntentForPackage(packageName) + ?: return false + return try { + context.startActivity(launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + true + } catch (_: ActivityNotFoundException) { + false + } catch (_: SecurityException) { + false + } +} + +@Composable +private fun LocalAppPickerDialog( + existingPackages: Set, + onDismiss: () -> Unit, + onSelect: (String) -> Unit, +) { + val context = LocalContext.current + var loading by remember { mutableStateOf(true) } + var apps by remember { mutableStateOf>(emptyList()) } + val firstAppFocusRequester = remember { FocusRequester() } + BackHandler(onBack = onDismiss) + LaunchedEffect(existingPackages) { + loading = true + apps = withContext(Dispatchers.IO) { + queryLaunchableLocalApps(context.packageManager, context.packageName) + .filterNot { it.packageName in existingPackages } + } + loading = false + } + LaunchedEffect(loading, apps) { + if (!loading && apps.isNotEmpty()) { + delay(80) + runCatching { firstAppFocusRequester.requestFocus() } + } + } + + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + stringResource(R.string.library_choose_local_app), + color = TextPrimary, + fontWeight = FontWeight.Bold, + ) + }, + text = { + when { + loading -> Row( + Modifier.fillMaxWidth().padding(vertical = 24.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically, + ) { + CircularProgressIndicator(Modifier.size(24.dp), strokeWidth = 2.dp) + Text(stringResource(R.string.library_local_apps_loading), color = TextMuted) + } + apps.isEmpty() -> Text(stringResource(R.string.library_no_launchable_apps), color = TextMuted) + else -> LazyColumn( + modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + items(apps, key = { it.packageName }) { app -> + LocalAppPickerRow( + app = app, + focusRequester = firstAppFocusRequester.takeIf { app == apps.first() }, + onSelect = { onSelect(app.packageName) }, + ) + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } + }, + containerColor = Panel, + ) +} + +@Composable +private fun LocalAppPickerRow( + app: LocalAppEntry, + focusRequester: FocusRequester?, + onSelect: () -> Unit, +) { + var focused by remember { mutableStateOf(false) } + val shape = RoundedCornerShape(14.dp) + val bitmap = remember(app.packageName, app.icon) { app.icon.toBitmap().asImageBitmap() } + Box(Modifier.fillMaxWidth()) { + Row( + Modifier + .fillMaxWidth() + .onFocusChanged { focused = it.isFocused } + .then(focusRequester?.let { Modifier.focusRequester(it) } ?: Modifier) + .clip(shape) + .clickable(onClick = onSelect) + .onPreviewKeyEvent { event -> + if (isTvActivateKey(event)) { + onSelect() + true + } else { + false + } + } + .focusable() + .padding(10.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Image( + bitmap = bitmap, + contentDescription = null, + modifier = Modifier.size(42.dp).clip(RoundedCornerShape(10.dp)), + contentScale = ContentScale.Fit, + ) + Text( + app.label, + color = TextPrimary, + fontWeight = if (focused) FontWeight.Bold else FontWeight.Medium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + InteractionFocusFrame( + visible = focused, + cornerRadius = 14.dp, + cinemaEffectEnabled = LocalAbsoluteCinemaEverywhere.current, + ) + } +} + +private fun queryLaunchableLocalApps(packageManager: PackageManager, ownPackage: String): List { + val intents = listOf( + Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LEANBACK_LAUNCHER), + Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER), + ) + return intents + .flatMap { intent -> packageManager.queryLaunchableActivities(intent) } + .distinctBy { it.activityInfo.packageName } + .filterNot { it.activityInfo.packageName == ownPackage } + .map { info -> + LocalAppEntry( + packageName = info.activityInfo.packageName, + label = info.loadLabel(packageManager).toString(), + icon = info.loadIcon(packageManager), + ) + } + .sortedBy { it.label.lowercase(Locale.getDefault()) } +} + +@Suppress("DEPRECATION") +private fun PackageManager.queryLaunchableActivities(intent: Intent): List = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + queryIntentActivities(intent, PackageManager.ResolveInfoFlags.of(PackageManager.MATCH_ALL.toLong())) + } else { + queryIntentActivities(intent, PackageManager.MATCH_ALL) + } + +@Composable +private fun AddLocalAppTile( + onClick: () -> Unit, + focusRequester: FocusRequester?, + topFocusRequester: FocusRequester?, +) { + var focused by remember { mutableStateOf(false) } + val shape = RoundedCornerShape(18.dp) + val haptics = LocalOpenNowHaptics.current + val activate = { + haptics?.play(HapticCue.Activate) + onClick() + } + LocalAppTileFrame(label = stringResource(R.string.library_add_local_app), focused = focused) { + // The focus frame is a sibling of the clipped tile, not a child: inside it, the glow would + // be cut off at the tile's own corners. + Box(Modifier.size(LOCAL_APP_TILE_SIZE)) { + Box( + Modifier + .matchParentSize() + .then(focusRequester?.let { Modifier.focusRequester(it) } ?: Modifier) + .then( + topFocusRequester?.let { top -> Modifier.focusProperties { up = top } } ?: Modifier, + ) + .onFocusChanged { focused = it.isFocused } + .focusMoveHaptics() + .clip(shape) + .background(PanelAlt.copy(alpha = 0.86f)) + .semantics { role = Role.Button } + .clickable(onClick = activate) + .onPreviewKeyEvent { event -> + if (isTvActivateKey(event)) { + activate() + true + } else { + false + } + } + .focusable(), + contentAlignment = Alignment.Center, + ) { + Text( + "+", + color = LocalSelectionTintColor.current, + style = MaterialTheme.typography.headlineMedium, + ) + } + InteractionFocusFrame( + visible = focused, + cornerRadius = 18.dp, + cinemaEffectEnabled = LocalAbsoluteCinemaEverywhere.current, + ) + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun LocalAppTile( + app: LocalAppEntry, + onLaunch: () -> Unit, + onRemove: () -> Unit, + topFocusRequester: FocusRequester?, +) { + var focused by remember { mutableStateOf(false) } + val shape = RoundedCornerShape(18.dp) + val bitmap = remember(app.packageName, app.icon) { app.icon.toBitmap().asImageBitmap() } + val haptics = LocalOpenNowHaptics.current + val launch = { + haptics?.play(HapticCue.Activate) + onLaunch() + } + val remove = { + haptics?.play(HapticCue.Boundary) + onRemove() + } + LocalAppTileFrame(label = app.label, focused = focused) { + Box(Modifier.size(LOCAL_APP_TILE_SIZE)) { + Box( + Modifier + .matchParentSize() + .then( + topFocusRequester?.let { top -> Modifier.focusProperties { up = top } } ?: Modifier, + ) + .onFocusChanged { focused = it.isFocused } + .focusMoveHaptics() + .clip(shape) + .background(PanelAlt.copy(alpha = 0.72f)) + .semantics { role = Role.Button } + // Long-press removes, matching how a launcher un-pins an icon, and keeps the + // tile free of a delete affordance that would crowd an icon this size. + .combinedClickable( + onClick = launch, + onLongClick = remove, + onLongClickLabel = stringResource(R.string.library_remove_local_app, app.label), + ) + .onPreviewKeyEvent { event -> + when { + !focused -> false + // Consume both halves of controller activation here. Letting + // combinedClickable observe only key-down leaves its long-press armed + // after key-up launches an external activity; it can then open the + // removal prompt while OpenNOW is in the background. + isTvActivationKey(event.key) -> { + if (event.type == KeyEventType.KeyUp) launch() + true + } + // Y removes, the same button the catalogue cards use for their + // secondary action, so a controller never needs the long-press. + event.type == KeyEventType.KeyUp && event.key == Key.ButtonY -> { + remove() + true + } + else -> false + } + } + .focusable(), + contentAlignment = Alignment.Center, + ) { + Image( + bitmap = bitmap, + contentDescription = null, + modifier = Modifier.size(LOCAL_APP_ICON_SIZE).clip(RoundedCornerShape(14.dp)), + contentScale = ContentScale.Fit, + ) + } + InteractionFocusFrame( + visible = focused, + cornerRadius = 18.dp, + cinemaEffectEnabled = LocalAbsoluteCinemaEverywhere.current, + ) + } + } +} + +/** Caption below the icon rather than inside it, so the icon keeps its full square. */ +@Composable +private fun LocalAppTileFrame( + label: String, + focused: Boolean, + tile: @Composable () -> Unit, +) { + Column( + Modifier.width(LOCAL_APP_TILE_WIDTH), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + tile() + Text( + label, + color = if (focused) TextPrimary else TextMuted, + fontWeight = if (focused) FontWeight.Bold else FontWeight.Medium, + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + ) + } +} + +@Composable +internal fun rememberDefaultLauncherControl(): DefaultLauncherControl { + val context = LocalContext.current + var isDefault by remember { mutableStateOf(isOpenNowDefaultLauncher(context)) } + val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { + isDefault = isOpenNowDefaultLauncher(context) + } + return DefaultLauncherControl( + isDefault = isDefault, + request = { + val intent = defaultLauncherRequestIntent(context, isDefault) + runCatching { launcher.launch(intent) } + .onFailure { + runCatching { launcher.launch(Intent(Settings.ACTION_SETTINGS)) } + } + Unit + }, + ) +} + +internal data class DefaultLauncherControl( + val isDefault: Boolean, + val request: () -> Unit, +) + +@Composable +internal fun DefaultLauncherSetting() { + val control = rememberDefaultLauncherControl() + ControlRow(onClick = control.request) { + ControlRowLabels( + label = stringResource( + if (control.isDefault) R.string.settings_default_launcher_selected + else R.string.settings_default_launcher, + ), + value = null, + expandedDescription = stringResource(R.string.settings_default_launcher_desc), + enabled = true, + style = controlRowStyle(), + ) + Spacer(Modifier.weight(1f)) + Text( + stringResource( + if (control.isDefault) R.string.settings_default_launcher_manage + else R.string.settings_default_launcher_action, + ), + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.labelLarge, + ) + } +} + +internal fun isOpenNowDefaultLauncher(context: Context): Boolean { + val homeIntent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME) + return context.packageManager.resolveActivity(homeIntent, PackageManager.MATCH_DEFAULT_ONLY) + ?.activityInfo + ?.packageName == context.packageName +} + +internal fun defaultLauncherRequestIntent(context: Context, alreadyDefault: Boolean): Intent { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && !alreadyDefault) { + val roleManager = context.getSystemService(RoleManager::class.java) + if (roleManager?.isRoleAvailable(RoleManager.ROLE_HOME) == true && + !roleManager.isRoleHeld(RoleManager.ROLE_HOME) + ) { + return roleManager.createRequestRoleIntent(RoleManager.ROLE_HOME) + } + } + return Intent(Settings.ACTION_HOME_SETTINGS) +} diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowLoginScreens.kt b/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowLoginScreens.kt new file mode 100644 index 000000000..47ae2c937 --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowLoginScreens.kt @@ -0,0 +1,769 @@ +package com.opencloudgaming.opennow + +import android.Manifest +import android.content.Context +import android.content.res.Configuration +import android.content.Intent +import android.hardware.input.InputManager +import android.net.Uri +import android.view.InputDevice +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +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.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.DisposableEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.Dp +import kotlinx.coroutines.delay +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlin.math.min +import kotlin.math.floor + +@Composable +internal fun LoginScreen(state: OpenNowUiState, viewModel: OpenNowViewModel) { + val signInFocusRequester = remember { FocusRequester() } + val context = LocalContext.current + val scope = rememberCoroutineScope() + var tokenDialogVisible by remember { mutableStateOf(false) } + var tokenInput by remember { mutableStateOf("") } + var pendingLogText by remember { mutableStateOf("") } + var logExportInProgress by remember { mutableStateOf(false) } + val logExportLauncher = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("text/plain")) { uri -> + if (uri == null) { + logExportInProgress = false + return@rememberLauncherForActivityResult + } + val logText = pendingLogText + scope.launch { + val result = withContext(Dispatchers.IO) { + runCatching { + context.contentResolver.openOutputStream(uri)?.use { output -> + output.write(logText.toByteArray(Charsets.UTF_8)) + } ?: error("Could not open log file") + } + } + result.onSuccess { + Toast.makeText(context, context.getString(R.string.login_logs_exported), Toast.LENGTH_SHORT).show() + }.onFailure { error -> + Toast.makeText( + context, + error.message ?: context.getString(R.string.login_logs_export_failed), + Toast.LENGTH_LONG, + ).show() + } + logExportInProgress = false + } + } + val tvLogin = state.androidTvProfile + val deviceCodeLoginAvailable = state.selectedProvider.supportsDeviceCodeLogin + val preferDeviceCodeLogin = tvLogin && deviceCodeLoginAvailable + val deviceLoginPrompt = state.deviceLoginPrompt.takeIf { deviceCodeLoginAvailable } + val normalLoginBusy = state.launchPhase.isNotBlank() && deviceLoginPrompt == null + LaunchedEffect(preferDeviceCodeLogin, deviceLoginPrompt == null) { + if (preferDeviceCodeLogin && deviceLoginPrompt == null) { + runCatching { signInFocusRequester.requestFocus() } + } + } + if (preferDeviceCodeLogin && deviceLoginPrompt != null) { + TvDeviceLoginScreen( + prompt = deviceLoginPrompt, + phase = state.launchPhase, + onCancel = viewModel::cancelLogin, + ) + return + } + BoxWithConstraints(Modifier.fillMaxSize()) { + val compactForPhonePairing = tvLogin && state.localTvConnector.hosting + val dedicatedPhonePairing = shouldUseDedicatedTvPairingLayout( + tvProfile = tvLogin, + hosting = state.localTvConnector.hosting, + availableWidthDp = maxWidth.value, + availableHeightDp = maxHeight.value, + ) + if (dedicatedPhonePairing) { + TvPhonePairingPanel( + state = state, + viewModel = viewModel, + dedicated = true, + modifier = Modifier.fillMaxSize(), + ) + } else { + Column( + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(if (compactForPhonePairing) 12.dp else 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + OpenNowMark( + size = if (compactForPhonePairing) 56.dp else 88.dp, + modifier = Modifier.clickable(onClick = viewModel::recordLoginIconTap), + ) + Spacer(Modifier.height(if (compactForPhonePairing) 8.dp else 20.dp)) + Text( + "OpenNOW", + color = TextPrimary, + style = if (compactForPhonePairing) MaterialTheme.typography.headlineLarge else MaterialTheme.typography.displaySmall, + fontWeight = FontWeight.Bold, + ) + Text( + stringResource(R.string.login_tagline), + color = TextMuted, + style = if (compactForPhonePairing) MaterialTheme.typography.bodyMedium else MaterialTheme.typography.bodyLarge, + ) + Spacer(Modifier.height(if (compactForPhonePairing) 12.dp else 28.dp)) + ProviderPicker(state.providers, state.selectedProvider, viewModel::selectProvider) + Spacer(Modifier.height(if (compactForPhonePairing) 8.dp else 16.dp)) + deviceLoginPrompt?.let { prompt -> + DeviceLoginPanel(prompt = prompt, phase = state.launchPhase, onCancel = viewModel::cancelLogin) + } ?: Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(10.dp)) { + Button( + onClick = { viewModel.login() }, + enabled = !normalLoginBusy, + modifier = Modifier.focusRequester(signInFocusRequester), + colors = ButtonDefaults.buttonColors( + disabledContainerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.72f), + disabledContentColor = MaterialTheme.colorScheme.onPrimary, + ), + ) { + if (normalLoginBusy) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + Spacer(Modifier.width(10.dp)) + } + Text( + when { + state.launchPhase.isNotBlank() -> state.launchPhase + preferDeviceCodeLogin -> stringResource(R.string.login_tv_start, state.selectedProvider.displayName) + else -> stringResource(R.string.login_with_provider, state.selectedProvider.displayName) + }, + ) + } + if (!tvLogin && deviceCodeLoginAvailable) { + TextButton(onClick = { viewModel.loginWithCode() }, enabled = !normalLoginBusy) { + Text(stringResource(R.string.login_use_code)) + } + } + if (tvLogin) { + TvPhonePairingPanel(state = state, viewModel = viewModel) + } + } + if (state.error != null) { + Spacer(Modifier.height(14.dp)) + Text(state.error.orEmpty(), color = Color(0xffff9f9f)) + } + } + } + } + + if (state.loginToolsVisible) { + AlertDialog( + onDismissRequest = viewModel::dismissLoginTools, + title = { Text(stringResource(R.string.login_tools_title)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text(stringResource(R.string.login_tools_body)) + Button( + onClick = { + viewModel.dismissLoginTools() + tokenDialogVisible = true + }, + enabled = !normalLoginBusy, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.login_token_title)) + } + OutlinedButton( + onClick = { + viewModel.dismissLoginTools() + if (tvLogin) { + viewModel.requestDiagnosticShare() + } else { + logExportInProgress = true + scope.launch { + runCatching { viewModel.sanitizedDebugLogText() } + .onSuccess { logs -> + pendingLogText = logs + logExportLauncher.launch(viewModel.debugLogFileName()) + } + .onFailure { error -> + Toast.makeText( + context, + error.message ?: context.getString(R.string.login_logs_export_failed), + Toast.LENGTH_LONG, + ).show() + logExportInProgress = false + } + } + } + }, + enabled = !logExportInProgress, + modifier = Modifier.fillMaxWidth(), + ) { + Text(if (tvLogin) "Export logs with QR" else "Export logs") + } + } + }, + confirmButton = {}, + dismissButton = { + TextButton(onClick = viewModel::dismissLoginTools) { + Text(stringResource(R.string.action_cancel)) + } + }, + ) + } + + if (tokenDialogVisible) { + val submitToken = { + val submittedToken = tokenInput + tokenInput = "" + tokenDialogVisible = false + viewModel.loginWithToken(submittedToken) + } + AlertDialog( + onDismissRequest = { + tokenInput = "" + tokenDialogVisible = false + }, + title = { Text(stringResource(R.string.login_token_title)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text(stringResource(R.string.login_token_body)) + OutlinedTextField( + value = tokenInput, + onValueChange = { tokenInput = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text(stringResource(R.string.login_access_token)) }, + minLines = 3, + maxLines = 6, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions( + onDone = { if (tokenInput.isNotBlank() && !normalLoginBusy) submitToken() }, + ), + singleLine = false, + ) + Text( + stringResource(R.string.login_token_warning), + color = TextMuted, + style = MaterialTheme.typography.bodySmall, + ) + } + }, + confirmButton = { + Button( + onClick = submitToken, + enabled = tokenInput.isNotBlank() && !normalLoginBusy, + ) { + Text(stringResource(R.string.action_sign_in)) + } + }, + dismissButton = { + TextButton( + onClick = { + tokenInput = "" + tokenDialogVisible = false + }, + ) { + Text(stringResource(R.string.action_cancel)) + } + }, + ) + } +} + +internal fun shouldUseDedicatedTvPairingLayout( + tvProfile: Boolean, + hosting: Boolean, + availableWidthDp: Float, + availableHeightDp: Float, +): Boolean = tvProfile && hosting && (availableHeightDp < 500f || availableWidthDp < 760f) + +@Composable +internal fun TvPhonePairingPanel( + state: OpenNowUiState, + viewModel: OpenNowViewModel, + dedicated: Boolean = false, + modifier: Modifier = Modifier, +) { + val connector = state.localTvConnector + val context = LocalContext.current + val localNetworkPermissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { granted -> + if (granted) viewModel.startLocalTvConnector() + } + if (!connector.hosting) { + OutlinedButton( + onClick = { + if (context.hasAndroidLocalNetworkAccess()) { + viewModel.startLocalTvConnector() + } else { + localNetworkPermissionLauncher.launch(Manifest.permission.ACCESS_LOCAL_NETWORK) + } + }, + enabled = !connector.busy, + modifier = modifier, + ) { + Text( + if (connector.busy) { + stringResource(R.string.tv_pair_starting) + } else { + stringResource(R.string.tv_pair_start) + }, + ) + } + } else { + val qrCode = remember(connector.pairUri) { connector.pairUri?.let(QrCode::encodeText) } + Card( + colors = CardDefaults.cardColors(containerColor = PanelAlt), + shape = RoundedCornerShape(if (dedicated) 26.dp else 18.dp), + modifier = if (dedicated) { + modifier.padding(12.dp) + } else { + modifier.fillMaxWidth().padding(top = 8.dp) + }, + ) { + BoxWithConstraints(if (dedicated) Modifier.fillMaxSize() else Modifier.fillMaxWidth()) { + val qrSize = if (dedicated) { + minOf(maxHeight - 40.dp, maxWidth * 0.36f, 240.dp).coerceAtLeast(152.dp) + } else { + 188.dp + } + Row( + (if (dedicated) Modifier.fillMaxSize() else Modifier.fillMaxWidth()) + .padding(if (dedicated) 18.dp else 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(if (dedicated) 20.dp else 12.dp), + ) { + if (connector.pairedDeviceName == null) { + qrCode?.let { code -> + Surface( + shape = RoundedCornerShape(18.dp), + color = Color.White, + border = BorderStroke(3.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.55f)), + ) { + QrCodeView(code, Modifier.size(qrSize)) + } + } + } + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(if (dedicated) 10.dp else 8.dp)) { + Text( + if (connector.pairedDeviceName == null) "Pair your phone" else "Phone connected", + color = TextPrimary, + style = if (dedicated) MaterialTheme.typography.headlineMedium else MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + Text( + if (connector.pairedDeviceName == null) { + stringResource(R.string.tv_pair_instructions) + } else { + "${connector.pairedDeviceName} can launch games. Approve trust below for settings, overlays, sessions, and account switching." + }, + color = TextMuted, + style = if (dedicated) MaterialTheme.typography.bodyMedium else MaterialTheme.typography.bodySmall, + maxLines = if (connector.pairedDeviceName == null) 4 else if (dedicated) 3 else 2, + overflow = TextOverflow.Ellipsis, + ) + if (connector.pairedDeviceName == null) { + PairingCodeDisplay(connector.pairingCode, compact = !dedicated) + } + if (connector.pairedDeviceName != null) { + SettingSwitch( + label = "Trust this phone", + checked = connector.pairedDeviceTrusted, + description = "Required before the phone can transfer an account or control TV settings and sessions.", + ) { trusted -> viewModel.setLocalTvDeviceTrusted(trusted) } + } + OutlinedButton(onClick = viewModel::stopLocalTvConnector) { + Text(if (connector.pairedDeviceName == null) "Cancel pairing" else "Disconnect phone") + } + } + } + } + } + } + connector.error?.let { Text(it, color = MaterialTheme.colorScheme.error) } +} + +@Composable +private fun PairingCodeDisplay(code: String?, compact: Boolean) { + val digits = code?.takeIf { it.length == 4 && it.all(Char::isDigit) } ?: "----" + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(stringResource(R.string.login_pairing_code), color = TextMuted, style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.Bold) + Row(horizontalArrangement = Arrangement.spacedBy(if (compact) 5.dp else 8.dp)) { + digits.forEach { digit -> + Surface( + modifier = Modifier.size(if (compact) 38.dp else 46.dp), + shape = RoundedCornerShape(12.dp), + color = Color.White.copy(alpha = 0.07f), + border = if (LocalAbsoluteCinemaEffects.current) { + BorderStroke(1.dp, LocalActiveSelectionColor.current) + } else { + null + }, + ) { + Box(contentAlignment = Alignment.Center) { + Text( + digit.toString(), + color = TextPrimary, + style = if (compact) MaterialTheme.typography.titleMedium else MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + ) + } + } + } + } + } +} + +@Composable +private fun TvDeviceLoginScreen(prompt: DeviceLoginPrompt, phase: String, onCancel: () -> Unit) { + BoxWithConstraints( + modifier = Modifier.fillMaxSize().padding(horizontal = 48.dp, vertical = 36.dp), + contentAlignment = Alignment.Center, + ) { + val landscape = maxWidth >= 720.dp + val qrMaxSize = minOf( + maxWidth * if (landscape) 0.28f else 0.68f, + maxHeight * if (landscape) 0.58f else 0.38f, + 340.dp, + ) + DeviceLoginPanel( + prompt = prompt, + phase = phase, + onCancel = onCancel, + modifier = Modifier.fillMaxWidth(if (landscape) 0.86f else 1f), + qrMaxSize = qrMaxSize, + preferLandscapeLayout = landscape, + focusCancelOnPrompt = false, + ) + } +} + +@Composable +internal fun DeviceLoginPanel( + prompt: DeviceLoginPrompt, + phase: String, + onCancel: () -> Unit, + modifier: Modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), + qrMaxSize: androidx.compose.ui.unit.Dp = 360.dp, + preferLandscapeLayout: Boolean = false, + focusCancelOnPrompt: Boolean = true, +) { + val context = LocalContext.current + val clipboardManager = LocalClipboardManager.current + val configuration = LocalConfiguration.current + val initialFocusRequester = remember { FocusRequester() } + val sideBySideLayout = shouldUseSideBySideDeviceLoginLayout( + orientation = configuration.orientation, + preferLandscapeLayout = preferLandscapeLayout, + availableWidthDp = configuration.screenWidthDp, + ) + val launchUrl = remember(prompt.verificationUriComplete, prompt.verificationUri) { + prompt.verificationUriComplete ?: prompt.verificationUri + } + val qrContent = launchUrl + var urlActionMessage by remember(launchUrl) { mutableStateOf(null) } + val qrCode = remember(qrContent, prompt.verificationUri) { + QrCode.encodeText(qrContent) ?: QrCode.encodeText(prompt.verificationUri) + } + val remainingSeconds by produceState(initialValue = secondsUntil(prompt.expiresAt), prompt.expiresAt) { + while (value > 0) { + delay(1000L) + value = secondsUntil(prompt.expiresAt) + } + } + LaunchedEffect(prompt.userCode, focusCancelOnPrompt) { + runCatching { initialFocusRequester.requestFocus() } + } + Card( + colors = CardDefaults.cardColors(containerColor = PanelAlt, contentColor = TextPrimary), + shape = RoundedCornerShape(14.dp), + modifier = modifier, + ) { + if (sideBySideLayout) { + Row( + Modifier.fillMaxWidth().padding(24.dp), + horizontalArrangement = Arrangement.spacedBy(24.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + DeviceLoginQr( + qrCode = qrCode, + qrMaxSize = qrMaxSize, + modifier = Modifier.weight(0.9f), + ) + DeviceLoginControls( + launchUrl = launchUrl, + prompt = prompt, + phase = phase, + remainingSeconds = remainingSeconds, + urlActionMessage = urlActionMessage, + onUrlActionMessage = { urlActionMessage = it }, + onCancel = onCancel, + focusRequester = initialFocusRequester, + focusCancel = focusCancelOnPrompt, + context = context, + clipboardManager = clipboardManager, + modifier = Modifier.weight(1.1f), + showTitle = true, + ) + } + } else { + Column( + Modifier.padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text(stringResource(R.string.login_tv_title), color = TextPrimary, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + DeviceLoginQr(qrCode = qrCode, qrMaxSize = qrMaxSize) + DeviceLoginControls( + launchUrl = launchUrl, + prompt = prompt, + phase = phase, + remainingSeconds = remainingSeconds, + urlActionMessage = urlActionMessage, + onUrlActionMessage = { urlActionMessage = it }, + onCancel = onCancel, + focusRequester = initialFocusRequester, + focusCancel = focusCancelOnPrompt, + context = context, + clipboardManager = clipboardManager, + showTitle = false, + ) + } + } + } +} + +internal fun shouldUseSideBySideDeviceLoginLayout( + orientation: Int, + preferLandscapeLayout: Boolean, + availableWidthDp: Int, +): Boolean = + preferLandscapeLayout || + (orientation == Configuration.ORIENTATION_LANDSCAPE && availableWidthDp >= DEVICE_LOGIN_SIDE_BY_SIDE_MIN_WIDTH_DP) + +@Composable +private fun DeviceLoginQr(qrCode: QrCode?, qrMaxSize: androidx.compose.ui.unit.Dp, modifier: Modifier = Modifier) { + qrCode?.let { + BoxWithConstraints( + modifier = modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + val qrDisplaySize = minOf(maxWidth * 0.92f, qrMaxSize) + QrCodeView(it, Modifier.size(qrDisplaySize)) + } + } +} + +@Composable +private fun DeviceLoginControls( + launchUrl: String, + prompt: DeviceLoginPrompt, + phase: String, + remainingSeconds: Int, + urlActionMessage: String?, + onUrlActionMessage: (String) -> Unit, + onCancel: () -> Unit, + focusRequester: FocusRequester, + focusCancel: Boolean, + context: android.content.Context, + clipboardManager: androidx.compose.ui.platform.ClipboardManager, + modifier: Modifier = Modifier, + showTitle: Boolean = true, +) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (showTitle) { + Text(stringResource(R.string.login_tv_title), color = TextPrimary, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + } + TextButton( + onClick = { + val opened = openExternalUrl(context, launchUrl) + if (opened) { + onUrlActionMessage("Opening sign-in URL") + } else { + clipboardManager.setText(AnnotatedString(launchUrl)) + onUrlActionMessage("URL copied") + } + }, + modifier = if (focusCancel) Modifier else Modifier.focusRequester(focusRequester), + ) { + Text( + launchUrl, + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.titleSmall, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + Text(prompt.userCode, style = MaterialTheme.typography.displaySmall, fontWeight = FontWeight.Bold, color = Color.White) + Text(stringResource(R.string.login_tv_status, phase.ifBlank { stringResource(R.string.login_tv_waiting) }), color = TextMuted) + urlActionMessage?.let { + Text(it, color = TextMuted, style = MaterialTheme.typography.bodySmall) + } + Text(stringResource(R.string.login_tv_expires, remainingSeconds / 60, remainingSeconds % 60), color = TextMuted) + OutlinedButton( + onClick = onCancel, + modifier = if (focusCancel) Modifier.focusRequester(focusRequester) else Modifier, + ) { + Text(stringResource(R.string.action_cancel)) + } + } +} + +internal fun openExternalUrl(context: android.content.Context, url: String): Boolean { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + return runCatching { + context.startActivity(intent) + true + }.getOrDefault(false) +} + +@Composable +internal fun QrCodeView(qrCode: QrCode, modifier: Modifier = Modifier) { + Canvas( + modifier + .clip(RoundedCornerShape(12.dp)) + .background(Color.White), + ) { + val quiet = 4 + val cells = qrCode.size + quiet * 2 + val cellSize = floor(min(size.width, size.height) / cells).coerceAtLeast(1f) + val qrSize = cellSize * cells + val originX = floor((size.width - qrSize) / 2f) + val originY = floor((size.height - qrSize) / 2f) + for (y in 0 until qrCode.size) { + for (x in 0 until qrCode.size) { + if (!qrCode.isDark(x, y)) continue + drawRect( + color = Color.Black, + topLeft = Offset(originX + (x + quiet) * cellSize, originY + (y + quiet) * cellSize), + size = Size(cellSize, cellSize), + ) + } + } + } +} + +private fun secondsUntil(deadlineMs: Long): Int = + ((deadlineMs - System.currentTimeMillis()).coerceAtLeast(0L) / 1000L).toInt() + +internal fun isPhoneLandscape(width: androidx.compose.ui.unit.Dp, height: androidx.compose.ui.unit.Dp): Boolean = + width > height && minOf(width, height) < PHONE_NAV_RAIL_MAX_SMALLEST_WIDTH + +private fun isPhonePortrait(width: androidx.compose.ui.unit.Dp, height: androidx.compose.ui.unit.Dp): Boolean = + height >= width && minOf(width, height) < PHONE_NAV_RAIL_MAX_SMALLEST_WIDTH + +@Composable +internal fun rememberPhysicalControllerConnected(enabled: Boolean): Boolean { + return rememberPhysicalControllerFamily(enabled) != null +} + +@Composable +internal fun rememberPhysicalControllerFamily(enabled: Boolean): AndroidControllerFamily? { + val context = LocalContext.current.applicationContext + var family by remember { mutableStateOf(connectedPhysicalControllerFamily().takeIf { enabled }) } + DisposableEffect(context, enabled) { + fun refresh() { + family = connectedPhysicalControllerFamily().takeIf { enabled } + } + refresh() + if (!enabled) { + onDispose {} + } else { + val inputManager = context.getSystemService(Context.INPUT_SERVICE) as? InputManager + val listener = object : InputManager.InputDeviceListener { + override fun onInputDeviceAdded(deviceId: Int) = refresh() + override fun onInputDeviceRemoved(deviceId: Int) = refresh() + override fun onInputDeviceChanged(deviceId: Int) = refresh() + } + inputManager?.registerInputDeviceListener(listener, null) + onDispose { + inputManager?.unregisterInputDeviceListener(listener) + } + } + } + return family +} + +private fun connectedPhysicalControllerFamily(): AndroidControllerFamily? { + val families = InputDevice.getDeviceIds() + .asSequence() + .mapNotNull { deviceId -> AndroidControllerInput.controllerFamily(InputDevice.getDevice(deviceId)) } + .toList() + return families.firstOrNull { it != AndroidControllerFamily.Generic } ?: families.firstOrNull() +} diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowQueueScreen.kt b/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowQueueScreen.kt new file mode 100644 index 000000000..52d85bac1 --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowQueueScreen.kt @@ -0,0 +1,1322 @@ +package com.opencloudgaming.opennow + +import android.os.SystemClock +import android.view.View +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.MutableTransitionState +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +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.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.DisposableEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.Shadow +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.ui.PlayerView +import kotlinx.coroutines.delay +import com.opencloudgaming.opennow.ui.theme.LocalReduceMotion +import com.opencloudgaming.opennow.ui.theme.OpenNowMotion +import kotlin.math.roundToInt +import kotlin.math.sin + +@Composable +internal fun QueueLoadingScreen(state: OpenNowUiState, viewModel: OpenNowViewModel) { + BackHandler( + enabled = canMinimizeStreamLaunch( + streamStatus = state.streamStatus, + sessionReady = state.streamSession?.isReadyForStream() == true, + ), + onBack = viewModel::minimizeStreamLaunch, + ) + val session = state.streamSession + val game = state.streamGame + val ads = sessionAdItems(session?.adState) + val ad = ads.firstOrNull { it.adId == state.queueAdActiveId } ?: ads.firstOrNull() + val mediaUrl = ad?.adMediaFiles?.firstOrNull { !it.mediaFileUrl.isNullOrBlank() }?.mediaFileUrl + ?: ad?.adUrl + ?: ad?.mediaUrl + val queuePosition = activeQueuePosition(state) + val visibleQueuePosition = rememberStableQueuePosition(queuePosition) + val queueCopy = queueLaunchStatusText(state, visibleQueuePosition) + val hasPlayableAd = ad != null && mediaUrl != null + val reduceMotion = LocalReduceMotion.current + val entranceState = remember(game?.id) { + MutableTransitionState(false).apply { targetState = true } + } + + BoxWithConstraints( + Modifier + .fillMaxSize() + .clipToBounds(), + contentAlignment = Alignment.Center, + ) { + QueueAmbientBackdrop( + accent = state.settings.uiAccent.color, + queuePosition = visibleQueuePosition, + ) + val useLandscapeAdLayout = hasPlayableAd && maxWidth > maxHeight + + Box( + Modifier + .fillMaxSize() + .padding(18.dp), + contentAlignment = Alignment.Center, + ) { + AnimatedVisibility( + visibleState = entranceState, + enter = fadeIn( + tween(if (reduceMotion) 0 else OpenNowMotion.DurationFast), + ) + scaleIn( + initialScale = if (reduceMotion) 1f else 0.965f, + animationSpec = tween( + if (reduceMotion) 0 else OpenNowMotion.DurationStandard, + easing = OpenNowMotion.EasingStandard, + ), + ) + slideInVertically( + initialOffsetY = { if (reduceMotion) 0 else it / 18 }, + animationSpec = tween( + if (reduceMotion) 0 else OpenNowMotion.DurationStandard, + easing = OpenNowMotion.EasingStandard, + ), + ), + ) { + if (ad != null && mediaUrl != null) { + QueueAdPanel( + ad = ad, + mediaUrl = mediaUrl, + viewModel = viewModel, + game = game, + queueCopy = queueCopy, + queuePosition = visibleQueuePosition, + error = state.error, + playbackKey = session?.sessionId.orEmpty(), + compact = useLandscapeAdLayout, + onMinimize = viewModel::minimizeStreamLaunch, + onCancel = viewModel::stopStream, + modifier = Modifier + .fillMaxWidth(if (useLandscapeAdLayout) 0.72f else 1f) + .widthIn(max = if (useLandscapeAdLayout) 900.dp else 620.dp), + ) + } else { + QueueStatusPanel( + game = game, + queueCopy = queueCopy, + queuePosition = visibleQueuePosition, + error = state.error, + compact = false, + onMinimize = viewModel::minimizeStreamLaunch, + onCancel = viewModel::stopStream, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + } +} + +@Composable +private fun QueueAmbientBackdrop( + accent: Color, + queuePosition: Int?, + modifier: Modifier = Modifier, +) { + val reduceMotion = LocalReduceMotion.current + val driftA: State + val driftB: State + val phase: State + val shimmer: State + val orbADim: State + val orbBDim: State + if (reduceMotion) { + driftA = remember { mutableFloatStateOf(0f) } + driftB = remember { mutableFloatStateOf(0f) } + phase = remember { mutableFloatStateOf(0f) } + shimmer = remember { mutableFloatStateOf(0f) } + orbADim = remember { mutableFloatStateOf(0.52f) } + orbBDim = remember { mutableFloatStateOf(0.4f) } + } else { + val transition = rememberInfiniteTransition(label = "queue-ambient") + driftA = transition.animateFloat( + initialValue = -1f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 11000, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "queue-ambient-drift-a", + ) + driftB = transition.animateFloat( + initialValue = 1f, + targetValue = -1f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 14000, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "queue-ambient-drift-b", + ) + phase = transition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 16000, easing = LinearEasing), + ), + label = "queue-ambient-phase", + ) + shimmer = transition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 5200, easing = LinearEasing), + ), + label = "queue-ambient-shimmer", + ) + orbADim = transition.animateFloat( + initialValue = 0.35f, + targetValue = 0.72f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 8200, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "queue-ambient-orb-a-dim", + ) + orbBDim = transition.animateFloat( + initialValue = 0.26f, + targetValue = 0.56f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 9800, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "queue-ambient-orb-b-dim", + ) + } + + BoxWithConstraints( + modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + listOf( + Color(0xff010203), + Color(0xff05080a), + Color(0xff020304), + ), + ), + ), + ) { + val baseSize = minOf(maxWidth, maxHeight) + val density = LocalDensity.current + val widthPx = with(density) { maxWidth.toPx() } + val heightPx = with(density) { maxHeight.toPx() } + QueueAmbientOrb( + color = accent, + size = baseSize * 0.92f, + modifier = Modifier + .align(Alignment.TopStart) + .graphicsLayer { + translationX = widthPx * (-0.22f + 0.10f * driftA.value) + translationY = heightPx * (0.02f + 0.08f * driftB.value) + alpha = orbADim.value.coerceIn(0f, 1f) + }, + ) + QueueAmbientOrb( + color = Color(0xff2bdcff), + size = baseSize * 0.7f, + modifier = Modifier + .align(Alignment.BottomEnd) + .graphicsLayer { + translationX = widthPx * (0.15f + 0.08f * driftB.value) + translationY = heightPx * (0.10f + 0.07f * driftA.value) + alpha = orbBDim.value.coerceIn(0f, 1f) + }, + ) + QueueSignalField( + accent = accent, + queuePosition = queuePosition, + phase = phase, + shimmer = shimmer, + modifier = Modifier.matchParentSize(), + ) + Box( + Modifier + .matchParentSize() + .background(Color.Black.copy(alpha = 0.34f)), + ) + } +} + +@Composable +private fun QueueAmbientOrb( + color: Color, + size: Dp, + modifier: Modifier = Modifier, +) { + Box( + modifier + .size(size) + .blur(64.dp) + .background( + Brush.radialGradient( + listOf( + color.copy(alpha = 0.58f), + color.copy(alpha = 0.16f), + Color.Transparent, + ), + ), + CircleShape, + ), + ) +} + +@Composable +private fun QueueSignalField( + accent: Color, + queuePosition: Int?, + phase: State, + shimmer: State, + modifier: Modifier = Modifier, +) { + val heat = queueUrgency(queuePosition) + Canvas(modifier) { + val phaseValue = phase.value + val shimmerValue = shimmer.value + val lineCount = 9 + val spacing = size.height / lineCount + val offset = shimmerValue * spacing + for (index in -1..lineCount) { + val y = index * spacing + offset + drawLine( + color = accent.copy(alpha = 0.035f + heat * 0.035f), + start = Offset(-size.width * 0.12f, y), + end = Offset(size.width * 1.08f, y - size.height * 0.10f), + strokeWidth = 1.dp.toPx(), + ) + } + repeat(12) { index -> + val lane = index + 1 + val x = ((lane * 0.173f + phaseValue * (0.08f + lane * 0.006f)) % 1f) * size.width + val y = ((lane * 0.291f + shimmerValue * (0.12f + lane * 0.004f)) % 1f) * size.height + drawCircle( + color = accent.copy(alpha = 0.05f + heat * 0.04f), + radius = (1.5f + (index % 4)) * density, + center = Offset(x, y), + ) + } + } +} + +@Composable +private fun AnimatedQueueStatusText( + queueCopy: String, + queuePosition: Int?, + compact: Boolean, + modifier: Modifier = Modifier, +) { + if (queuePosition == null) { + Text( + queueCopy, + modifier = modifier, + color = queueIdleStatusColor(queueCopy), + style = (if (compact) MaterialTheme.typography.bodyLarge else MaterialTheme.typography.titleMedium) + .copy(fontWeight = FontWeight.Normal), + textAlign = TextAlign.Center, + ) + return + } + + var previousQueuePosition by remember { mutableStateOf(null) } + val numberProgress = remember { Animatable(1f) } + var numberTrigger by remember { mutableStateOf(0) } + var numberFrom by remember { mutableStateOf(queuePosition.toString()) } + var numberTo by remember { mutableStateOf(queuePosition.toString()) } + val heat = queueUrgency(queuePosition) + val hotQueue = queuePosition < 10 + val reduceMotion = LocalReduceMotion.current + val glow: State + val moleculePhase: State + if (reduceMotion) { + glow = remember { mutableFloatStateOf(0.72f) } + moleculePhase = remember { mutableFloatStateOf(0f) } + } else { + val transition = rememberInfiniteTransition(label = "queue-status-glow") + glow = transition.animateFloat( + initialValue = 0.55f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = if (hotQueue) 520 else 1100, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "queue-status-glow-alpha", + ) + moleculePhase = transition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween( + durationMillis = (190 - heat * 95).roundToInt().coerceIn(92, 190), + easing = LinearEasing, + ), + ), + label = "queue-status-molecule-phase", + ) + } + val statusColor by animateColorAsState( + targetValue = queueUrgencyColor(queuePosition), + animationSpec = tween(durationMillis = 240), + label = "queue-status-color", + ) + + LaunchedEffect(queuePosition) { + val current = queuePosition + val previous = previousQueuePosition + if (previous != null && current < previous) { + numberFrom = previous.toString() + numberTo = current.toString() + numberTrigger += 1 + } else { + numberFrom = current.toString() + numberTo = current.toString() + } + previousQueuePosition = current + } + + LaunchedEffect(numberTrigger) { + if (numberTrigger == 0) return@LaunchedEffect + numberProgress.snapTo(0f) + numberProgress.animateTo( + targetValue = 1f, + animationSpec = tween(durationMillis = if (hotQueue) 320 else 420), + ) + } + + val moleculeCagePx = with(LocalDensity.current) { + (if (hotQueue) (0.45f + heat * 1.45f).dp else 0.dp).toPx() + } + val parts = queueStatusParts(queueCopy, queuePosition) + val textStyle = (if (compact) MaterialTheme.typography.bodyLarge else MaterialTheme.typography.titleMedium) + .copy(fontWeight = FontWeight.Normal) + val numberAnimating = numberTrigger > 0 && numberFrom != numberTo + val numberTravelPx = with(LocalDensity.current) { (if (compact) 18.dp else 22.dp).toPx() } + + Row( + modifier = modifier, + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + parts.prefix, + color = TextMuted, + style = textStyle, + textAlign = TextAlign.Center, + ) + AnimatedQueueNumber( + currentNumber = parts.number, + previousNumber = numberFrom, + targetNumber = numberTo, + animating = numberAnimating, + phaseProvider = { numberProgress.value }, + travelPx = numberTravelPx, + color = statusColor, + style = textStyle.copy( + shadow = Shadow( + color = statusColor.copy(alpha = heat * 0.68f), + offset = Offset(0f, 0f), + blurRadius = 18f + heat * 14f, + ), + ), + glow = glow, + moleculePhase = moleculePhase, + moleculeCagePx = moleculeCagePx, + heat = heat, + ) + Text( + parts.suffix, + color = TextMuted, + style = textStyle, + textAlign = TextAlign.Center, + ) + } +} + +@Composable +private fun AnimatedQueueNumber( + currentNumber: String, + previousNumber: String, + targetNumber: String, + animating: Boolean, + phaseProvider: () -> Float, + travelPx: Float, + color: Color, + style: TextStyle, + glow: State, + moleculePhase: State, + moleculeCagePx: Float, + heat: Float, +) { + val fromNumber = if (animating) previousNumber else currentNumber + val toNumber = if (animating) targetNumber else currentNumber + val slotCount = toNumber.length + + Row( + modifier = Modifier + .clipToBounds() + .graphicsLayer { + val phase = moleculePhase.value + translationX = if (moleculeCagePx > 0f) { + (sin(phase * 31.415928f) * 0.64f + sin(phase * 106.81416f) * 0.36f) * moleculeCagePx + } else { + 0f + } + translationY = if (moleculeCagePx > 0f) { + (sin(phase * 43.982296f) * 0.55f + sin(phase * 81.68141f) * 0.45f) * moleculeCagePx * 0.55f + } else { + 0f + } + val pulse = 1f + heat * 0.012f * glow.value + scaleX = pulse + scaleY = pulse + }, + verticalAlignment = Alignment.CenterVertically, + ) { + repeat(slotCount) { slotIndex -> + val fromDigit = fromNumber.rightAlignedCharAt(slotIndex, slotCount) + val toDigit = toNumber.rightAlignedCharAt(slotIndex, slotCount) + QueueNumberDigitSlot( + fromDigit = fromDigit, + toDigit = toDigit, + digitChanged = animating && fromDigit != toDigit, + phaseProvider = phaseProvider, + travelPx = travelPx, + color = color, + style = style, + ) + } + } +} + +@Composable +private fun QueueNumberDigitSlot( + fromDigit: Char?, + toDigit: Char?, + digitChanged: Boolean, + phaseProvider: () -> Float, + travelPx: Float, + color: Color, + style: TextStyle, +) { + val from = fromDigit?.toString().orEmpty() + val to = toDigit?.toString().orEmpty() + Box( + modifier = Modifier.clipToBounds(), + contentAlignment = Alignment.Center, + ) { + if (from.isNotEmpty()) { + Text( + from, + modifier = Modifier.graphicsLayer(alpha = 0f), + color = color, + style = style, + textAlign = TextAlign.Center, + ) + } + if (to.isNotEmpty() && to != from) { + Text( + to, + modifier = Modifier.graphicsLayer(alpha = 0f), + color = color, + style = style, + textAlign = TextAlign.Center, + ) + } + if (digitChanged) { + if (from.isNotEmpty()) { + Text( + from, + modifier = Modifier.graphicsLayer { + val phase = phaseProvider() + translationY = -travelPx * phase + scaleX = 1f - phase * 0.03f + scaleY = 1f - phase * 0.03f + alpha = 1f - phase + }, + color = color, + style = style, + textAlign = TextAlign.Center, + ) + } + if (to.isNotEmpty()) { + Text( + to, + modifier = Modifier.graphicsLayer { + val phase = phaseProvider() + translationY = travelPx * (1f - phase) + scaleX = 0.97f + phase * 0.03f + scaleY = 0.97f + phase * 0.03f + alpha = phase + }, + color = color, + style = style, + textAlign = TextAlign.Center, + ) + } + } else if (to.isNotEmpty()) { + Text( + to, + color = color, + style = style, + textAlign = TextAlign.Center, + ) + } + } +} + +private fun String.rightAlignedCharAt(slotIndex: Int, slotCount: Int): Char? = + getOrNull(length - slotCount + slotIndex) + +private data class QueueStatusParts( + val prefix: String, + val number: String, + val suffix: String, +) + +private fun queueStatusParts(queueCopy: String, queuePosition: Int): QueueStatusParts { + val number = queuePosition.toString() + val index = queueCopy.indexOf(number) + if (index < 0) { + return QueueStatusParts(prefix = "$queueCopy ", number = number, suffix = "") + } + return QueueStatusParts( + prefix = queueCopy.substring(0, index), + number = number, + suffix = queueCopy.substring(index + number.length), + ) +} + +private fun queueUrgency(queuePosition: Int?): Float { + val position = queuePosition ?: return 0f + if (position >= 10) return 0f + return ((10 - position).toFloat() / 9f).coerceIn(0f, 1f) +} + +private fun activeQueuePosition(state: OpenNowUiState): Int? = + queueDisplayPosition(state) + +@Composable +private fun rememberStableQueuePosition(queuePosition: Int?): Int? { + var stableQueuePosition by remember { mutableStateOf(queuePosition) } + LaunchedEffect(queuePosition) { + if (queuePosition == stableQueuePosition) return@LaunchedEffect + if (queuePosition == null || stableQueuePosition == null) { + stableQueuePosition = queuePosition + return@LaunchedEffect + } + delay(QUEUE_POSITION_VISUAL_SETTLE_MS) + stableQueuePosition = queuePosition + } + return stableQueuePosition +} + +@Composable +private fun queueLaunchStatusText(state: OpenNowUiState, queuePosition: Int?): String { + val status = queueLaunchStatus(state, queuePosition) + return when (status.kind) { + QueueLaunchStatusKind.QueuePosition -> stringResource(R.string.queue_position, requireNotNull(status.queuePosition)) + QueueLaunchStatusKind.WaitingForRig -> stringResource(R.string.queue_waiting_for_rig) + QueueLaunchStatusKind.ConnectingStream -> stringResource(R.string.queue_connecting_stream) + QueueLaunchStatusKind.ResumingSession -> stringResource(R.string.queue_resuming_session) + QueueLaunchStatusKind.SettingUpRig -> stringResource(R.string.queue_setting_up_rig) + QueueLaunchStatusKind.StartingSession -> stringResource(R.string.queue_starting_session) + } +} + +@Composable +private fun queueIdleStatusColor(queueCopy: String): Color = + if (queueCopy == stringResource(R.string.queue_starting_session)) Green else TextMuted + +private fun queueUrgencyColor(queuePosition: Int?): Color { + val heat = queueUrgency(queuePosition) + if (heat <= 0f) return TextMuted + val green = (0.57f - 0.49f * heat).coerceIn(0.06f, 0.57f) + val blue = (0.25f - 0.17f * heat).coerceIn(0.08f, 0.25f) + return Color(red = 1f, green = green, blue = blue, alpha = 1f) +} + +@Composable +private fun QueueStatusPanel( + game: GameInfo?, + queueCopy: String, + queuePosition: Int?, + error: String?, + compact: Boolean, + onMinimize: () -> Unit, + onCancel: () -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + Column( + modifier, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + val imageWidth = if (compact) 154.dp else 220.dp + UrlImage( + gameTvBannerImageUrl(context, game), + Modifier + .width(imageWidth) + .aspectRatio(16f / 9f) + .clip(RoundedCornerShape(14.dp)), + ) + Spacer(Modifier.height(if (compact) 12.dp else 16.dp)) + Text( + game?.title ?: stringResource(R.string.queue_starting_stream), + color = TextPrimary, + style = if (compact) MaterialTheme.typography.titleLarge else MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + ) + AnimatedQueueStatusText( + queueCopy = queueCopy, + queuePosition = queuePosition, + compact = compact, + ) + Spacer(Modifier.height(if (compact) 14.dp else 18.dp)) + LinearProgressIndicator(Modifier.fillMaxWidth(if (compact) 0.9f else 0.7f)) + Spacer(Modifier.height(12.dp)) + Row( + Modifier.fillMaxWidth(if (compact) 0.92f else 0.7f), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + OutlinedButton(onClick = onMinimize, modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.action_minimize), maxLines = 1, overflow = TextOverflow.Ellipsis) + } + OutlinedButton(onClick = onCancel, modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.action_cancel), maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + if (compact && queuePosition != null) { + Spacer(Modifier.height(14.dp)) + LandscapeQueuePositionDock(queuePosition = queuePosition) + } + error?.let { + Spacer(Modifier.height(12.dp)) + Text(it, color = Color(0xffff9f9f), textAlign = TextAlign.Center) + } + } +} + +@Composable +private fun LandscapeQueuePositionDock(queuePosition: Int, modifier: Modifier = Modifier) { + val accent = queueUrgencyColor(queuePosition) + val heat = queueUrgency(queuePosition) + val shape = RoundedCornerShape(16.dp) + Box( + modifier + .fillMaxWidth(0.92f) + .clip(shape) + .background( + Brush.horizontalGradient( + listOf( + accent.copy(alpha = 0.18f + heat * 0.16f), + PanelAlt.copy(alpha = 0.94f), + Color.Black.copy(alpha = 0.36f), + ), + ), + ) + .border(1.dp, accent.copy(alpha = 0.32f + heat * 0.36f), shape) + .padding(horizontal = 16.dp, vertical = 12.dp), + ) { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + stringResource(R.string.queue_title), + color = TextMuted, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + stringResource(R.string.queue_live_position), + color = TextMuted.copy(alpha = 0.78f), + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Text( + queuePosition.toString(), + color = accent, + style = MaterialTheme.typography.displaySmall.copy( + fontWeight = FontWeight.Black, + shadow = Shadow( + color = accent.copy(alpha = 0.24f + heat * 0.42f), + offset = Offset(0f, 0f), + blurRadius = 18f + heat * 14f, + ), + ), + maxLines = 1, + textAlign = TextAlign.End, + ) + } + } +} + +@Composable +private fun QueueAdPanel( + ad: SessionAdInfo, + mediaUrl: String, + viewModel: OpenNowViewModel, + game: GameInfo?, + queueCopy: String, + queuePosition: Int?, + error: String?, + playbackKey: String, + compact: Boolean, + onMinimize: () -> Unit, + onCancel: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier, + shape = RoundedCornerShape(18.dp), + color = Panel.copy(alpha = 0.95f), + tonalElevation = 8.dp, + ) { + if (compact) { + Row( + Modifier.padding(14.dp), + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + QueueAdPlayback( + ad = ad, + mediaUrl = mediaUrl, + playbackKey = playbackKey, + viewModel = viewModel, + modifier = Modifier + .weight(1.55f) + .aspectRatio(16f / 9f), + ) + Column( + Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + QueueAdHeading(game = game, compact = true) + QueueStatusAndActions( + queueCopy = queueCopy, + queuePosition = queuePosition, + compact = true, + stackActions = true, + onMinimize = onMinimize, + onCancel = onCancel, + ) + error?.let { + Text(it, color = Color(0xffff9f9f), textAlign = TextAlign.Center) + } + } + } + } else { + Column( + Modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + QueueAdHeading(game = game, compact = false) + QueueAdPlayback( + ad = ad, + mediaUrl = mediaUrl, + playbackKey = playbackKey, + viewModel = viewModel, + modifier = Modifier + .fillMaxWidth() + .height(220.dp), + ) + QueueStatusAndActions( + queueCopy = queueCopy, + queuePosition = queuePosition, + compact = false, + stackActions = false, + onMinimize = onMinimize, + onCancel = onCancel, + ) + error?.let { + Text(it, color = Color(0xffff9f9f), textAlign = TextAlign.Center) + } + } + } + } +} + +@Composable +private fun QueueAdPlayback( + ad: SessionAdInfo, + mediaUrl: String, + playbackKey: String, + viewModel: OpenNowViewModel, + modifier: Modifier = Modifier, +) { + QueueAdPlayer( + adId = ad.adId, + url = mediaUrl, + playbackKey = playbackKey, + modifier = modifier, + onStarted = { viewModel.reportQueueAd(ad.adId, "start") }, + onPaused = { viewModel.reportQueueAd(ad.adId, "pause") }, + onResumed = { viewModel.reportQueueAd(ad.adId, "resume") }, + onFinished = { watchedTimeInMs -> + viewModel.reportQueueAd(ad.adId, "finish", watchedTimeInMs = watchedTimeInMs) + }, + onError = { watchedTimeInMs, errorInfo -> + viewModel.reportQueueAd( + ad.adId, + "cancel", + watchedTimeInMs = watchedTimeInMs, + cancelReason = "error", + errorInfo = errorInfo, + ) + }, + ) +} + +@Composable +private fun QueueAdHeading(game: GameInfo?, compact: Boolean) { + Column(Modifier.fillMaxWidth()) { + Text( + stringResource(R.string.queue_advertisement), + color = TextMuted, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + maxLines = 1, + ) + Text( + game?.title ?: stringResource(R.string.queue_starting_stream), + color = TextPrimary, + style = if (compact) MaterialTheme.typography.titleMedium else MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun QueueStatusAndActions( + queueCopy: String, + queuePosition: Int?, + compact: Boolean, + stackActions: Boolean, + onMinimize: () -> Unit, + onCancel: () -> Unit, +) { + Column( + Modifier.fillMaxWidth(if (compact) 1f else 0.7f), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(if (compact) 8.dp else 10.dp), + ) { + AnimatedQueueStatusText( + queueCopy = queueCopy, + queuePosition = queuePosition, + compact = compact, + ) + LinearProgressIndicator(Modifier.fillMaxWidth()) + if (stackActions) { + OutlinedButton(onClick = onMinimize, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.action_minimize), maxLines = 1, overflow = TextOverflow.Ellipsis) + } + OutlinedButton(onClick = onCancel, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.action_cancel), maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } else { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + OutlinedButton(onClick = onMinimize, modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.action_minimize), maxLines = 1, overflow = TextOverflow.Ellipsis) + } + OutlinedButton(onClick = onCancel, modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.action_cancel), maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } + } +} + +@Composable +internal fun MinimizedQueueDock( + state: OpenNowUiState, + onRestore: () -> Unit, + onCancel: () -> Unit, + modifier: Modifier = Modifier, +) { + val queuePosition = activeQueuePosition(state) + val visibleQueuePosition = rememberStableQueuePosition(queuePosition) + val queueCopy = queueLaunchStatusText(state, visibleQueuePosition) + Surface( + modifier = modifier + .fillMaxWidth(), + shape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp), + color = Panel.copy(alpha = 0.98f), + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Row( + Modifier.padding(horizontal = 14.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + CircularProgressIndicator(Modifier.size(24.dp), strokeWidth = 2.dp, color = MaterialTheme.colorScheme.primary) + Column(Modifier.weight(1f)) { + Text( + state.streamGame?.title ?: stringResource(R.string.queue_starting_stream), + color = TextPrimary, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + MinimizedQueueStatusText( + queueCopy = queueCopy, + queuePosition = visibleQueuePosition, + ) + } + TextButton(onClick = onRestore) { Text(stringResource(R.string.action_view)) } + OutlinedButton(onClick = onCancel, contentPadding = PaddingValues(horizontal = 10.dp, vertical = 6.dp)) { + Text(stringResource(R.string.action_cancel)) + } + } + } +} + +@Composable +private fun MinimizedQueueStatusText( + queueCopy: String, + queuePosition: Int?, +) { + if (queuePosition == null) { + Text(queueCopy, color = queueIdleStatusColor(queueCopy), style = MaterialTheme.typography.bodySmall) + return + } + val parts = queueStatusParts(queueCopy, queuePosition) + Row(verticalAlignment = Alignment.CenterVertically) { + Text(parts.prefix, color = TextMuted, style = MaterialTheme.typography.bodySmall) + Text( + parts.number, + color = queueUrgencyColor(queuePosition), + style = MaterialTheme.typography.bodySmall, + ) + Text(parts.suffix, color = TextMuted, style = MaterialTheme.typography.bodySmall) + } +} + +private const val QUEUE_AD_FORCE_PLAY_TIMEOUT_MS = 10_000L +private const val QUEUE_AD_START_TIMEOUT_MS = 30_000L +private const val QUEUE_AD_STUCK_TIMEOUT_MS = 30_000L +private const val QUEUE_AD_PROGRESS_CHECK_INTERVAL_MS = 1_000L + +@Composable +private fun QueueAdPlayer( + adId: String, + url: String, + playbackKey: String, + modifier: Modifier = Modifier, + onStarted: () -> Unit, + onPaused: () -> Unit, + onResumed: () -> Unit, + onFinished: (watchedTimeInMs: Long) -> Unit, + onError: (watchedTimeInMs: Long, errorInfo: String) -> Unit, +) { + val context = LocalContext.current + var muted by remember { mutableStateOf(false) } + val player = remember(adId, url, playbackKey) { + ExoPlayer.Builder(context).build().apply { + setMediaItem(MediaItem.fromUri(url)) + volume = if (muted) 0f else 1f + prepare() + playWhenReady = true + } + } + var reportedStart by remember(adId, url, playbackKey) { mutableStateOf(false) } + var reportedFinish by remember(adId, url, playbackKey) { mutableStateOf(false) } + var reportedPause by remember(adId, url, playbackKey) { mutableStateOf(false) } + var playing by remember(adId, url, playbackKey) { mutableStateOf(player.playWhenReady) } + var controlsVisible by remember(adId, url, playbackKey) { mutableStateOf(false) } + LaunchedEffect(player) { + val loadStartedAtMs = SystemClock.elapsedRealtime() + var forcePlayAttempted = false + var playbackObserved = false + var lastPositionMs = player.currentPosition.coerceAtLeast(0L) + var lastProgressAtMs = loadStartedAtMs + while (!reportedFinish) { + delay(QUEUE_AD_PROGRESS_CHECK_INTERVAL_MS) + val nowMs = SystemClock.elapsedRealtime() + if (!reportedStart) { + if (!forcePlayAttempted && nowMs - loadStartedAtMs >= QUEUE_AD_FORCE_PLAY_TIMEOUT_MS) { + forcePlayAttempted = true + player.play() + } + if (nowMs - loadStartedAtMs >= QUEUE_AD_START_TIMEOUT_MS) { + reportedFinish = true + onError(player.currentPosition.coerceAtLeast(0L), "Ad play timeout") + break + } + continue + } + + if (!playbackObserved) { + playbackObserved = true + lastPositionMs = player.currentPosition.coerceAtLeast(0L) + lastProgressAtMs = nowMs + } + if (!player.playWhenReady || player.playbackState == Player.STATE_ENDED) { + lastPositionMs = player.currentPosition.coerceAtLeast(0L) + lastProgressAtMs = nowMs + continue + } + + val positionMs = player.currentPosition.coerceAtLeast(0L) + if (positionMs > lastPositionMs) { + lastPositionMs = positionMs + lastProgressAtMs = nowMs + } else if (nowMs - lastProgressAtMs >= QUEUE_AD_STUCK_TIMEOUT_MS) { + reportedFinish = true + onError(positionMs, "Ad video is stuck") + break + } + } + } + LaunchedEffect(controlsVisible, playing) { + if (controlsVisible && playing) { + delay(2400L) + controlsVisible = false + } + } + DisposableEffect(player) { + val listener = object : Player.Listener { + override fun onIsPlayingChanged(isPlaying: Boolean) { + playing = isPlaying + if (!isPlaying) controlsVisible = true + if (isPlaying && !reportedStart && !reportedFinish) { + reportedStart = true + onStarted() + } + } + + override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) { + if (!reportedStart || reportedFinish) return + if (playWhenReady && reportedPause) { + reportedPause = false + onResumed() + } else if (!playWhenReady && player.playbackState != Player.STATE_ENDED && !reportedPause) { + reportedPause = true + onPaused() + } + } + + override fun onPlaybackStateChanged(playbackState: Int) { + if (playbackState == Player.STATE_ENDED && !reportedFinish) { + reportedFinish = true + onFinished(player.currentPosition.coerceAtLeast(0L)) + } + } + + override fun onPlayerError(error: androidx.media3.common.PlaybackException) { + if (!reportedFinish) { + reportedFinish = true + onError(player.currentPosition.coerceAtLeast(0L), "Error loading url") + } + } + } + player.addListener(listener) + listener.onIsPlayingChanged(player.isPlaying) + listener.onPlaybackStateChanged(player.playbackState) + onDispose { + player.removeListener(listener) + player.release() + } + } + Box( + modifier = modifier + .clip(RoundedCornerShape(8.dp)) + .clickable { controlsVisible = true }, + ) { + AndroidView( + modifier = Modifier + .fillMaxSize(), + factory = { ctx -> PlayerView(ctx).apply { this.player = player; useController = false } }, + update = { it.player = player; it.useController = false }, + ) + AnimatedVisibility( + visible = controlsVisible || !playing, + enter = fadeIn(), + exit = fadeOut(), + modifier = Modifier.align(Alignment.BottomCenter), + ) { + Row( + modifier = Modifier + .padding(bottom = 12.dp) + .clip(RoundedCornerShape(999.dp)) + .background(Color.Black.copy(alpha = 0.58f)) + .padding(horizontal = 8.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + QueueAdIconButton( + label = if (playing) "Pause ad" else "Play ad", + icon = if (playing) QueueAdControlIcon.Pause else QueueAdControlIcon.Play, + onClick = { + controlsVisible = true + if (playing) { + player.pause() + playing = false + } else { + player.play() + playing = true + } + }, + ) + QueueAdIconButton( + label = if (muted) "Unmute ad" else "Mute ad", + icon = if (muted) QueueAdControlIcon.Muted else QueueAdControlIcon.Volume, + onClick = { + controlsVisible = true + muted = !muted + player.volume = if (muted) 0f else 1f + }, + ) + } + } + } +} + +private enum class QueueAdControlIcon { Play, Pause, Volume, Muted } + +@Composable +private fun QueueAdIconButton(label: String, icon: QueueAdControlIcon, onClick: () -> Unit) { + IconButton( + onClick = onClick, + modifier = Modifier + .size(42.dp) + .semantics { contentDescription = label }, + ) { + QueueAdControlIconView(icon = icon, modifier = Modifier.size(22.dp)) + } +} + +@Composable +private fun QueueAdControlIconView(icon: QueueAdControlIcon, modifier: Modifier = Modifier) { + Canvas(modifier) { + val w = size.width + val h = size.height + when (icon) { + QueueAdControlIcon.Play -> { + val path = Path().apply { + moveTo(w * 0.35f, h * 0.24f) + lineTo(w * 0.35f, h * 0.76f) + lineTo(w * 0.76f, h * 0.5f) + close() + } + drawPath(path, Color.White) + } + QueueAdControlIcon.Pause -> { + drawRoundRect(Color.White, Offset(w * 0.28f, h * 0.24f), Size(w * 0.14f, h * 0.52f), CornerRadius(w * 0.04f, w * 0.04f)) + drawRoundRect(Color.White, Offset(w * 0.58f, h * 0.24f), Size(w * 0.14f, h * 0.52f), CornerRadius(w * 0.04f, w * 0.04f)) + } + QueueAdControlIcon.Volume, QueueAdControlIcon.Muted -> { + val body = Path().apply { + moveTo(w * 0.18f, h * 0.42f) + lineTo(w * 0.34f, h * 0.42f) + lineTo(w * 0.52f, h * 0.26f) + lineTo(w * 0.52f, h * 0.74f) + lineTo(w * 0.34f, h * 0.58f) + lineTo(w * 0.18f, h * 0.58f) + close() + } + drawPath(body, Color.White) + if (icon == QueueAdControlIcon.Volume) { + drawLine(Color.White, Offset(w * 0.62f, h * 0.38f), Offset(w * 0.72f, h * 0.5f), strokeWidth = w * 0.08f) + drawLine(Color.White, Offset(w * 0.72f, h * 0.5f), Offset(w * 0.62f, h * 0.62f), strokeWidth = w * 0.08f) + } else { + drawLine(Color.White, Offset(w * 0.64f, h * 0.36f), Offset(w * 0.84f, h * 0.64f), strokeWidth = w * 0.08f) + drawLine(Color.White, Offset(w * 0.84f, h * 0.36f), Offset(w * 0.64f, h * 0.64f), strokeWidth = w * 0.08f) + } + } + } + } +} diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowScreens.kt b/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowScreens.kt new file mode 100644 index 000000000..9c43a7ba5 --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowScreens.kt @@ -0,0 +1,2203 @@ +package com.opencloudgaming.opennow + +import android.Manifest +import android.app.Activity +import android.content.Context +import android.content.pm.PackageManager +import android.content.res.Configuration +import android.content.Intent +import android.hardware.input.InputManager +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.net.Uri +import android.os.Build +import android.os.SystemClock +import android.provider.Settings +import androidx.annotation.StringRes +import android.speech.RecognizerIntent +import android.view.InputDevice +import android.view.KeyEvent +import android.view.MotionEvent +import android.view.PointerIcon +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.MutableTransitionState +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.focusable +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.draggable +import androidx.compose.foundation.gestures.rememberDraggableState +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +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.ime +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items as gridItems +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.AssistChip +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ElevatedButton +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Cast +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.Keyboard +import androidx.compose.material.icons.rounded.KeyboardArrowDown +import androidx.compose.material.icons.rounded.KeyboardArrowUp +import androidx.compose.material.icons.rounded.Refresh +import androidx.compose.material3.IconButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.minimumInteractiveComponentSize +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.NavigationBarItemDefaults +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.ScaffoldDefaults +import androidx.compose.material3.Slider +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.darkColorScheme +import androidx.compose.material.icons.automirrored.rounded.BatteryUnknown +import androidx.compose.material.icons.rounded.Battery0Bar +import androidx.compose.material.icons.rounded.Battery1Bar +import androidx.compose.material.icons.rounded.Battery2Bar +import androidx.compose.material.icons.rounded.Battery3Bar +import androidx.compose.material.icons.rounded.Battery4Bar +import androidx.compose.material.icons.rounded.Battery5Bar +import androidx.compose.material.icons.rounded.Battery6Bar +import androidx.compose.material.icons.rounded.BatteryFull +import androidx.compose.material.icons.rounded.Bolt +import androidx.compose.material.icons.rounded.SignalCellular0Bar +import androidx.compose.material.icons.rounded.SignalCellular4Bar +import androidx.compose.material.icons.rounded.SignalCellularAlt +import androidx.compose.material.icons.rounded.SignalCellularAlt1Bar +import androidx.compose.material.icons.rounded.SignalCellularAlt2Bar +import androidx.compose.material.icons.rounded.SignalWifi0Bar +import androidx.compose.material.icons.rounded.Wifi +import androidx.compose.material.icons.rounded.Wifi1Bar +import androidx.compose.material.icons.rounded.Wifi2Bar +import androidx.compose.material.icons.rounded.WifiOff +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.key +import androidx.compose.runtime.setValue +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.MutableIntState +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.zIndex +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.focus.FocusManager +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.Shadow +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.pointerInteropFilter +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.layout.layout +import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.viewinterop.AndroidView +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.core.content.ContextCompat +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.ui.PlayerView +import coil3.compose.AsyncImage +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.io.File +import java.text.DateFormat +import java.util.Date +import java.util.Locale +import kotlin.math.min +import kotlin.math.floor +import com.opencloudgaming.opennow.ui.controls.ControlActionRow +import com.opencloudgaming.opennow.ui.controls.ControlNavigationRow +import com.opencloudgaming.opennow.ui.controls.ControlRowStyle +import com.opencloudgaming.opennow.ui.controls.ControlSection +import com.opencloudgaming.opennow.ui.controls.ControlSectionStyle +import com.opencloudgaming.opennow.ui.controls.ControlSliderRow +import com.opencloudgaming.opennow.ui.controls.ControlSwitchRow +import com.opencloudgaming.opennow.ui.controls.LocalControlRowStyle +import com.opencloudgaming.opennow.ui.controls.LocalControlSectionStyle +import com.opencloudgaming.opennow.ui.theme.LocalReduceMotion +import com.opencloudgaming.opennow.ui.theme.OpenNowMotion +import com.opencloudgaming.opennow.ui.theme.OpenNowPalette +import com.opencloudgaming.opennow.ui.theme.OpenNowRadius +import com.opencloudgaming.opennow.ui.theme.OpenNowShapes +import com.opencloudgaming.opennow.ui.theme.OpenNowSpacing +import com.opencloudgaming.opennow.ui.theme.OpenNowTypography +import com.opencloudgaming.opennow.ui.theme.numeric +import com.opencloudgaming.opennow.ui.theme.tint +import kotlin.math.roundToInt +import kotlin.math.sin +import kotlin.math.sqrt + +// Aliases onto the shared token layer. The names stay so existing call sites keep working; the +// values now live in exactly one place instead of being duplicated across two files. +internal val Green = OpenNowPalette.AccentDefault +internal val Background = OpenNowPalette.Background +internal val Panel = OpenNowPalette.Panel +internal val ProfileMenuContainerColor = Panel +internal val PanelAlt = OpenNowPalette.PanelAlt +internal val TextPrimary = OpenNowPalette.TextPrimary +internal val TextMuted = OpenNowPalette.TextMuted +private val ChromeScrim = OpenNowPalette.ChromeScrim +internal val TopBarCompactControlHeight = 30.dp +internal const val DEVICE_LOGIN_SIDE_BY_SIDE_MIN_WIDTH_DP = 520 +internal const val COMPACT_STREAM_DEVICE_STATUS_REFRESH_MS = 5_000L +internal const val QUEUE_POSITION_VISUAL_SETTLE_MS = 1100L +internal const val ACTIVE_STREAM_MODE_NOTICE_DURATION_MS = 8_000L +internal const val STREAM_NETWORK_NOTICE_DURATION_MS = 12_000L +internal val UiAccent.color: Color + get() = when (this) { + UiAccent.OpenNow -> OpenNowPalette.AccentDefault + UiAccent.Pixel -> OpenNowPalette.AccentPixel + UiAccent.HotPink -> OpenNowPalette.AccentHotPink + UiAccent.Lime -> OpenNowPalette.AccentLime + UiAccent.Coral -> OpenNowPalette.AccentCoral + UiAccent.Violet -> OpenNowPalette.AccentViolet + UiAccent.LegacyOrange -> OpenNowPalette.AccentViolet + UiAccent.AbsoluteCinema -> Color.White + UiAccent.Switch -> OpenNowPalette.AccentSwitchRed + } + +@Composable +internal fun uiAccentLabel(accent: UiAccent): String = when (accent) { + UiAccent.OpenNow -> stringResource(R.string.accent_opennow) + UiAccent.Pixel -> stringResource(R.string.accent_pixel) + UiAccent.HotPink -> stringResource(R.string.accent_hot_pink) + UiAccent.Lime -> stringResource(R.string.accent_lime) + UiAccent.Coral -> stringResource(R.string.accent_coral) + UiAccent.Violet -> stringResource(R.string.accent_violet) + UiAccent.LegacyOrange -> stringResource(R.string.accent_violet) + UiAccent.AbsoluteCinema -> stringResource(R.string.accent_absolute_cinema) + UiAccent.Switch -> stringResource(R.string.accent_switch) +} + +/** Accent selection is independent from the switches that opt into Cinema border effects. */ +internal fun selectableUiAccents(): List = UiAccent.entries.filterNot { it == UiAccent.LegacyOrange } + +internal val UiAccent.secondaryColor: Color + get() = when (this) { + UiAccent.OpenNow -> OpenNowPalette.AccentDefaultSecondary + UiAccent.AbsoluteCinema -> Color.White + UiAccent.Switch -> OpenNowPalette.AccentSwitchBlue + else -> color + } + +internal data class ActiveSelectionEffectStyle( + val color: Color, + val secondaryColor: Color, + /** Flat selected fills, icon tints, and menu highlights — everything that is not an effect. */ + val tintColor: Color, + /** Static borders around game artwork; independent from the animated Cinema treatment. */ + val gameCardBordersEnabled: Boolean, + val enabled: Boolean, + val absoluteCinemaActive: Boolean, + val absoluteCinemaEverywhere: Boolean, +) + +/** + * The fixed tint for selected navigation — the tab bar and its rotated form, the rail. + * + * Navigation chrome deliberately does not follow the interface accent. It is the one surface that + * is always on screen, and an accent there reads as the app changing identity rather than as a + * highlight. + */ +internal val NavigationSelectionColor = Color.White + +/** + * Absolute Cinema is an explicit animation toggle, not a color override. The everywhere suboption + * broadens the same treatment to additional hovered and focused surfaces. The user's selected + * accent remains the color owner. + * + * [ActiveSelectionEffectStyle.tintColor] splits flat selection from animated energy. Both use the + * selected color; enabling Cinema never changes the Material palette. + */ +internal fun AppSettings.activeSelectionEffectStyle(): ActiveSelectionEffectStyle { + val cinemaEverywhere = absoluteCinemaEffects && absoluteCinemaEverywhere + val cinemaActive = absoluteCinemaEffects + val baseColor = if (uiAccent == UiAccent.OpenNow) Color.White else uiAccent.color + val baseSecondaryColor = if (uiAccent == UiAccent.OpenNow) Color.White else uiAccent.secondaryColor + return ActiveSelectionEffectStyle( + color = baseColor, + secondaryColor = baseSecondaryColor, + tintColor = if (uiAccent.usesDefaultSelectionTint()) Color.White else uiAccent.color, + gameCardBordersEnabled = liveSelectedOutlines, + // Selection borders are an Absolute Cinema effect. Other accents still own fills, icon + // tints, and text emphasis, but no longer draw outlines around ordinary controls. + enabled = cinemaActive, + absoluteCinemaActive = cinemaActive, + absoluteCinemaEverywhere = cinemaEverywhere, + ) +} + +/** Accents whose flat selection colour stays the default white regardless of the effect colour. */ +internal fun UiAccent.usesDefaultSelectionTint(): Boolean = + this == UiAccent.OpenNow || this == UiAccent.AbsoluteCinema + +/** + * The Material palette colour for an accent. + * + * Absolute Cinema keeps the regular Material palette and white artwork/focus borders. Selecting it + * never enables those effects by itself; choosing another accent can still tint Cinema effects. + */ +internal val UiAccent.themeColor: Color + get() = if (this == UiAccent.AbsoluteCinema) OpenNowPalette.AccentDefault else color + +internal val UiAccent.themeSecondaryColor: Color + get() = if (this == UiAccent.AbsoluteCinema) OpenNowPalette.AccentDefaultSecondary else secondaryColor + +internal val LocalActiveSelectionColor = staticCompositionLocalOf { Color.White } +internal val LocalActiveSelectionSecondaryColor = staticCompositionLocalOf { Color.White } +/** Flat selected fills and icon tints. See [ActiveSelectionEffectStyle.tintColor]. */ +internal val LocalSelectionTintColor = staticCompositionLocalOf { Color.White } +internal val LocalGameCardBordersEnabled = staticCompositionLocalOf { false } +internal val LocalActiveSelectionEnabled = staticCompositionLocalOf { true } +internal val LocalAbsoluteCinemaEffects = staticCompositionLocalOf { false } +internal val LocalAbsoluteCinemaEverywhere = staticCompositionLocalOf { false } +/** Selects the classic blue/orange palette only inside animated Absolute Cinema frames. */ +internal val LocalAbsoluteCinemaPalette = staticCompositionLocalOf { false } +internal val LocalVibrationEnabled = staticCompositionLocalOf { true } +// Leave a full control-height runway below the last item so mobile focus/hover frames do not +// collide with the viewport edge in Settings, Library, or Store. +internal val AppScrollEndSpacing = 72.dp + +internal fun shouldShowAppWallpaper( + page: AppPage, + inStream: Boolean, + settings: AppSettings, +): Boolean = + !inStream && + (page == AppPage.Home || page == AppPage.Library || page == AppPage.Settings) && + shouldShowCatalogWallpaper(settings) + +@Composable +fun OpenNowTheme( + settings: AppSettings, + physicalControllerConnected: Boolean, + content: @Composable () -> Unit, +) { + val context = LocalContext.current + val accent = settings.uiAccent.themeColor + val fallbackScheme = darkColorScheme( + primary = accent, + onPrimary = OpenNowPalette.OnAccent, + background = Background, + surface = Panel, + surfaceVariant = PanelAlt, + onBackground = TextPrimary, + onSurface = TextPrimary, + onSurfaceVariant = TextMuted, + secondary = settings.uiAccent.themeSecondaryColor, + errorContainer = OpenNowPalette.ErrorContainer, + onErrorContainer = OpenNowPalette.OnErrorContainer, + ) + val colorScheme = if (settings.dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + dynamicDarkColorScheme(context).copy( + primary = accent, + onPrimary = OpenNowPalette.OnAccent, + secondary = settings.uiAccent.themeSecondaryColor, + tertiary = Green, + errorContainer = OpenNowPalette.ErrorContainer, + onErrorContainer = OpenNowPalette.OnErrorContainer, + ) + } else { + fallbackScheme + } + // Honour both the system-wide animation switch and the in-app toggle. Infinite transitions + // (shimmer, focus energy, carousel auto-advance) read this and stop entirely. + val reduceMotion = remember(settings.controllerBackgroundAnimations, context) { + val systemScale = runCatching { + Settings.Global.getFloat( + context.contentResolver, + Settings.Global.ANIMATOR_DURATION_SCALE, + 1f, + ) + }.getOrDefault(1f) + systemScale == 0f || !settings.controllerBackgroundAnimations + } + val selectionEffectStyle = settings.activeSelectionEffectStyle() + val gamingHandheld = remember { isGamingHandheldDevice() } + val openNowHaptics = rememberOpenNowHaptics( + enabled = settings.vibrationEnabled, + navigationEnabled = settings.vibrationEnabled && (gamingHandheld || physicalControllerConnected), + handheldFeedback = gamingHandheld, + ) + CompositionLocalProvider( + LocalReduceMotion provides reduceMotion, + LocalOpenNowHaptics provides openNowHaptics, + LocalActiveSelectionColor provides selectionEffectStyle.color, + LocalActiveSelectionSecondaryColor provides selectionEffectStyle.secondaryColor, + LocalSelectionTintColor provides selectionEffectStyle.tintColor, + LocalGameCardBordersEnabled provides selectionEffectStyle.gameCardBordersEnabled, + LocalActiveSelectionEnabled provides selectionEffectStyle.enabled, + LocalAbsoluteCinemaEffects provides selectionEffectStyle.absoluteCinemaActive, + LocalAbsoluteCinemaEverywhere provides selectionEffectStyle.absoluteCinemaEverywhere, + LocalAbsoluteCinemaPalette provides (settings.uiAccent == UiAccent.AbsoluteCinema), + LocalVibrationEnabled provides settings.vibrationEnabled, + ) { + MaterialTheme( + colorScheme = colorScheme, + typography = OpenNowTypography, + shapes = OpenNowShapes, + content = content, + ) + } +} + +@Composable +fun OpenNowApp( + viewModel: OpenNowViewModel, + onMicrophoneCaptureActiveChange: (Boolean) -> Unit = {}, +) { + val state by viewModel.state.collectAsStateWithLifecycle() + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + val physicalControllerConnected = rememberPhysicalControllerConnected(enabled = true) + val controllerFocusEnabled = shouldShowControllerFocus( + focused = true, + tvProfile = state.androidTvProfile, + physicalControllerConnected = physicalControllerConnected, + ) + val launchAudioController = remember(context) { AndroidNerdAudioController(context.applicationContext) } + val playIntroOnAppLaunch = remember { state.settings.streamIntroMusic } + val introStartsMutedOnLaunch = remember { + state.settings.streamIntroMusic && state.settings.streamIntroStartMode == IntroMusicStartMode.Muted + } + val musicControlsEnabled = state.settings.streamIntroMusic || state.settings.queueReadyMusic + val streamActive = state.page == AppPage.Stream || state.streamStatus != "idle" + val gameDetailsTransitionRegistry = remember { GameDetailsTransitionRegistry() } + var launchIntroStarted by remember { mutableStateOf(false) } + var launchMusicMuted by remember { mutableStateOf(introStartsMutedOnLaunch) } + var launchMusicPlaying by remember { mutableStateOf(false) } + var previousStreamStatus by remember { mutableStateOf(state.streamStatus) } + var queuedForStartCue by remember { mutableStateOf(false) } + var lastStartCueSessionId by remember { mutableStateOf(null) } + var hiddenUpdatePromptKey by remember { mutableStateOf(null) } + var completedSessionBugReportOpen by rememberSaveable { mutableStateOf(false) } + val updatePromptKey = state.androidUpdate.visibleNoticeKey(state.dismissedAndroidUpdateNoticeKey) + // After sign-in, not before: the appearance step previews the user's own box art, and there is + // no catalog to draw from until an account is attached. + val showSetupFlow = state.authSession != null && shouldShowSetupFlow(state.settings) + val showAnalyticsConsent = !showSetupFlow && !state.settings.analyticsConsentAsked + val diagnosticDialogVisible = state.diagnosticShare.awaitingConsent || + state.diagnosticShare.uploading || + state.diagnosticShare.pasteUrl != null + val showCompletedSessionBugReport = completedSessionBugReportOpen && !showAnalyticsConsent && !diagnosticDialogVisible + val showSessionReport = state.sessionReport != null && + state.settings.showSessionReportAfterStream && + !showAnalyticsConsent && + !diagnosticDialogVisible && + !showCompletedSessionBugReport + val showUpdatePrompt = updatePromptKey != null && + updatePromptKey != hiddenUpdatePromptKey && + !showSetupFlow && + !showAnalyticsConsent && + !showSessionReport && + !showCompletedSessionBugReport && + !diagnosticDialogVisible && + state.androidUpdate.status in setOf(AndroidUpdateStatus.Available, AndroidUpdateStatus.Downloaded) + + DisposableEffect(launchAudioController) { + onDispose { + launchAudioController.release() + } + } + DisposableEffect(lifecycleOwner, launchAudioController) { + val observer = LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_PAUSE -> launchAudioController.pauseAll { launchMusicPlaying = it } + Lifecycle.Event.ON_RESUME -> launchAudioController.resumeAll { launchMusicPlaying = it } + else -> Unit + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + } + } + LaunchedEffect( + playIntroOnAppLaunch, + state.settings.streamIntroMusic, + state.settings.queueReadyMusic, + launchMusicMuted, + state.page, + state.streamStatus, + state.launchPhase, + state.queuePosition, + state.streamSession?.sessionId, + state.streamSession?.queuePosition, + state.streamSession?.seatSetupStep, + ) { + val sessionId = state.streamSession?.sessionId + val queueReadyForStream = + previousStreamStatus == "queue" && + state.streamStatus == "connecting" && + sessionId != null && + sessionId != lastStartCueSessionId + previousStreamStatus = state.streamStatus + if (state.streamStatus == "queue") { + queuedForStartCue = queuedForStartCue || + queueDisplayPosition(state) != null || + state.launchPhase.equals("Queue", ignoreCase = true) + } + + if (!musicControlsEnabled) { + launchIntroStarted = false + launchMusicMuted = false + launchAudioController.stopAll { launchMusicPlaying = it } + if (state.streamStatus == "idle") { + queuedForStartCue = false + } + return@LaunchedEffect + } + if (!state.settings.streamIntroMusic && launchMusicMuted) { + launchMusicMuted = false + } + if (!state.settings.streamIntroMusic) { + launchAudioController.stopIntro { launchMusicPlaying = it } + } + if (!state.settings.queueReadyMusic) { + launchAudioController.stopQueueReadyReminder { launchMusicPlaying = it } + } + + if (!state.settings.streamIntroMusic && !state.settings.queueReadyMusic) { + launchAudioController.stopAll { launchMusicPlaying = it } + } else if (queueReadyForStream && queuedForStartCue) { + launchMusicMuted = false + lastStartCueSessionId = sessionId + queuedForStartCue = false + launchAudioController.startQueueReadyReminder(enabled = state.settings.queueReadyMusic) { launchMusicPlaying = it } + } else if (launchMusicMuted) { + launchAudioController.stopIntro { launchMusicPlaying = it } + } else if (playIntroOnAppLaunch && state.settings.streamIntroMusic && !streamActive) { + if (!launchIntroStarted) { + launchIntroStarted = true + launchAudioController.startIntro(enabled = true) { launchMusicPlaying = it } + } + } else { + launchAudioController.stopIntro { launchMusicPlaying = it } + } + if (state.streamStatus == "idle") { + queuedForStartCue = false + } + } + val musicControl = TopBarMusicControl( + visible = musicControlsEnabled, + playing = launchMusicPlaying, + muted = launchMusicMuted, + onToggle = { + when { + launchMusicMuted -> { + launchMusicMuted = false + if (state.settings.streamIntroMusic && !streamActive) { + launchIntroStarted = true + launchAudioController.startIntro(enabled = true) { launchMusicPlaying = it } + } + } + launchMusicPlaying -> { + launchMusicMuted = true + launchAudioController.stopAll { launchMusicPlaying = it } + } + state.settings.streamIntroMusic && !streamActive -> { + launchIntroStarted = true + launchAudioController.startIntro(enabled = true) { launchMusicPlaying = it } + } + else -> { + launchMusicMuted = true + launchAudioController.stopAll { launchMusicPlaying = it } + } + } + }, + ) + + OpenNowTheme( + settings = state.settings, + physicalControllerConnected = physicalControllerConnected, + ) { + val primaryColor = MaterialTheme.colorScheme.primary + CompositionLocalProvider( + LocalTvLoadingProfile provides state.androidTvProfile, + LocalControllerFocusEnabled provides controllerFocusEnabled, + LocalGameDetailsTransitionRegistry provides gameDetailsTransitionRegistry, + ) { + Box( + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .drawWithCache { + val brush = Brush.radialGradient( + colors = listOf( + primaryColor.copy(alpha = 0.15f), + Color.Transparent + ), + center = Offset(size.width, 0f), + radius = size.width.coerceAtLeast(size.height) * 0.8f + ) + onDrawBehind { + drawRect(brush) + } + } + ) { + Surface(Modifier.fillMaxSize(), color = Color.Transparent) { + when { + showSetupFlow -> SetupFlowScreen(state = state, viewModel = viewModel) + state.authSession != null -> MainShell( + state = state, + viewModel = viewModel, + musicControl = musicControl, + onMicrophoneCaptureActiveChange = onMicrophoneCaptureActiveChange, + ) + else -> LoginScreen(state, viewModel) + } + } + state.sessionReport?.takeIf { showSessionReport }?.let { report -> + SessionReportDialog( + report = report, + onDismiss = { dontShowAgain -> + if (dontShowAgain) { + viewModel.updateSettings( + state.settings.copy(showSessionReportAfterStream = false), + ) + } + viewModel.dismissSessionReport() + }, + onReportBug = { dontShowAgain -> + if (dontShowAgain) { + viewModel.updateSettings( + state.settings.copy(showSessionReportAfterStream = false), + ) + } + viewModel.resetBugReportSubmission() + completedSessionBugReportOpen = true + }, + ) + } + if (showCompletedSessionBugReport) { + CompletedSessionBugReportDialog( + submission = state.bugReportSubmission, + versionCheck = state.bugReportVersionCheck, + update = state.androidUpdate, + onSubmit = { title, description, knownIssueOverrideKey -> + viewModel.submitBugReport(title, description, knownIssueOverrideKey) + }, + onReset = viewModel::resetBugReportSubmission, + onVersionCheck = viewModel::verifyBugReportVersion, + onOpenUpdate = viewModel::performAndroidUpdatePrimaryAction, + preflightProvider = { + buildBugReportPreflightDeck( + BugReportPreflightEvidence( + requestedSettings = state.settings.stream, + recommendedSettings = state.recommendedStreamSettings, + nativeLowLatencyDecoderEnabled = state.settings.nativeLowLatencyDecoder, + runtimeDiagnostics = AndroidRuntimeDiagnostics.snapshot(context), + sessionReport = state.sessionReport, + codecReport = state.codecReport, + androidTvProfile = state.androidTvProfile, + serverZone = state.streamSession?.zone, + manuallySelectedServer = state.manuallySelectedServerForReport, + inputDiagnostics = NativeInputDiagnostics.snapshot(), + ), + ) + }, + onDismiss = { + if (!state.bugReportSubmission.uploading) { + completedSessionBugReportOpen = false + viewModel.dismissSessionReport() + viewModel.resetBugReportSubmission() + } + }, + ) + } + updatePromptKey?.takeIf { showUpdatePrompt }?.let { promptKey -> + AndroidUpdatePromptDialog( + update = state.androidUpdate, + onPrimary = { + hiddenUpdatePromptKey = promptKey + when (state.androidUpdate.status) { + AndroidUpdateStatus.Available -> viewModel.performAndroidUpdatePrimaryAction() + AndroidUpdateStatus.Downloaded -> viewModel.installAndroidUpdate() + else -> Unit + } + }, + onDetails = { + hiddenUpdatePromptKey = promptKey + viewModel.openAndroidUpdateSettings() + }, + onDismiss = viewModel::dismissAndroidUpdateNotice, + ) + } + if (showAnalyticsConsent) { + AnalyticsConsentDialog( + onAllow = { + viewModel.updateSettings( + state.settings.copy( + analyticsConsentAsked = true, + analyticsOptOut = false, + ), + ) + }, + onDecline = { + viewModel.updateSettings( + state.settings.copy( + analyticsConsentAsked = true, + analyticsOptOut = true, + ), + ) + }, + ) + } + DiagnosticShareDialog( + state = state, + onUpload = viewModel::uploadDiagnosticShare, + onDismiss = viewModel::dismissDiagnosticShare, + ) + } + } + } +} + +@Composable +private fun MainShell( + state: OpenNowUiState, + viewModel: OpenNowViewModel, + musicControl: TopBarMusicControl, + onMicrophoneCaptureActiveChange: (Boolean) -> Unit, +) { + val context = LocalContext.current + val inStream = state.page == AppPage.Stream + val streamingActive = inStream && state.streamStatus != "idle" + val modalPickerOpen = state.pendingPrintedWasteGame != null || + state.pendingStoreChoiceGame != null || + state.pendingMembershipNotice != null + val tvProfile = state.androidTvProfile + val physicalControllerConnected = rememberPhysicalControllerConnected(enabled = !inStream) + val navAudioController = remember(context) { AndroidNerdAudioController(context.applicationContext) } + var visibleSearchTarget by remember { mutableStateOf(null) } + var settingsSearchQuery by remember { mutableStateOf("") } + var settingsDetailRouteOpen by remember { mutableStateOf(false) } + var settingsBackRequestToken by remember { mutableStateOf(0) } + val tvStreamReturnFocusRequester = remember { FocusRequester() } + val topBarProfileFocusRequester = remember { FocusRequester() } + val catalogFilterFocusRequester = remember { FocusRequester() } + var previouslyInStream by remember { mutableStateOf(inStream) } + val navigationToneEnabled = state.settings.controllerUiSounds && !inStream + val showMinimizedQueueDock = state.streamLaunchMinimized && shouldShowQueueLaunchStatus(state) + DisposableEffect(navAudioController) { + onDispose { + navAudioController.release() + } + } + LaunchedEffect(state.page) { + if (state.page != AppPage.Settings) { + settingsDetailRouteOpen = false + } + } + LaunchedEffect(inStream, tvProfile) { + val shouldRestoreFocus = shouldRestoreTvNavigationFocus( + previouslyInStream = previouslyInStream, + currentlyInStream = inStream, + tvProfile = tvProfile, + ) + previouslyInStream = inStream + if (shouldRestoreFocus) { + delay(120) + repeat(3) { attempt -> + if (runCatching { tvStreamReturnFocusRequester.requestFocus() }.isSuccess) { + return@LaunchedEffect + } + if (attempt < 2) delay(80) + } + } + } + fun revealSearch( + target: SearchTarget = when (state.page) { + AppPage.Library -> SearchTarget.Library + AppPage.Settings -> SearchTarget.Settings + else -> SearchTarget.Store + }, + ) { + visibleSearchTarget = target + if (target == SearchTarget.Store && state.page != AppPage.Home) { + viewModel.setPage(AppPage.Home) + } else if (target == SearchTarget.Library && state.page != AppPage.Library) { + viewModel.setPage(AppPage.Library) + } else if (target == SearchTarget.Settings && state.page != AppPage.Settings) { + viewModel.setPage(AppPage.Settings) + } + } + fun navigateFromAppChrome(page: AppPage) { + if (page == AppPage.Settings) { + viewModel.recordSettingsIconTap() + } + visibleSearchTarget = null + viewModel.setPage(page) + } + BackHandler(enabled = state.selectedGame != null && !inStream) { + viewModel.clearSelectedGame() + } + BackHandler( + enabled = (tvProfile || physicalControllerConnected) && + !inStream && + state.selectedGame == null && + !modalPickerOpen && + state.page != AppPage.Home, + ) { + viewModel.setPage(AppPage.Home) + } + BoxWithConstraints(Modifier.fillMaxSize()) { + var phoneLandscapeScrollChromeHidden by remember { mutableStateOf(false) } + val horizontalChrome = maxWidth > maxHeight + val phoneLandscapeChrome = !tvProfile && !inStream && isPhoneLandscape(maxWidth, maxHeight) + val portraitChrome = !inStream && maxHeight >= maxWidth + val showNavigationRail = !inStream && (tvProfile || phoneLandscapeChrome) + val scrollChromePage = state.page == AppPage.Home || state.page == AppPage.Library + val wallpaperPage = scrollChromePage || state.page == AppPage.Settings + val tvCatalogChrome = tvProfile && scrollChromePage + val mobileCatalogControlsInTopBar = !tvProfile && portraitChrome + val storeControlsInTopBar = (mobileCatalogControlsInTopBar || phoneLandscapeChrome || tvCatalogChrome) && state.page == AppPage.Home + val libraryControlsInTopBar = (mobileCatalogControlsInTopBar || phoneLandscapeChrome || tvCatalogChrome) && state.page == AppPage.Library + val screenEdgePadding = appContentEdgePaddingDp( + settings = state.settings, + inStream = inStream, + tvProfile = tvProfile, + ).dp + LaunchedEffect(phoneLandscapeChrome, scrollChromePage) { + if (!phoneLandscapeChrome || !scrollChromePage) { + phoneLandscapeScrollChromeHidden = false + } + } + Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) + // A chosen app wallpaper stays visible through Settings as well as Store and Library. + // Stream surfaces remain opaque so custom art can never compete with video or overlays. + val wallpaperVisible = shouldShowAppWallpaper(state.page, inStream, state.settings) + if (wallpaperVisible) { + CatalogWallpaperBackdrop( + settings = state.settings, + tvProfile = tvProfile, + width = maxWidth, + height = maxHeight, + ) + } else if (!inStream && state.settings.ambientBackgroundEnabled) { + AmbientBackground() + } + + Scaffold( + containerColor = Color.Transparent, + contentWindowInsets = if (streamingActive || tvProfile) WindowInsets(0, 0, 0, 0) else ScaffoldDefaults.contentWindowInsets, + bottomBar = { + if (!inStream && !showNavigationRail) { + Column { + if (showMinimizedQueueDock) { + MinimizedQueueDock( + state = state, + onRestore = viewModel::restoreStreamLaunch, + onCancel = viewModel::stopStream, + ) + } + NavigationBar( + containerColor = MaterialTheme.colorScheme.background, + tonalElevation = 0.dp, + ) { + BottomNavItem( + selected = state.page == AppPage.Home, + onClick = { + visibleSearchTarget = null + viewModel.setPage(AppPage.Home) + }, + iconRes = R.drawable.ic_tab_store, + label = stringResource(R.string.nav_store), + ) + BottomNavItem( + // Search is a mode, not a destination: it never claims selection. + selected = false, + onClick = { revealSearch() }, + iconRes = R.drawable.ic_search, + label = stringResource(R.string.nav_search), + ) + BottomNavItem( + selected = state.page == AppPage.Library, + onClick = { + visibleSearchTarget = null + viewModel.setPage(AppPage.Library) + }, + iconRes = R.drawable.ic_tab_library, + label = stringResource(R.string.nav_library), + ) + BottomNavItem( + selected = state.page == AppPage.Settings, + onClick = { + navigateFromAppChrome(AppPage.Settings) + }, + iconRes = R.drawable.ic_tab_settings, + label = stringResource(R.string.nav_settings), + ) + } + } + } + }, + ) { padding -> + Box( + Modifier + .fillMaxSize() + .padding(padding) + .onPreviewKeyEvent { event -> + if (isNavigationToneKey(event)) { + navAudioController.playButtonTone(navigationToneEnabled) + } + false + }, + ) { + Row( + Modifier + .fillMaxSize() + .padding(screenEdgePadding), + ) { + if (showNavigationRail) { + AppNavigationRail( + state = state, + activeSearchTarget = visibleSearchTarget, + largeIcons = phoneLandscapeChrome, + darkenForCatalogBackground = state.settings.nerdCatalogBackground && wallpaperPage, + showSettingsBack = shouldShowSettingsBackRail( + tvProfile = tvProfile, + settingsPageOpen = state.page == AppPage.Settings, + horizontalChrome = horizontalChrome, + detailRouteOpen = settingsDetailRouteOpen, + ), + showCatalogControllerActions = physicalControllerConnected && scrollChromePage, + onNavigate = { page -> + navigateFromAppChrome(page) + }, + onSearch = { revealSearch(it) }, + onSettingsBack = { settingsBackRequestToken += 1 }, + streamReturnFocusRequester = tvStreamReturnFocusRequester, + ) + } + Column( + Modifier + .weight(1f) + .fillMaxHeight(), + ) { + AnimatedVisibility( + visible = shouldShowTopStatusBar( + inStream = inStream, + portraitChrome = portraitChrome, + phoneLandscapeChrome = phoneLandscapeChrome, + phoneLandscapeScrollChromeHidden = phoneLandscapeScrollChromeHidden, + tvProfile = tvProfile, + ), + ) { + if (!inStream) { + TopStatusBar( + state = state, + profileFocusRequester = topBarProfileFocusRequester, + catalogControlFocusRequester = catalogFilterFocusRequester.takeIf { + storeControlsInTopBar || libraryControlsInTopBar + }, + onResumeActiveSession = viewModel::resumeActiveSession, + onOpenSettings = { navigateFromAppChrome(AppPage.Settings) }, + onOpenLocalApps = { + visibleSearchTarget = null + viewModel.openInterfaceSettings() + }, + onOpenStreamSettings = viewModel::openStreamSettings, + musicControl = musicControl, + showRefreshAction = shouldShowTvRefreshAction(tvProfile, inStream), + refreshing = when (state.page) { + AppPage.Settings -> state.settingsRefreshing + AppPage.Home, AppPage.Library -> state.loadingGames + AppPage.Stream -> false + }, + onRefresh = { + when (state.page) { + AppPage.Settings -> viewModel.refreshSettings() + AppPage.Home, AppPage.Library -> viewModel.refreshGames() + AppPage.Stream -> Unit + } + }, + showChromeScrim = portraitChrome, + ) { + if (storeControlsInTopBar) { + Spacer(Modifier.weight(1f)) + val filterOptions = rememberCatalogFilterOptions( + remember(state.catalogResult.filterGroups) { + catalogVisibleFilterGroups(state.catalogResult.filterGroups) + }, + ) + CatalogSortFilterMenu( + sortOptions = state.catalogResult.sortOptions, + selectedSortId = state.catalogSortId, + filterOptions = filterOptions, + selectedFilterIds = state.catalogFilterIds, + onSortChange = viewModel::setCatalogSort, + onFilterToggle = viewModel::toggleCatalogFilter, + focusRequester = catalogFilterFocusRequester, + leadingFocusRequester = topBarProfileFocusRequester, + ) + } else if (libraryControlsInTopBar) { + val orderedLibraryGames = remember( + state.libraryGames, + state.settings.favoriteGameIds, + state.librarySortId, + ) { + sortLibraryGames( + favoriteOrderedGames(state.libraryGames, state.settings.favoriteGameIds), + state.librarySortId, + ) + } + val touchFilterLabel = stringResource(R.string.catalog_filter_touch_controls) + val libraryFilterOptions = remember(orderedLibraryGames, touchFilterLabel) { + libraryStoreFilterOptions(orderedLibraryGames, touchFilterLabel) + } + Spacer(Modifier.weight(1f)) + CatalogSortFilterMenu( + sortOptions = librarySortOptions(), + selectedSortId = state.librarySortId, + filterOptions = libraryFilterOptions, + selectedFilterIds = state.libraryFilterIds, + onSortChange = viewModel::setLibrarySort, + onFilterToggle = viewModel::toggleLibraryFilter, + focusRequester = catalogFilterFocusRequester, + leadingFocusRequester = topBarProfileFocusRequester, + ) + } + } + } + } + Box( + Modifier + .weight(1f) + .fillMaxWidth(), + ) { + CompositionLocalProvider(LocalSelectedCatalogGameId provides state.selectedGame?.id) { + when (state.page) { + AppPage.Home -> HomeScreen( + state = state, + viewModel = viewModel, + tvProfile = tvProfile, + hideChromeWhenScrolled = phoneLandscapeChrome, + controlsInTopBar = storeControlsInTopBar, + topBarFocusRequester = catalogFilterFocusRequester.takeIf { + storeControlsInTopBar + }, + searchRequested = visibleSearchTarget == SearchTarget.Store, + onSearchDismissed = { + if (visibleSearchTarget == SearchTarget.Store) visibleSearchTarget = null + }, + onScrollChromeHiddenChange = { phoneLandscapeScrollChromeHidden = it }, + ) + AppPage.Library -> LibraryScreen( + state = state, + viewModel = viewModel, + tvProfile = tvProfile, + hideChromeWhenScrolled = phoneLandscapeChrome, + controlsInTopBar = libraryControlsInTopBar, + topBarFocusRequester = catalogFilterFocusRequester.takeIf { + libraryControlsInTopBar + }, + searchRequested = visibleSearchTarget == SearchTarget.Library, + onSearchDismissed = { + if (visibleSearchTarget == SearchTarget.Library) visibleSearchTarget = null + }, + onScrollChromeHiddenChange = { phoneLandscapeScrollChromeHidden = it }, + ) + AppPage.Settings -> SettingsScreen( + state = state, + viewModel = viewModel, + tvProfile = tvProfile, + searchRequested = visibleSearchTarget == SearchTarget.Settings, + searchQuery = settingsSearchQuery, + backRequestToken = settingsBackRequestToken, + onSearchQueryChange = { next -> + settingsSearchQuery = next + if (next.isBlank() && visibleSearchTarget == SearchTarget.Settings) { + visibleSearchTarget = null + } + }, + onDetailRouteChange = { settingsDetailRouteOpen = it }, + ) + AppPage.Stream -> StreamScreen( + state = state, + viewModel = viewModel, + onMicrophoneCaptureActiveChange = onMicrophoneCaptureActiveChange, + ) + } + } + } + if (showMinimizedQueueDock && showNavigationRail) { + MinimizedQueueDock( + state = state, + onRestore = viewModel::restoreStreamLaunch, + onCancel = viewModel::stopStream, + ) + } + } + } + state.selectedGame?.takeIf { !inStream && !modalPickerOpen }?.let { game -> + ControllerModalOverlay(onDismissRequest = viewModel::clearSelectedGame) { + // Keep details in the app's window so the activated artwork and destination + // banner share coordinates and the first visible frame follows the haptic. + GameDetailsSheet( + game = game, + favorite = game.id in state.settings.favoriteGameIds, + defaultVariantId = state.settings.defaultGameVariantIds[game.id], + fullScreen = tvProfile, + safeAreaPadding = screenEdgePadding, + onPlay = viewModel::play, + onChooseStore = viewModel::chooseStore, + onFavorite = viewModel::updateFavorites, + connectedTvName = state.localTvConnector.connectedTvName, + onPlayOnTv = viewModel::playOnLocalTv, + onDismiss = viewModel::clearSelectedGame, + ) + } + } + state.pendingPrintedWasteGame?.let { game -> + ControllerModalDialog(onDismissRequest = viewModel::dismissPrintedWasteSelector) { + AnimatedLaunchOverlay(Modifier.fillMaxSize()) { + PrintedWasteSelector(state, game, viewModel) + } + } + } + state.pendingMembershipNotice?.let { notice -> + ControllerModalDialog(onDismissRequest = viewModel::dismissMembershipNotice) { + MembershipRequirementDialog( + notice = notice, + onCancel = viewModel::dismissMembershipNotice, + onContinue = viewModel::continuePastMembershipNotice, + ) + } + } + state.pendingStoreChoiceGame?.let { game -> + ControllerModalDialog(onDismissRequest = viewModel::dismissStoreChoice) { + AnimatedLaunchOverlay(Modifier.fillMaxSize()) { + StoreLaunchSelector( + game = game, + defaultVariantId = state.settings.defaultGameVariantIds[game.id], + onLaunch = viewModel::playVariant, + onSetDefaultStore = viewModel::setDefaultGameVariant, + onDismiss = viewModel::dismissStoreChoice, + ) + } + } + } + } + } + } +} + +@Composable +private fun ControllerModalOverlay( + onDismissRequest: () -> Unit, + content: @Composable () -> Unit, +) { + Box( + Modifier + .fillMaxSize() + .zIndex(10f) + .onPreviewKeyEvent { event -> + if (event.key == Key.ButtonB || event.key == Key.Back || event.key == Key.Escape) { + if (event.type == KeyEventType.KeyUp) onDismissRequest() + true + } else { + false + } + }, + contentAlignment = Alignment.Center, + ) { + content() + } +} + +@Composable +private fun ControllerModalDialog( + onDismissRequest: () -> Unit, + content: @Composable () -> Unit, +) { + Dialog( + onDismissRequest = onDismissRequest, + properties = DialogProperties( + dismissOnBackPress = true, + dismissOnClickOutside = false, + usePlatformDefaultWidth = false, + ), + ) { + Box( + Modifier + .fillMaxSize() + .onPreviewKeyEvent { event -> + if (event.key == Key.ButtonB || event.key == Key.Back || event.key == Key.Escape) { + if (event.type == KeyEventType.KeyUp) onDismissRequest() + true + } else { + false + } + }, + contentAlignment = Alignment.Center, + ) { + content() + } + } +} + +internal fun shouldRestoreTvNavigationFocus( + previouslyInStream: Boolean, + currentlyInStream: Boolean, + tvProfile: Boolean, +): Boolean = tvProfile && previouslyInStream && !currentlyInStream + +@Composable +private fun AppNavigationRail( + state: OpenNowUiState, + activeSearchTarget: SearchTarget?, + largeIcons: Boolean, + darkenForCatalogBackground: Boolean, + showSettingsBack: Boolean, + showCatalogControllerActions: Boolean, + onNavigate: (AppPage) -> Unit, + onSearch: (SearchTarget) -> Unit, + onSettingsBack: () -> Unit, + streamReturnFocusRequester: FocusRequester, +) { + val bonanzaActive = LocalAbsoluteCinemaEverywhere.current + Box( + modifier = Modifier + .width(APP_NAV_RAIL_WIDTH) + .fillMaxHeight() + .padding(start = 6.dp, top = 8.dp, end = 6.dp, bottom = 8.dp), + ) { + Surface( + modifier = Modifier.fillMaxSize(), + shape = RoundedCornerShape(26.dp), + color = if (bonanzaActive) Color.Transparent else navigationRailScrim(darkenForCatalogBackground), + border = if (bonanzaActive) BorderStroke(1.dp, Color.White.copy(alpha = 0.88f)) else null, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + BoxWithConstraints(Modifier.fillMaxSize()) { + val canFitCatalogControllerActions = maxHeight >= 440.dp + Column( + modifier = Modifier + .align(if (showSettingsBack) Alignment.BottomCenter else Alignment.Center) + .fillMaxWidth() + .padding(bottom = if (showSettingsBack) 8.dp else 0.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AppNavigationRailItem( + selected = state.page == AppPage.Home, + onClick = { onNavigate(AppPage.Home) }, + iconRes = R.drawable.ic_tab_store, + label = stringResource(R.string.nav_store), + iconSize = if (largeIcons) 30.dp else 24.dp, + focusRequester = streamReturnFocusRequester, + ) + AppNavigationRailItem( + // See the bottom bar: search is a mode, not a destination. + selected = false, + onClick = { + onSearch( + when (state.page) { + AppPage.Library -> SearchTarget.Library + AppPage.Settings -> SearchTarget.Settings + else -> SearchTarget.Store + }, + ) + }, + iconRes = R.drawable.ic_search, + label = stringResource(R.string.nav_search), + iconSize = if (largeIcons) 30.dp else 24.dp, + ) + AppNavigationRailItem( + selected = state.page == AppPage.Library, + onClick = { onNavigate(AppPage.Library) }, + iconRes = R.drawable.ic_tab_library, + label = stringResource(R.string.nav_library), + iconSize = if (largeIcons) 30.dp else 24.dp, + ) + AppNavigationRailItem( + selected = state.page == AppPage.Settings, + onClick = { onNavigate(AppPage.Settings) }, + iconRes = R.drawable.ic_tab_settings, + label = stringResource(R.string.nav_settings), + iconSize = if (largeIcons) 30.dp else 24.dp, + showConnectionDot = shouldShowLocalTvConnectionDot( + tvProfile = state.androidTvProfile, + pairedDeviceName = state.localTvConnector.pairedDeviceName, + ), + ) + AnimatedVisibility(visible = showCatalogControllerActions && canFitCatalogControllerActions) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Spacer(Modifier.height(8.dp)) + ControllerCatalogRailActionHints() + } + } + AnimatedVisibility(visible = showSettingsBack) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Spacer(Modifier.height(6.dp)) + AppNavigationRailItem( + selected = false, + onClick = onSettingsBack, + iconRes = R.drawable.ic_arrow_back, + label = stringResource(R.string.action_back), + iconSize = if (largeIcons) 30.dp else 24.dp, + ) + } + } + } + } + } + } +} + +internal fun navigationRailScrim(darkenForCatalogBackground: Boolean): Color = + if (darkenForCatalogBackground) Color.Black.copy(alpha = 0.76f) else ChromeScrim + +internal fun shouldShowLocalTvConnectionDot(tvProfile: Boolean, pairedDeviceName: String?): Boolean = + tvProfile && !pairedDeviceName.isNullOrBlank() + +internal fun shouldShowLocalAppsProfileAction( + localAppLauncherSupported: Boolean, + @Suppress("UNUSED_PARAMETER") + localAppsEnabled: Boolean, +): Boolean = localAppLauncherSupported + +internal fun shouldAnimateOpenNowAppIcon( + codecReport: RuntimeCodecReport?, + reduceMotion: Boolean, + absoluteCinemaEnabled: Boolean, + androidTvProfile: Boolean = false, +): Boolean { + if (reduceMotion) return false + if (androidTvProfile) return true + return absoluteCinemaEnabled && + codecReport?.let { !it.lowPowerGpuProfile && !it.constrainedRuntimeProfile } == true +} + +internal fun shouldShowTopStatusBar( + inStream: Boolean, + portraitChrome: Boolean, + phoneLandscapeChrome: Boolean, + phoneLandscapeScrollChromeHidden: Boolean, + tvProfile: Boolean, +): Boolean = + !inStream && ( + tvProfile || + portraitChrome || + (phoneLandscapeChrome && !phoneLandscapeScrollChromeHidden) + ) + +internal fun shouldShowTvRefreshAction(tvProfile: Boolean, inStream: Boolean): Boolean = + tvProfile && !inStream + +internal fun shouldShowSettingsBackRail( + tvProfile: Boolean, + settingsPageOpen: Boolean, + horizontalChrome: Boolean, + detailRouteOpen: Boolean, +): Boolean = !tvProfile && settingsPageOpen && horizontalChrome && detailRouteOpen + +@Composable +private fun AppNavigationRailItem( + selected: Boolean, + onClick: () -> Unit, + iconRes: Int, + label: String, + modifier: Modifier = Modifier, + iconSize: Dp = 24.dp, + focusRequester: FocusRequester? = null, + showConnectionDot: Boolean = false, +) { + var focused by remember { mutableStateOf(false) } + val haptics = LocalOpenNowHaptics.current + // Flat rail chrome is fixed; only the animated frame around it follows the accent. + val navTint = NavigationSelectionColor + val showActiveFrame = selected || focused + val contentColor = when { + focused -> Color.White + selected -> navTint + else -> TextMuted + } + Box( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 5.dp, vertical = 2.dp), + ) { + Surface( + onClick = { + haptics?.play(HapticCue.Activate) + onClick() + }, + modifier = Modifier + .fillMaxWidth() + .onFocusChanged { focused = it.isFocused } + .focusMoveHaptics() + .then(focusRequester?.let { Modifier.focusRequester(it) } ?: Modifier), + shape = RoundedCornerShape(18.dp), + color = if (selected && !focused) navTint.copy(alpha = 0.12f) else Color.Transparent, + border = null, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 58.dp) + .padding(horizontal = 4.dp, vertical = 6.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + painter = painterResource(iconRes), + contentDescription = label, + tint = contentColor, + modifier = Modifier.size(if (selected) iconSize + 2.dp else iconSize), + ) + if (showConnectionDot) { + Spacer(Modifier.height(2.dp)) + Box( + Modifier + .size(6.dp) + .clip(CircleShape) + .background(Color(0xffb56cff)), + ) + } + Spacer(Modifier.height(2.dp)) + Text( + label, + color = contentColor, + style = MaterialTheme.typography.labelSmall, + fontWeight = if (selected) FontWeight.ExtraBold else FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + InteractionFocusFrame( + visible = showActiveFrame, + cornerRadius = 18.dp, + cinemaEffectEnabled = LocalAbsoluteCinemaEffects.current, + ) + } +} + +private data class TopBarMusicControl( + val visible: Boolean, + val playing: Boolean, + val muted: Boolean, + val onToggle: () -> Unit, +) + +@Composable +private fun RowScope.BottomNavItem( + selected: Boolean, + onClick: () -> Unit, + iconRes: Int, + label: String, +) { + val haptics = LocalOpenNowHaptics.current + // See AppNavigationRailItem: the tab bar is the rail rotated, and keeps the same fixed tint. + val navTint = NavigationSelectionColor + NavigationBarItem( + selected = selected, + onClick = { + haptics?.play(HapticCue.Activate) + onClick() + }, + colors = NavigationBarItemDefaults.colors( + selectedIconColor = navTint, + selectedTextColor = navTint, + indicatorColor = navTint.copy(alpha = 0.20f), + unselectedIconColor = TextMuted, + unselectedTextColor = TextMuted, + ), + icon = { + Box( + modifier = Modifier.size(width = 46.dp, height = 34.dp), + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(iconRes), + contentDescription = null, + modifier = Modifier.size(if (selected) 27.dp else 24.dp), + ) + } + }, + label = { + Text( + label, + fontWeight = if (selected) FontWeight.ExtraBold else FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + ) +} + +@Composable +private fun TopStatusBar( + state: OpenNowUiState, + profileFocusRequester: FocusRequester, + catalogControlFocusRequester: FocusRequester?, + onResumeActiveSession: () -> Unit, + onOpenSettings: () -> Unit, + onOpenLocalApps: () -> Unit, + onOpenStreamSettings: () -> Unit, + musicControl: TopBarMusicControl, + showRefreshAction: Boolean = false, + refreshing: Boolean = false, + onRefresh: () -> Unit = {}, + showChromeScrim: Boolean = true, + content: @Composable RowScope.() -> Unit = {}, +) { + val barScrim = if (showChromeScrim) ChromeScrim else Color.Transparent + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(start = 8.dp, top = 8.dp, end = 8.dp, bottom = 5.dp), + shape = RoundedCornerShape(24.dp), + color = barScrim, + border = if (LocalGameCardBordersEnabled.current) { + BorderStroke(0.5.dp, Color.White) + } else { + null + }, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Row( + Modifier.padding(horizontal = 12.dp, vertical = 7.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + TopBarProfileMenu( + state = state, + focusRequester = profileFocusRequester, + nextFocusRequester = catalogControlFocusRequester, + onOpenSettings = onOpenSettings, + onOpenLocalApps = onOpenLocalApps, + ) + Spacer(Modifier.width(8.dp)) + Row( + Modifier.weight(1f), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (state.settings.nerdMode) { + TopStatusDetails(state, onOpenStreamSettings) + } + // Mobile catalogue controls include their own weighted spacer. Keeping them last + // makes the merged filter button occupy the slot immediately before Music, or the + // Music slot itself when music controls are disabled. + content() + } + if (musicControl.visible) { + Spacer(Modifier.width(6.dp)) + TopBarMusicButton(musicControl) + } + if (showRefreshAction) { + Spacer(Modifier.width(6.dp)) + TopBarRefreshButton(refreshing = refreshing, onRefresh = onRefresh) + } + if (state.activeSession != null) { + Spacer(Modifier.width(6.dp)) + ElevatedButton( + onClick = onResumeActiveSession, + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 7.dp), + ) { + Text(stringResource(R.string.action_resume), style = MaterialTheme.typography.labelMedium) + } + } + } + } +} + +@Composable +private fun TopBarRefreshButton( + refreshing: Boolean, + onRefresh: () -> Unit, +) { + var focused by remember { mutableStateOf(false) } + val shape = RoundedCornerShape(14.dp) + Box { + Surface( + shape = shape, + color = Color.White.copy(alpha = 0.1f), + tonalElevation = 0.dp, + ) { + IconButton( + onClick = onRefresh, + enabled = !refreshing, + modifier = Modifier + .size(40.dp) + .onFocusChanged { focused = it.isFocused }, + ) { + if (refreshing) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = TextPrimary, + ) + } else { + Icon( + imageVector = Icons.Rounded.Refresh, + contentDescription = stringResource(R.string.action_refresh), + tint = TextPrimary, + modifier = Modifier.size(22.dp), + ) + } + } + } + InteractionFocusFrame( + visible = focused, + cornerRadius = 14.dp, + cinemaEffectEnabled = LocalAbsoluteCinemaEverywhere.current, + ) + } +} + +@Composable +private fun TopBarProfileMenu( + state: OpenNowUiState, + focusRequester: FocusRequester, + nextFocusRequester: FocusRequester?, + onOpenSettings: () -> Unit, + onOpenLocalApps: () -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + BackHandler(enabled = expanded) { expanded = false } + var focused by remember { mutableStateOf(false) } + val currentUser = state.authSession?.user + val savedAccount = state.savedAccounts.firstOrNull { it.userId == currentUser?.userId } + ?: state.savedAccounts.firstOrNull() + val displayName = currentUser?.displayName?.takeIf { it.isNotBlank() } + ?: savedAccount?.displayName?.takeIf { it.isNotBlank() } + ?: stringResource(R.string.profile_not_available) + val tier = state.subscriptionInfo?.membershipTier?.takeIf { it.isNotBlank() } + ?: currentUser?.membershipTier?.takeIf { it.isNotBlank() } + ?: savedAccount?.membershipTier?.takeIf { it.isNotBlank() } + ?: stringResource(R.string.profile_not_available) + val email = currentUser?.email?.takeIf { it.isNotBlank() } + ?: savedAccount?.email?.takeIf { it.isNotBlank() } + ?: stringResource(R.string.profile_not_available) + val profileDescription = stringResource(R.string.profile_menu_description) + val haptics = LocalOpenNowHaptics.current + val launcherControl = rememberDefaultLauncherControl() + + Box { + Box( + modifier = Modifier + .size(40.dp) + .focusRequester(focusRequester) + .then( + nextFocusRequester?.let { next -> + Modifier.focusProperties { right = next } + } ?: Modifier, + ) + .onFocusChanged { focused = it.isFocused } + .focusMoveHaptics() + .semantics { + contentDescription = profileDescription + role = Role.Button + } + .clickable { + haptics?.play(HapticCue.Activate) + expanded = true + }, + contentAlignment = Alignment.Center, + ) { + OpenNowAppIcon( + size = 32.dp, + animate = shouldAnimateOpenNowAppIcon( + codecReport = state.codecReport, + reduceMotion = LocalReduceMotion.current, + absoluteCinemaEnabled = LocalAbsoluteCinemaEffects.current, + androidTvProfile = state.androidTvProfile, + ), + ) + ControllerFocusFrame( + visible = focused, + cornerRadius = 14.dp, + tint = if (LocalAbsoluteCinemaEffects.current) LocalActiveSelectionColor.current else Color.White, + secondaryTint = if (LocalAbsoluteCinemaEffects.current) LocalActiveSelectionSecondaryColor.current else Color.White, + ) + } + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + modifier = Modifier + .widthIn(min = 250.dp, max = 300.dp), + // Popups must stay readable over custom wallpaper. Effects Bonanza belongs on each + // focused item; only the landscape navigation rail is allowed to become transparent. + containerColor = ProfileMenuContainerColor, + ) { + Column( + Modifier.padding(horizontal = 16.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(3.dp), + ) { + Text( + stringResource(R.string.profile_identity, displayName, tier), + color = TextPrimary, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.ExtraBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + email, + color = TextMuted, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + if (BuildConfig.LOCAL_APP_LAUNCHER_SUPPORTED) { + ProfileDropdownMenuItem( + text = { + Text( + stringResource( + if (launcherControl.isDefault) R.string.settings_default_launcher_selected + else R.string.settings_default_launcher, + ), + fontWeight = FontWeight.SemiBold, + ) + }, + onClick = { + expanded = false + haptics?.play(HapticCue.Activate) + launcherControl.request() + }, + ) + if (shouldShowLocalAppsProfileAction(BuildConfig.LOCAL_APP_LAUNCHER_SUPPORTED, state.settings.localAppsEnabled)) { + ProfileDropdownMenuItem( + text = { + Text( + stringResource(R.string.settings_local_apps), + fontWeight = FontWeight.SemiBold, + ) + }, + onClick = { + expanded = false + haptics?.play(HapticCue.Activate) + onOpenLocalApps() + }, + ) + } + } + HorizontalDivider(color = Color.White.copy(alpha = 0.10f)) + ProfileDropdownMenuItem( + text = { + Text( + stringResource(R.string.nav_settings), + color = LocalSelectionTintColor.current, + fontWeight = FontWeight.Bold, + ) + }, + onClick = { + expanded = false + haptics?.play(HapticCue.Activate) + onOpenSettings() + }, + ) + } + } +} + +@Composable +private fun ProfileDropdownMenuItem( + text: @Composable () -> Unit, + onClick: () -> Unit, +) { + var focused by remember { mutableStateOf(false) } + Box(Modifier.fillMaxWidth()) { + DropdownMenuItem( + text = text, + onClick = onClick, + modifier = Modifier.onFocusChanged { focused = it.isFocused || it.hasFocus }, + ) + InteractionFocusFrame( + visible = focused, + cornerRadius = 4.dp, + cinemaEffectEnabled = LocalAbsoluteCinemaEverywhere.current, + ) + } +} + +@Composable +private fun TopStatusDetails( + state: OpenNowUiState, + onOpenStreamSettings: () -> Unit, +) { + val stream = state.activeStreamSettings ?: state.settings.stream + val summary = streamStatusSummary(stream) + var focused by remember { mutableStateOf(false) } + val shape = RoundedCornerShape(999.dp) + Row( + horizontalArrangement = Arrangement.spacedBy(5.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Surface( + modifier = Modifier + .height(TopBarCompactControlHeight) + .onFocusChanged { focused = it.isFocused } + .focusMoveHaptics() + .semantics { contentDescription = "Open Stream settings: $summary" } + .clickable(onClick = onOpenStreamSettings) + .then( + if (focused) Modifier.border(2.dp, MaterialTheme.colorScheme.primary, shape) else Modifier, + ), + shape = shape, + color = if (focused) MaterialTheme.colorScheme.primary.copy(alpha = 0.22f) else PanelAlt.copy(alpha = 0.9f), + tonalElevation = 0.dp, + ) { + Box(Modifier.fillMaxHeight().padding(horizontal = 8.dp), contentAlignment = Alignment.Center) { + Text( + summary, + color = TextMuted, + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +@Composable +private fun TopBarMusicButton(control: TopBarMusicControl) { + val description = when { + control.muted -> "Music muted" + control.playing -> "Music playing" + else -> "Music ready" + } + Surface( + modifier = Modifier + .width(38.dp) + .height(TopBarCompactControlHeight) + .semantics { contentDescription = description } + .clickable(onClick = control.onToggle), + shape = RoundedCornerShape(999.dp), + color = if (control.muted) OpenNowPalette.ErrorContainer.copy(alpha = 0.92f) else PanelAlt.copy(alpha = 0.78f), + tonalElevation = 0.dp, + ) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + if (control.muted) { + Icon( + painter = painterResource(R.drawable.ic_volume_off), + contentDescription = null, + tint = OpenNowPalette.OnErrorContainer, + modifier = Modifier.size(17.dp), + ) + } else { + MusicBars(playing = control.playing) + } + } + } +} + +@Composable +private fun MusicBars(playing: Boolean, modifier: Modifier = Modifier) { + val transition = rememberInfiniteTransition(label = "top-bar-music-bars") + val phase by transition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 820, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + label = "top-bar-music-bars-phase", + ) + val color = MaterialTheme.colorScheme.primary + Canvas(modifier.size(width = 18.dp, height = 16.dp)) { + val barWidth = size.width / 5.8f + val gap = (size.width - barWidth * 3f) / 2f + repeat(3) { index -> + val wave = if (playing) { + ((sin((phase.toDouble() * 6.283185307179586) + index * 1.35) + 1.0) / 2.0).toFloat() + } else { + 0.36f + index * 0.12f + } + val barHeight = size.height * (0.32f + wave * 0.58f) + val left = index * (barWidth + gap) + drawRoundRect( + color = color, + topLeft = Offset(left, size.height - barHeight), + size = Size(barWidth, barHeight), + cornerRadius = CornerRadius(barWidth, barWidth), + ) + } + } +} + +private fun streamStatusSummary(stream: StreamSettings): String = + listOf( + formatTopBarResolution(stream.resolution), + stream.aspectRatio, + stream.codec.name, + "${stream.fps} FPS", + ).filter { it.isNotBlank() }.joinToString(" • ") + +private fun formatTopBarResolution(resolution: String): String { + val parts = resolution.lowercase(Locale.US).split("x", limit = 2) + return if (parts.size == 2 && parts.all { it.trim().isNotBlank() }) { + "${parts[0].trim()} × ${parts[1].trim()}" + } else { + resolution + } +} + +@Composable +internal fun NativeSearchField( + query: String, + onQueryChange: (String) -> Unit, + placeholder: String, + searching: Boolean = false, + modifier: Modifier = Modifier, + focusRequester: FocusRequester? = null, + onOpen: (() -> Unit)? = null, +) { + val focusManager = LocalFocusManager.current + val speechLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == Activity.RESULT_OK) { + val spoken = result.data + ?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS) + ?.firstOrNull() + if (!spoken.isNullOrBlank()) onQueryChange(spoken) + } + } + val voiceSearchIntent = remember(placeholder) { + Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH) + .putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM) + .putExtra(RecognizerIntent.EXTRA_LANGUAGE, Locale.getDefault()) + .putExtra(RecognizerIntent.EXTRA_PROMPT, placeholder) + } + Surface( + modifier = modifier.height(56.dp), + shape = RoundedCornerShape(28.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.72f), + tonalElevation = 2.dp, + ) { + Row( + Modifier + .fillMaxSize() + .padding(start = 18.dp, end = if (query.isBlank()) 18.dp else 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + painter = painterResource(R.drawable.ic_search), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(22.dp), + ) + BasicTextField( + value = query, + onValueChange = onQueryChange, + singleLine = true, + textStyle = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurface), + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + modifier = Modifier + .weight(1f) + .then(focusRequester?.let { Modifier.focusRequester(it) } ?: Modifier) + .onFocusChanged { if (it.isFocused) onOpen?.invoke() } + .onPreviewKeyEvent { handleDpadFocusMove(it, focusManager) }, + decorationBox = { innerTextField -> + Box(Modifier.fillMaxWidth()) { + if (query.isBlank()) { + Text( + placeholder, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + innerTextField() + } + }, + ) + if (query.isNotBlank()) { + if (searching) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.primary, + ) + } + IconButton(onClick = { onQueryChange("") }) { + Icon( + painter = painterResource(R.drawable.ic_clear), + contentDescription = stringResource(R.string.search_clear), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(26.dp), + ) + } + } else { + if (searching) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.primary, + ) + } + IconButton(onClick = { runCatching { speechLauncher.launch(voiceSearchIntent) } }) { + Icon( + painter = painterResource(R.drawable.ic_mic), + contentDescription = stringResource(R.string.search_voice), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(22.dp), + ) + } + } + } + } +} + +internal fun handleDpadFocusMove(event: androidx.compose.ui.input.key.KeyEvent, focusManager: FocusManager): Boolean { + if (event.type != KeyEventType.KeyDown) return false + val direction = when (event.key) { + Key.DirectionUp -> FocusDirection.Up + Key.DirectionDown -> FocusDirection.Down + Key.DirectionLeft -> FocusDirection.Left + Key.DirectionRight -> FocusDirection.Right + else -> return false + } + return focusManager.moveFocus(direction) +} + +internal fun Modifier.lockedFocusGroup(): Modifier = + focusProperties { onExit = { cancelFocusChange() } } + .focusGroup() + +private fun isNavigationToneKey(event: androidx.compose.ui.input.key.KeyEvent): Boolean = + event.type == KeyEventType.KeyDown && + event.key in setOf( + Key.DirectionUp, + Key.DirectionDown, + Key.DirectionLeft, + Key.DirectionRight, + ) + +internal fun handleVerticalDpadFocusMove(event: androidx.compose.ui.input.key.KeyEvent, focusManager: FocusManager): Boolean { + if (event.type != KeyEventType.KeyDown) return false + val direction = when (event.key) { + Key.DirectionUp -> FocusDirection.Up + Key.DirectionDown -> FocusDirection.Down + else -> return false + } + return focusManager.moveFocus(direction) +} + +/** + * Key event handler for Compose Sliders when navigated by TV remote or D-pad controller. + * - D-pad Up/Down → moves focus to the next/previous focusable element. + * - D-pad Left → decrements the slider value by [step], clamped to [min]. + * - D-pad Right → increments the slider value by [step], clamped to [max]. + * Returns true when the event is consumed (Left/Right) so that Compose does not + * move focus sideways instead of changing the value. + */ +internal fun handleSliderDpadInput( + event: androidx.compose.ui.input.key.KeyEvent, + value: Float, + min: Float, + max: Float, + step: Float, + focusManager: FocusManager, + onValueAdjusted: (Float) -> Unit, +): Boolean { + if (event.type != KeyEventType.KeyDown) return false + return when (event.key) { + Key.DirectionUp -> focusManager.moveFocus(FocusDirection.Up) + Key.DirectionDown -> focusManager.moveFocus(FocusDirection.Down) + Key.DirectionLeft -> { + val newValue = (value - step).coerceIn(min, max) + onValueAdjusted(newValue) + true + } + Key.DirectionRight -> { + val newValue = (value + step).coerceIn(min, max) + onValueAdjusted(newValue) + true + } + else -> false + } +} + +internal fun isTvActivationKey(key: Key): Boolean = + key in setOf( + Key.DirectionCenter, + Key.Enter, + Key.NumPadEnter, + ) + +internal fun isTvActivateKey(event: androidx.compose.ui.input.key.KeyEvent): Boolean = + event.type == KeyEventType.KeyUp && isTvActivationKey(event.key) diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowSessionDialogs.kt b/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowSessionDialogs.kt new file mode 100644 index 000000000..775e558c2 --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowSessionDialogs.kt @@ -0,0 +1,829 @@ +package com.opencloudgaming.opennow + +import androidx.activity.compose.BackHandler +import android.content.res.Configuration +import android.os.Build +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +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.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material.icons.rounded.Wifi +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.type +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogProperties +import java.util.Locale +import com.opencloudgaming.opennow.ui.theme.OpenNowPalette +import com.opencloudgaming.opennow.ui.theme.OpenNowRadius +import com.opencloudgaming.opennow.ui.theme.OpenNowSpacing +import com.opencloudgaming.opennow.ui.theme.numeric +import com.opencloudgaming.opennow.ui.theme.tint + +@Composable +internal fun SessionReportDialog( + report: SessionReport, + onDismiss: (dontShowAgain: Boolean) -> Unit, + onReportBug: (dontShowAgain: Boolean) -> Unit, +) { + // Four tones for a 0-100 score was more colour than information, and AccentLime vs + // AccentDefault is indistinguishable at the 0.12 alpha this fills with. + val scoreColor = when (report.rating) { + SessionReportRating.Excellent, SessionReportRating.Good -> OpenNowPalette.StatusGood + SessionReportRating.Fair -> OpenNowPalette.StatusFair + SessionReportRating.Poor -> OpenNowPalette.StatusPoor + } + val configuration = LocalConfiguration.current + val landscapeLayout = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE + var dontShowAgain by rememberSaveable(report.gameTitle, report.durationSeconds) { mutableStateOf(false) } + AlertDialog( + onDismissRequest = { onDismiss(dontShowAgain) }, + modifier = if (landscapeLayout) { + Modifier.widthIn(max = 960.dp).fillMaxWidth(0.94f) + } else { + Modifier + }, + properties = DialogProperties(usePlatformDefaultWidth = !landscapeLayout), + title = { Text(stringResource(R.string.session_report_title)) }, + text = { + if (landscapeLayout) { + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = (configuration.screenHeightDp * 0.66f).dp), + horizontalArrangement = Arrangement.spacedBy(OpenNowSpacing.lg), + verticalAlignment = Alignment.Top, + ) { + Column( + modifier = Modifier + .weight(1f) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + SessionReportSummary(report, scoreColor) + SessionReportConnection(report) + } + Column( + modifier = Modifier + .weight(1f) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + SessionReportOutcome(report) { onReportBug(dontShowAgain) } + } + } + } else { + Column( + modifier = Modifier + .heightIn(max = 510.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + SessionReportSummary(report, scoreColor) + SessionReportConnection(report) + SessionReportOutcome(report) { onReportBug(dontShowAgain) } + } + } + }, + dismissButton = { + Row( + modifier = Modifier + .clip(RoundedCornerShape(OpenNowRadius.sm)) + .clickable { dontShowAgain = !dontShowAgain }, + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox( + checked = dontShowAgain, + onCheckedChange = { dontShowAgain = it }, + ) + Text( + stringResource(R.string.session_report_dont_show_again), + color = TextMuted, + style = MaterialTheme.typography.bodySmall, + ) + } + }, + confirmButton = { Button(onClick = { onDismiss(dontShowAgain) }) { Text(stringResource(R.string.stream_panel_done)) } }, + ) +} + +@Composable +private fun SessionReportSummary(report: SessionReport, scoreColor: Color) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Surface( + color = scoreColor.copy(alpha = 0.12f), + shape = RoundedCornerShape(OpenNowRadius.lg + 2.dp), + border = BorderStroke(1.dp, scoreColor.copy(alpha = 0.38f)), + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(OpenNowSpacing.lg), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(Modifier.weight(1f)) { + Text(report.gameTitle, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Text( + stringResource( + R.string.session_report_subtitle, + formatSessionTimerDuration(report.durationSeconds), + ), + color = TextMuted, + style = MaterialTheme.typography.bodySmall, + ) + } + Column(horizontalAlignment = Alignment.End) { + Text( + stringResource(R.string.session_report_score, report.score), + color = scoreColor, + style = MaterialTheme.typography.headlineMedium.numeric(), + ) + Text(report.rating.label, color = scoreColor, style = MaterialTheme.typography.labelMedium) + } + } + } + if (report.limitedData) { + Text( + stringResource(R.string.session_report_limited_data), + color = TextMuted, + style = MaterialTheme.typography.bodySmall, + ) + } + } +} + +@Composable +private fun SessionReportConnection(report: SessionReport) { + Text(stringResource(R.string.session_report_connection), style = MaterialTheme.typography.titleSmall) + SessionReportMetricGrid( + listOf( + SessionReportMetricData( + label = stringResource(R.string.session_report_metric_latency), + value = report.averagePingMs?.let { stringResource(R.string.session_report_ms_avg, it) }, + detail = report.peakPingMs?.let { stringResource(R.string.session_report_ms_peak, it) }, + quality = report.averagePingMs?.let(StreamQuality::latency), + ), + SessionReportMetricData( + label = stringResource(R.string.session_report_metric_speed), + value = formatRuntimeBitrate(report.averageBitrateKbps), + detail = report.peakBitrateKbps?.let { + stringResource(R.string.session_report_peak, formatRuntimeBitrate(it)) + }, + ), + SessionReportMetricData( + label = stringResource(R.string.session_report_metric_loss), + value = report.packetLossPct?.let { "%.2f%%".format(Locale.US, it) }, + detail = report.packetLossPct?.let { + stringResource( + if (it <= 0.5) R.string.session_report_loss_stable + else R.string.session_report_loss_affects, + ) + }, + quality = report.packetLossPct?.let(StreamQuality::packetLoss), + ), + SessionReportMetricData( + label = stringResource(R.string.session_report_metric_jitter), + value = report.averageJitterMs?.let { "%.1f ms".format(Locale.US, it) }, + detail = stringResource(R.string.session_report_jitter_detail), + quality = report.averageJitterMs?.let(StreamQuality::jitter), + ), + SessionReportMetricData( + label = stringResource(R.string.session_report_metric_fps), + value = report.averageFps?.let { "%.1f / %d".format(Locale.US, it, report.targetFps) }, + detail = stringResource(R.string.session_report_fps_detail), + quality = report.averageFps?.let { StreamQuality.frameRate(it, report.targetFps) }, + ), + SessionReportMetricData( + label = stringResource(R.string.session_report_metric_decode), + value = report.averageDecodeMs?.let { "%.1f ms".format(Locale.US, it) }, + detail = stringResource(R.string.session_report_decode_detail), + quality = report.averageDecodeMs?.let { + StreamQuality.decode(it, report.targetFps, report.averageFps) + }, + ), + ), + ) + val networkLabel = when (report.networkKind) { + AndroidNetworkKind.Wifi -> report.wifiBand.label + else -> report.networkKind.label + } + Text( + buildString { + append("Network: $networkLabel") + report.estimatedLinkDownstreamKbps?.let { + append(" • Android link estimate ${formatRuntimeBitrate(it)}") + } + }, + color = TextMuted, + style = MaterialTheme.typography.bodySmall, + ) +} + +@Composable +private fun SessionReportOutcome(report: SessionReport, onReportBug: () -> Unit) { + Text(stringResource(R.string.session_report_delivered_profile), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold) + Text( + buildString { + append(formatRuntimeResolution(report.deliveredResolution ?: report.requestedResolution)) + append(" • ") + append(report.deliveredCodec ?: report.requestedCodec.name) + if ( + normalizeSessionReportResolution(report.deliveredResolution) != + normalizeSessionReportResolution(report.requestedResolution) || + report.deliveredCodec?.contains(report.requestedCodec.name, ignoreCase = true) == false + ) { + append(" (requested ${formatRuntimeResolution(report.requestedResolution)} • ${report.requestedCodec.name})") + } + }, + color = TextPrimary, + style = MaterialTheme.typography.bodyMedium, + ) + if (report.downgrades.isNotEmpty()) { + Text(stringResource(R.string.session_report_why_changed), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold) + report.downgrades.forEach { finding -> SessionReportFindingRow(finding) } + } + Text(stringResource(R.string.session_report_what_next), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold) + report.recommendations.forEach { finding -> SessionReportFindingRow(finding) } + TextButton( + onClick = onReportBug, + contentPadding = PaddingValues(horizontal = 0.dp, vertical = 4.dp), + ) { + Text(stringResource(R.string.session_report_bug_prompt), color = TextMuted) + Text( + stringResource(R.string.session_report_bug_action), + color = MaterialTheme.colorScheme.primary, + textDecoration = TextDecoration.Underline, + ) + } +} + +@Composable +internal fun CompletedSessionBugReportDialog( + submission: BugReportSubmissionState, + versionCheck: AndroidBugReportVersionCheckState, + update: AndroidUpdateState, + onSubmit: (String, String, String?) -> Unit, + onReset: () -> Unit, + onVersionCheck: () -> Unit, + onOpenUpdate: () -> Unit, + preflightProvider: () -> BugReportPreflightDeck, + onDismiss: () -> Unit, +) { + val configuration = LocalConfiguration.current + val appLocale = currentAndroidAppLocale(LocalContext.current) + val landscapeLayout = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE + var title by rememberSaveable { mutableStateOf("") } + var description by rememberSaveable { mutableStateOf("") } + var consentChecked by rememberSaveable { mutableStateOf(false) } + var confirmationOpen by rememberSaveable { mutableStateOf(false) } + var acknowledgedKnownIssueKey by rememberSaveable { mutableStateOf(null) } + val preflightDeck = remember { preflightProvider() } + val knownIssueBlock = bugReportKnownIssueBlock(title, description, preflightDeck) + + LaunchedEffect(update.installSource.isGooglePlay) { + if (update.installSource.isGooglePlay) onVersionCheck() + } + + AlertDialog( + onDismissRequest = { + if (!submission.uploading) onDismiss() + }, + modifier = if (landscapeLayout) { + Modifier.widthIn(max = 960.dp).fillMaxWidth(0.94f) + } else { + Modifier + }, + properties = DialogProperties(usePlatformDefaultWidth = !landscapeLayout), + title = { Text(stringResource(R.string.bug_report_dialog_title)) }, + text = { + Column( + modifier = Modifier + .heightIn( + max = if (landscapeLayout) { + (configuration.screenHeightDp * 0.68f).dp + } else { + 620.dp + }, + ) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + DiscordCommunityLink( + summary = stringResource(R.string.discord_community_bug_report_summary), + ) + when { + submission.submitted -> { + Icon(Icons.Rounded.Check, contentDescription = null, tint = Green) + Text(stringResource(R.string.bug_report_sent), color = Green, fontWeight = FontWeight.Bold) + submission.reference?.let { reference -> + CopyableBugReportId(reference) + } + } + !appLocale.bugReportsAllowed -> BugReportLocaleGateCard() + !androidBugReportsAllowed(update, versionCheck) -> BugReportVersionGateCard( + update = update, + versionCheck = versionCheck, + onRetry = onVersionCheck, + onOpenUpdate = onOpenUpdate, + ) + else -> { + Text( + stringResource(R.string.bug_report_describe_english), + color = TextMuted, + style = MaterialTheme.typography.bodySmall, + ) + OutlinedTextField( + value = title, + onValueChange = { value -> + title = value + if (submission.error != null) onReset() + }, + modifier = Modifier.fillMaxWidth(), + enabled = !submission.uploading, + singleLine = true, + label = { Text(stringResource(R.string.bug_report_title_label)) }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + ) + OutlinedTextField( + value = description, + onValueChange = { value -> + description = value + if (submission.error != null) onReset() + }, + modifier = Modifier.fillMaxWidth().heightIn(min = 128.dp), + enabled = !submission.uploading, + minLines = 4, + maxLines = 7, + label = { Text(stringResource(R.string.bug_report_description_label)) }, + supportingText = { + Text( + androidBugReportDescriptionError(description) + ?: "${androidBugReportMeaningfulCharacterCount(description)} / $ANDROID_BUG_REPORT_MIN_MEANINGFUL_CHARS meaningful characters", + ) + }, + isError = description.isNotEmpty() && + androidBugReportDescriptionError(description) != null, + ) + BugReportDescriptionFeedback( + description = description, + error = androidBugReportDescriptionError(description), + ) + BugReportDataDisclosure(includeTypedTextWarning = true) + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .clickable(enabled = !submission.uploading) { + consentChecked = !consentChecked + }, + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox( + checked = consentChecked, + onCheckedChange = { consentChecked = it }, + enabled = !submission.uploading, + ) + Text( + stringResource(R.string.bug_report_consent), + color = TextMuted, + style = MaterialTheme.typography.bodySmall, + ) + } + knownIssueBlock?.let { block -> + BugReportKnownIssueOverride( + block = block, + checked = acknowledgedKnownIssueKey == block.key, + enabled = !submission.uploading, + onCheckedChange = { checked -> + acknowledgedKnownIssueKey = block.key.takeIf { checked } + }, + ) + } + submission.error?.let { error -> + Text(error, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) + } + } + } + } + }, + confirmButton = { + when { + submission.submitted -> Button(onClick = onDismiss) { Text(stringResource(R.string.stream_panel_done)) } + submission.uploading -> Button(enabled = false, onClick = {}) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.bug_report_sending)) + } + appLocale.bugReportsAllowed && androidBugReportsAllowed(update, versionCheck) -> Button( + onClick = { confirmationOpen = true }, + enabled = androidBugReportTitleError(title) == null && + androidBugReportDescriptionError(description) == null && + consentChecked && + bugReportKnownIssueAllowsSubmission(knownIssueBlock, acknowledgedKnownIssueKey), + ) { + Text(stringResource(if (knownIssueBlock == null) R.string.bug_report_review_send else R.string.bug_report_send_anyway)) + } + } + }, + dismissButton = { + if (!submission.submitted) { + TextButton(onClick = onDismiss, enabled = !submission.uploading) { + Text(stringResource(R.string.action_close)) + } + } + }, + ) + + if (confirmationOpen) { + AlertDialog( + onDismissRequest = { confirmationOpen = false }, + title = { Text(stringResource(R.string.bug_report_confirm_title)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + knownIssueBlock?.let { block -> + Text(block.title, color = Color(0xffffc266), fontWeight = FontWeight.Bold) + Text(block.action, color = TextMuted, style = MaterialTheme.typography.bodySmall) + } + Text(stringResource(R.string.bug_report_confirm_body)) + } + }, + confirmButton = { + Button( + onClick = { + confirmationOpen = false + onSubmit( + title, + description, + knownIssueBlock?.key?.takeIf { it == acknowledgedKnownIssueKey }, + ) + }, + ) { + Text(if (knownIssueBlock == null) "Send" else "Send anyway") + } + }, + dismissButton = { + TextButton(onClick = { confirmationOpen = false }) { + Text(stringResource(R.string.action_back)) + } + }, + ) + } +} + +private data class SessionReportMetricData( + val label: String, + /** Null when the metric was never measured. */ + val value: String?, + val detail: String?, + val quality: StreamQualityLevel? = null, +) + +/** + * Six cards in an even two- or three-column grid. + * + * They used to be a FlowRow of fixed 136dp cards, which left a ragged right edge at every width + * and, because `value` was unbounded while `detail` was capped at one line, let cards in the same + * row end up different heights. + */ +@Composable +private fun SessionReportMetricGrid(metrics: List) { + BoxWithConstraints(Modifier.fillMaxWidth()) { + val columns = if (maxWidth >= 520.dp) 3 else 2 + Column(verticalArrangement = Arrangement.spacedBy(OpenNowSpacing.sm)) { + metrics.chunked(columns).forEach { row -> + Row(horizontalArrangement = Arrangement.spacedBy(OpenNowSpacing.sm)) { + row.forEach { metric -> SessionReportMetric(metric, Modifier.weight(1f)) } + // Six items divide evenly into 2 and 3, so this is defensive only. + repeat(columns - row.size) { Spacer(Modifier.weight(1f)) } + } + } + } + } +} + +@Composable +private fun SessionReportMetric(metric: SessionReportMetricData, modifier: Modifier = Modifier) { + val notMeasured = stringResource(R.string.session_report_not_measured) + Surface( + modifier = modifier, + color = PanelAlt, + shape = RoundedCornerShape(OpenNowRadius.md), + ) { + // A fixed three-line structure keeps every card the same height without an intrinsics + // pass, which would be a second measure inside an already-scrolling dialog. + Column(Modifier.padding(horizontal = OpenNowSpacing.md, vertical = 10.dp)) { + Text(metric.label, color = TextMuted, style = MaterialTheme.typography.labelSmall, maxLines = 1) + Text( + metric.value ?: notMeasured, + color = metric.quality?.tint() ?: TextPrimary, + style = MaterialTheme.typography.bodyMedium.numeric(), + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + // Rendered even when absent so the line box is still reserved. + Text( + metric.detail.orEmpty(), + color = TextMuted, + style = MaterialTheme.typography.labelSmall.numeric(), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@Composable +private fun SessionReportFindingRow(finding: SessionReportFinding) { + val titleColor = if (finding.kind == SessionReportFindingKind.Warning) OpenNowPalette.StatusFair else Green + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(finding.title, color = titleColor, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold) + Text(finding.detail, color = TextMuted, style = MaterialTheme.typography.bodySmall) + } +} + +private fun normalizeSessionReportResolution(value: String?): Pair? = + value?.let(::parseResolutionPixelsOrNull) + +@Composable +internal fun DiagnosticShareDialog( + state: OpenNowUiState, + onUpload: () -> Unit, + onDismiss: () -> Unit, +) { + val share = state.diagnosticShare + if (!share.awaitingConsent && !share.uploading && share.pasteUrl == null) return + val clipboard = LocalClipboardManager.current + LaunchedEffect(share.clipboardSummary, state.androidTvProfile) { + if (!state.androidTvProfile) { + share.clipboardSummary?.let { clipboard.setText(AnnotatedString(it)) } + } + } + when { + share.uploading -> AlertDialog( + onDismissRequest = {}, + title = { Text(stringResource(R.string.diagnostics_preparing_title)) }, + text = { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + CircularProgressIndicator(Modifier.size(28.dp)) + Text(stringResource(R.string.diagnostics_preparing_body)) + } + }, + confirmButton = {}, + ) + share.pasteUrl != null -> { + val qrCode = remember(share.pasteUrl) { QrCode.encodeText(share.pasteUrl) } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(if (state.androidTvProfile) R.string.diagnostics_scan_title else R.string.diagnostics_copied_title)) }, + text = { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (state.androidTvProfile) { + if (qrCode != null) { + QrCodeView(qrCode, Modifier.size(240.dp)) + Text(stringResource(R.string.diagnostics_scan_body)) + } else { + Text(stringResource(R.string.diagnostics_qr_failed)) + } + } else { + Text(stringResource(R.string.diagnostics_clipboard_body)) + Text( + share.pasteUrl, + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center, + ) + } + } + }, + confirmButton = { Button(onClick = onDismiss) { Text(stringResource(R.string.stream_panel_done)) } }, + ) + } + else -> AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.diagnostics_create_title)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text(stringResource(R.string.diagnostics_create_body)) + Text(stringResource(R.string.diagnostics_create_caveat), color = TextMuted) + share.error?.let { Text(it, color = MaterialTheme.colorScheme.error) } + } + }, + confirmButton = { Button(onClick = onUpload) { Text(stringResource(if (share.error == null) R.string.diagnostics_upload_action else R.string.action_retry)) } }, + dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } }, + ) + } +} + +@Composable +internal fun AnalyticsConsentDialog( + onAllow: () -> Unit, + onDecline: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDecline, + title = { Text(stringResource(R.string.analytics_consent_title)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text( + stringResource(R.string.analytics_consent_body), + ) + Text( + stringResource(R.string.analytics_consent_caveat), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + }, + confirmButton = { + Button(onClick = onAllow) { + Text(stringResource(R.string.analytics_consent_allow)) + } + }, + dismissButton = { + TextButton(onClick = onDecline) { + Text(stringResource(R.string.analytics_consent_decline)) + } + }, + ) +} + +@Composable +internal fun AndroidUpdatePromptDialog( + update: AndroidUpdateState, + onPrimary: () -> Unit, + onDetails: () -> Unit, + onDismiss: () -> Unit, +) { + val version = update.availableVersionName?.let { "Version $it" } + ?: update.availableVersionCode?.let { "Build $it" } + ?: "A new build" + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(if (update.status == AndroidUpdateStatus.Downloaded) "Update ready" else "OpenNOW update available") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text( + if (update.status == AndroidUpdateStatus.Downloaded) { + "$version is downloaded and ready to install." + } else if (update.installSource.usesGooglePlayUpdates) { + "You are on build ${update.currentVersionCode}. Google Play has ${version.lowercase()}." + } else { + "$version is available for this device." + }, + ) + update.releaseNotes?.trim()?.takeIf { it.isNotBlank() }?.let { notes -> + Text( + notes, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, + maxLines = 8, + overflow = TextOverflow.Ellipsis, + ) + } + } + }, + confirmButton = { + Button(onClick = onPrimary) { + Text( + when { + update.status == AndroidUpdateStatus.Downloaded -> "Install" + update.installSource.usesGooglePlayUpdates -> "Update" + else -> "Download" + }, + ) + } + }, + dismissButton = { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton(onClick = onDetails) { + Text(stringResource(R.string.common_details)) + } + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.action_cancel)) + } + } + }, + ) +} + +@Composable +private fun LoadingScreen(text: String) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp)) { + OpenNowMark(72.dp) + CircularProgressIndicator(color = MaterialTheme.colorScheme.primary) + Text(text, color = TextMuted) + } + } +} + +/** + * Shown when Play is pressed on a game whose membership tier the account does not meet. + * + * Informational, not a block. GeForce NOW is the authority on entitlement and it can change under + * us — a new promotion, a plan that just renewed, a label the catalogue has wrong — so the launch + * stays one tap away. What the warning buys is that a refusal further down now has an explanation + * attached to it, instead of looking like OpenNOW failing to start the game. + */ +@Composable +internal fun MembershipRequirementDialog( + notice: PendingMembershipNotice, + onCancel: () -> Unit, + onContinue: () -> Unit, +) { + BackHandler(onBack = onCancel) + AlertDialog( + onDismissRequest = onCancel, + title = { + Text( + stringResource(R.string.membership_gate_title, notice.requirement.requiredPlanLabel), + fontWeight = FontWeight.Bold, + ) + }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text( + stringResource( + R.string.membership_gate_body, + notice.game.title, + notice.requirement.requiredPlanLabel, + notice.requirement.currentPlanLabel, + ), + ) + Text( + stringResource(R.string.membership_gate_hint), + color = TextMuted, + style = MaterialTheme.typography.bodySmall, + ) + } + }, + confirmButton = { + Button(onClick = onContinue) { + Text(stringResource(R.string.membership_gate_continue)) + } + }, + dismissButton = { + TextButton(onClick = onCancel) { + Text(stringResource(R.string.action_cancel)) + } + }, + ) +} diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowSettingsControls.kt b/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowSettingsControls.kt new file mode 100644 index 000000000..8f2d036cb --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowSettingsControls.kt @@ -0,0 +1,312 @@ +package com.opencloudgaming.opennow + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.opencloudgaming.opennow.ui.controls.ControlRow +import com.opencloudgaming.opennow.ui.controls.ControlRowLabels +import com.opencloudgaming.opennow.ui.controls.ControlSection +import com.opencloudgaming.opennow.ui.controls.ControlSliderRow +import com.opencloudgaming.opennow.ui.controls.ControlSwitchRow +import java.util.Locale +import kotlin.math.roundToInt + +@Composable +internal fun SearchableSettingsSection( + searchQuery: String, + title: String, + vararg keywords: String, + content: @Composable ColumnScope.() -> Unit, +) { + if (settingsSearchMatches(searchQuery, title, *keywords)) { + SettingsSection(title, content) + } +} + +private fun settingsSearchMatches(searchQuery: String, vararg terms: String): Boolean { + val tokens = searchQuery.trim().lowercase(Locale.US).split(Regex("\\s+")).filter { it.isNotBlank() } + if (tokens.isEmpty()) return true + val haystack = terms.joinToString(" ").lowercase(Locale.US) + return tokens.all { token -> token in haystack } +} + +@Composable +private fun SettingsSection(title: String, content: @Composable ColumnScope.() -> Unit) { + ControlSection(title = title, content = content) +} + +@Composable +internal fun SettingSwitch( + label: String, + checked: Boolean, + enabled: Boolean = true, + description: String? = null, + indentLevel: Int = 0, + onCheckedChange: (Boolean) -> Unit, +) { + ControlSwitchRow( + label = label, + checked = checked, + onCheckedChange = onCheckedChange, + description = description, + enabled = enabled, + indentLevel = indentLevel, + ) +} + +@Composable +internal fun SessionProxyWarningDialog(onCancel: () -> Unit, onEnable: () -> Unit) { + AlertDialog( + onDismissRequest = onCancel, + title = { Text(stringResource(R.string.settings_session_proxy_warning_title), fontWeight = FontWeight.Bold) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text(stringResource(R.string.settings_session_proxy_warning_traffic), style = MaterialTheme.typography.bodySmall) + Text(stringResource(R.string.settings_session_proxy_warning_breakage), style = MaterialTheme.typography.bodySmall) + Text(stringResource(R.string.settings_session_proxy_warning_trust), style = MaterialTheme.typography.bodySmall) + } + }, + confirmButton = { + Button(onClick = onEnable) { + Text(stringResource(R.string.settings_session_proxy_warning_enable)) + } + }, + dismissButton = { + TextButton(onClick = onCancel) { + Text(stringResource(R.string.action_cancel)) + } + }, + containerColor = SettingsPanel, + titleContentColor = SettingsText, + textContentColor = SettingsTextMuted, + ) +} + +/** + * Turns a raw slider value into something readable. + * + * Every sub-integer slider used to render as `"%.2f"`, so opacity showed `0.75` and card size + * showed `1.00` — numbers with no stated unit and no obvious meaning. Fractional 0..1 ranges now + * read as percentages, and anything else gets its unit appended. + */ +internal fun formatSliderValue( + value: Float, + min: Float, + max: Float, + step: Float, + unit: String? = null, + valueFormatter: ((Float) -> String)? = null, +): String { + valueFormatter?.let { return it(value) } + val isFraction = step < 1f + val looksLikeRatio = isFraction && min >= 0f && max <= 2f + return when { + looksLikeRatio && unit == null -> "${(value * 100f).roundToInt()}%" + isFraction -> buildString { + append("%.2f".format(value)) + unit?.let { append(' ').append(it) } + } + else -> buildString { + append(value.roundToInt()) + unit?.let { append(' ').append(it) } + } + } +} + +@Composable +internal fun NumberSlider( + label: String, + value: Float, + min: Float, + max: Float, + step: Float, + /** Appended to the value, e.g. "FPS", "ms", "dp". Ignored when [valueFormatter] is supplied. */ + unit: String? = null, + /** Full control over the readout when neither the percent nor the unit default fits. */ + valueFormatter: ((Float) -> String)? = null, + description: String? = null, + descriptionProvider: ((Float) -> String?)? = null, + onChange: (Float) -> Unit, +) { + ControlSliderRow( + label = label, + value = value, + min = min, + max = max, + step = step, + onChange = onChange, + unit = unit, + valueFormatter = valueFormatter, + description = description, + descriptionProvider = descriptionProvider, + ) +} + +@Composable +internal fun ChoiceRow( + label: String, + options: List, + selected: String, + description: String? = null, + activeOutlineColor: Color? = null, + activeOutlineSecondaryColor: Color? = null, + onSelect: (String) -> Unit, +) { + ChoiceMenuRow( + label = label, + options = options.map { ChoiceMenuOption(value = it, label = it) }, + selectedLabel = selected, + description = description, + activeOutlineColor = activeOutlineColor, + activeOutlineSecondaryColor = activeOutlineSecondaryColor, + onSelect = onSelect, + ) +} + +@Composable +internal fun ChoiceMenuRow( + label: String, + options: List, + selectedLabel: String, + description: String? = null, + activeOutlineColor: Color? = null, + activeOutlineSecondaryColor: Color? = null, + onSelect: (String) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + var descriptionExpanded by remember(label) { mutableStateOf(false) } + BackHandler(enabled = expanded) { expanded = false } + val autoLabel = stringResource(R.string.option_auto) + // Outer chrome comes from the shared row; the dropdown body below is specific to this control. + ControlRow(onClick = { expanded = true }) { + ControlRowLabels( + label = label, + value = null, + expandedDescription = description?.takeIf { descriptionExpanded }, + enabled = true, + style = com.opencloudgaming.opennow.ui.controls.controlRowStyle(), + ) + if (!description.isNullOrBlank()) { + IconButton( + onClick = { descriptionExpanded = !descriptionExpanded }, + modifier = Modifier.padding(horizontal = 2.dp), + ) { + Icon( + painter = painterResource(R.drawable.ic_help), + contentDescription = stringResource( + if (descriptionExpanded) R.string.control_hide_description + else R.string.control_show_description, + ), + tint = MaterialTheme.colorScheme.primary, + ) + } + } + Box { + OutlinedButton(onClick = { expanded = true }) { Text(selectedLabel.ifBlank { autoLabel }, maxLines = 1, overflow = TextOverflow.Ellipsis) } + ControllerFocusFrame( + visible = activeOutlineColor != null, + cornerRadius = 20.dp, + tint = activeOutlineColor, + secondaryTint = activeOutlineSecondaryColor, + // Material's button keeps a 48 dp touch target around its visible 40 dp pill. + // Follow the pill so the Cinema orbit is vertically centered on the border. + verticalInset = 4.dp, + ) + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + options.forEach { option -> + var optionFocused by remember(option.value) { mutableStateOf(false) } + Box { + DropdownMenuItem( + text = { + val disabledAlpha = if (option.enabled) 1f else 0.48f + val badgeAlpha = if (option.enabled) 0.7f else 0.48f + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + option.label, + color = if (option.enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = disabledAlpha), + ) + option.badge?.let { badge -> + Text( + badge, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = badgeAlpha), + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier + .border(1.dp, MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = if (option.enabled) 0.3f else 0.2f), RoundedCornerShape(3.dp)) + .padding(horizontal = 4.dp, vertical = 1.dp), + ) + } + } + }, + enabled = option.enabled, + modifier = Modifier.onFocusChanged { + optionFocused = option.enabled && (it.isFocused || it.hasFocus) + }, + onClick = { + expanded = false + onSelect(option.value) + }, + ) + InteractionFocusFrame( + visible = optionFocused, + cornerRadius = 4.dp, + cinemaEffectEnabled = LocalAbsoluteCinemaEverywhere.current, + ) + ControllerFocusFrame( + visible = !optionFocused && option.label == selectedLabel && activeOutlineColor != null, + cornerRadius = 4.dp, + tint = activeOutlineColor, + secondaryTint = activeOutlineSecondaryColor, + ) + } + } + } + } + } +} + +@Composable +internal fun ChoiceOptionRow( + label: String, + options: List, + selectedValue: String, + description: String? = null, + onSelect: (String) -> Unit, +) { + val selectedLabel = options.firstOrNull { it.value == selectedValue }?.label ?: selectedValue + ChoiceRow(label, options.map { it.label }, selectedLabel, description = description) { selected -> + options.firstOrNull { it.label == selected }?.value?.let(onSelect) + } +} diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowSettingsPanels.kt b/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowSettingsPanels.kt new file mode 100644 index 000000000..1ea23ea10 --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowSettingsPanels.kt @@ -0,0 +1,1938 @@ +package com.opencloudgaming.opennow + +import android.Manifest +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.ProgressBarRangeInfo +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.progressBarRangeInfo +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.ui.unit.dp +import java.text.DateFormat +import java.util.Date +import java.util.Locale +import kotlin.math.roundToInt +import android.os.PowerManager +import android.os.BatteryManager +import android.os.Build +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.net.Uri +import android.provider.Settings +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.foundation.layout.Spacer +import kotlinx.coroutines.delay +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import com.google.mlkit.vision.barcode.common.Barcode +import com.google.mlkit.vision.codescanner.GmsBarcodeScannerOptions +import com.google.mlkit.vision.codescanner.GmsBarcodeScanning +import com.opencloudgaming.opennow.ui.controls.ControlNavigationRow + +@Composable +internal fun AppDataSettingsPanel(viewModel: OpenNowViewModel) { + var clearCacheConfirmOpen by remember { mutableStateOf(false) } + var resetSettingsConfirmOpen by remember { mutableStateOf(false) } + if (clearCacheConfirmOpen) { + AlertDialog( + onDismissRequest = { clearCacheConfirmOpen = false }, + title = { Text(stringResource(R.string.settings_clear_cache_title)) }, + text = { Text(stringResource(R.string.settings_clear_cache_body)) }, + confirmButton = { + Button( + onClick = { + clearCacheConfirmOpen = false + viewModel.clearCatalogCache() + }, + ) { + Text(stringResource(R.string.settings_clear_cache)) + } + }, + dismissButton = { + TextButton(onClick = { clearCacheConfirmOpen = false }) { + Text(stringResource(R.string.action_cancel)) + } + }, + ) + } + if (resetSettingsConfirmOpen) { + AlertDialog( + onDismissRequest = { resetSettingsConfirmOpen = false }, + title = { Text(stringResource(R.string.settings_reset_title)) }, + text = { Text(stringResource(R.string.settings_reset_body)) }, + confirmButton = { + Button( + onClick = { + resetSettingsConfirmOpen = false + viewModel.resetSettings() + }, + ) { + Text(stringResource(R.string.settings_reset_and_relaunch)) + } + }, + dismissButton = { + TextButton(onClick = { resetSettingsConfirmOpen = false }) { + Text(stringResource(R.string.action_cancel)) + } + }, + ) + } + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text( + stringResource(R.string.settings_reset_explainer), + color = SettingsTextMuted, + style = MaterialTheme.typography.bodySmall, + ) + Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + OutlinedButton(onClick = { clearCacheConfirmOpen = true }, modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.settings_clear_cache), maxLines = 1, overflow = TextOverflow.Ellipsis) + } + OutlinedButton(onClick = viewModel::resetStreamTutorial, modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.settings_reset_tutorial), maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + OutlinedButton(onClick = { resetSettingsConfirmOpen = true }, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.settings_reset_settings), maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } +} + +@Composable +internal fun AndroidUpdatePanel(state: OpenNowUiState, viewModel: OpenNowViewModel) { + val update = state.androidUpdate + if (!update.updateChecksSupported) { + AndroidUpdateUnavailablePanel(update) + return + } + val updateCheckingDisabled = !state.settings.autoCheckForUpdates + val checkBlockedByStream = state.isAndroidUpdateCheckBlockedByStream() + val showCheckPauseMessage = checkBlockedByStream && when (update.status) { + AndroidUpdateStatus.Available, + AndroidUpdateStatus.Downloading, + AndroidUpdateStatus.Downloaded -> false + else -> true + } + val statusMessage = when { + updateCheckingDisabled -> "Automatic checks are off." + showCheckPauseMessage -> "Checks pause while streaming." + else -> updateStatusSubtitle(update) + } + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(22.dp), + color = if (update.status in updateAvailableStatuses) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.12f) + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.72f) + }, + ) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text( + updateStatusTitle(update), + color = SettingsText, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + statusMessage, + color = SettingsTextMuted, + style = MaterialTheme.typography.bodySmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + UpdateStatusBadge(update.status) + } + UpdateVersionSummary(update) + if (update.status == AndroidUpdateStatus.Downloading) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + LinearProgressIndicator(Modifier.fillMaxWidth()) + update.progress?.let { progress -> + Text( + formatAndroidUpdateProgress(progress), + color = SettingsTextMuted, + style = MaterialTheme.typography.labelSmall, + ) + } + } + } + UpdateReleaseNotes(update.releaseNotes) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + OutlinedButton( + onClick = viewModel::checkAndroidUpdate, + enabled = update.canCheck && !checkBlockedByStream && !updateCheckingDisabled, + modifier = Modifier.weight(1f), + ) { + Text(if (update.status == AndroidUpdateStatus.Checking) "Checking..." else "Check", maxLines = 1, overflow = TextOverflow.Ellipsis) + } + when { + update.status == AndroidUpdateStatus.Available -> { + Button( + onClick = viewModel::performAndroidUpdatePrimaryAction, + enabled = update.canDownload || update.canOpenPlayStore, + modifier = Modifier.weight(1f), + ) { + Text( + if (update.installSource.usesGooglePlayUpdates) "Update" else "Download", + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + update.status == AndroidUpdateStatus.Downloaded -> { + Button( + onClick = viewModel::installAndroidUpdate, + enabled = update.canInstall, + modifier = Modifier.weight(1f), + ) { + Text(stringResource(R.string.action_install), maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } + } + } + } +} + +@Composable +private fun AndroidUpdateUnavailablePanel(update: AndroidUpdateState) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(22.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.72f), + ) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text( + if (update.installSource.usesGooglePlayUpdates) "Updates managed by Google Play" else "APK updates unavailable", + color = SettingsText, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Text( + update.message, + color = SettingsTextMuted, + style = MaterialTheme.typography.bodySmall, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } + Surface( + shape = RoundedCornerShape(999.dp), + color = MaterialTheme.colorScheme.secondary.copy(alpha = 0.16f), + ) { + Text( + if (update.installSource.usesGooglePlayUpdates) "PLAY" else "LOCKED", + modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp), + color = MaterialTheme.colorScheme.secondary, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + maxLines = 1, + ) + } + } + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.52f), + ) { + Row(Modifier.padding(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + UpdateInfoValue("Current", formatCurrentUpdateVersion(update), Modifier.weight(1f)) + UpdateInfoValue("Source", update.installSource.displayName, Modifier.weight(1f)) + } + } + } + } +} + +@Composable +private fun UpdateStatusBadge(status: AndroidUpdateStatus) { + Surface( + shape = RoundedCornerShape(999.dp), + color = updateMessageColor(status).copy(alpha = 0.16f), + ) { + Text( + updateStatusBadgeText(status), + modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp), + color = updateMessageColor(status), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + maxLines = 1, + ) + } +} + +private val updateAvailableStatuses = setOf( + AndroidUpdateStatus.Available, + AndroidUpdateStatus.Downloading, + AndroidUpdateStatus.Downloaded, +) + +@Composable +private fun UpdateVersionSummary(update: AndroidUpdateState) { + val checked = update.lastCheckedAt?.let { checkedAt -> + DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT).format(Date(checkedAt)) + } + val availableVersion = formatAvailableUpdateVersion(update) + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.52f), + ) { + Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) { + UpdateInfoValue("Current", formatCurrentUpdateVersion(update), Modifier.weight(1f)) + availableVersion?.let { + UpdateInfoValue("Available", it, Modifier.weight(1f)) + } + } + checked?.let { + Text( + "Last checked $it", + color = SettingsTextMuted, + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +@Composable +private fun UpdateInfoValue(label: String, value: String, modifier: Modifier = Modifier) { + Column(modifier, verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + label, + color = SettingsTextMuted, + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + value, + color = SettingsText, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun UpdateReleaseNotes(notes: String?) { + val releaseNotes = notes?.trim()?.takeIf { it.isNotBlank() } ?: return + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.52f), + ) { + Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + stringResource(R.string.settings_release_notes), + color = SettingsText, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + ) + Text( + releaseNotes, + color = SettingsTextMuted, + style = MaterialTheme.typography.bodySmall, + maxLines = 8, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +private fun formatAvailableUpdateVersion(update: AndroidUpdateState): String? { + val pieces = listOfNotNull( + update.availableVersionName?.let { "v$it" }, + update.availableVersionCode?.let { "build $it" }, + ) + return pieces.takeIf { it.isNotEmpty() }?.joinToString(" ") +} + +private fun formatCurrentUpdateVersion(update: AndroidUpdateState): String = + listOfNotNull( + update.currentVersionName.takeIf(String::isNotBlank)?.let { "v$it" }, + "build ${update.currentVersionCode}", + ).joinToString(" ") + +private fun updateStatusTitle(update: AndroidUpdateState): String = + when (update.status) { + AndroidUpdateStatus.Available -> "Update available" + AndroidUpdateStatus.Downloading -> "Downloading update" + AndroidUpdateStatus.Downloaded -> "Ready to install" + AndroidUpdateStatus.NotAvailable -> "OpenNOW is up to date" + AndroidUpdateStatus.Checking -> "Checking for updates" + AndroidUpdateStatus.Error -> "Update check failed" + AndroidUpdateStatus.Idle -> "App updates" + } + +private fun updateStatusSubtitle(update: AndroidUpdateState): String = + when (update.status) { + AndroidUpdateStatus.Available -> if (update.installSource.usesGooglePlayUpdates) { + update.message + } else { + update.availableVersionName?.let { "Version $it is available." } ?: "A new build is available." + } + AndroidUpdateStatus.Downloading -> "Keep OpenNOW open while the APK downloads." + AndroidUpdateStatus.Downloaded -> update.availableVersionName?.let { "Version $it has been downloaded." } ?: "The update has been downloaded." + AndroidUpdateStatus.NotAvailable -> update.message + AndroidUpdateStatus.Checking -> if (update.installSource.usesGooglePlayUpdates) "Checking Google Play." else "Contacting the update source." + AndroidUpdateStatus.Error -> update.message + AndroidUpdateStatus.Idle -> update.message + } + +private fun updateStatusBadgeText(status: AndroidUpdateStatus): String = + when (status) { + AndroidUpdateStatus.Available -> "NEW" + AndroidUpdateStatus.Downloading -> "DOWNLOADING" + AndroidUpdateStatus.Downloaded -> "READY" + AndroidUpdateStatus.NotAvailable -> "CURRENT" + AndroidUpdateStatus.Checking -> "CHECKING" + AndroidUpdateStatus.Error -> "ERROR" + AndroidUpdateStatus.Idle -> "IDLE" + } + +@Composable +private fun updateMessageColor(status: AndroidUpdateStatus): Color = + when (status) { + AndroidUpdateStatus.Available, + AndroidUpdateStatus.Downloaded, + AndroidUpdateStatus.NotAvailable -> MaterialTheme.colorScheme.primary + AndroidUpdateStatus.Error -> Color(0xffff9f9f) + else -> SettingsTextMuted + } + +private fun formatAndroidUpdateProgress(progress: AndroidUpdateProgress): String { + val bytes = progress.totalBytes?.let { total -> + "${formatUpdateBytes(progress.transferredBytes)} / ${formatUpdateBytes(total)}" + } ?: formatUpdateBytes(progress.transferredBytes) + return progress.percent?.let { "$it% - $bytes" } ?: bytes +} + +private fun formatUpdateBytes(bytes: Long): String { + if (bytes < 1024L) return "$bytes B" + val units = listOf("KB", "MB", "GB") + var value = bytes.toDouble() / 1024.0 + var unit = units.first() + for (index in 1 until units.size) { + if (value < 1024.0) break + value /= 1024.0 + unit = units[index] + } + return "%.1f %s".format(Locale.US, value, unit) +} + +@Composable +internal fun LocalTvSettingsPanel( + state: OpenNowUiState, + viewModel: OpenNowViewModel, + showTitle: Boolean = true, +) { + val connector = state.localTvConnector + val context = LocalContext.current + val scannerOptions = remember { + GmsBarcodeScannerOptions.Builder() + .setBarcodeFormats(Barcode.FORMAT_QR_CODE) + .enableAutoZoom() + .build() + } + val scanTvQr = { + runCatching { + GmsBarcodeScanning + .getClient(context.applicationContext, scannerOptions) + .startScan() + }.onSuccess { scanTask -> + scanTask + .addOnSuccessListener { barcode -> + viewModel.pairLocalTvQrValue(barcode.rawValue) + } + .addOnFailureListener { error -> + Toast.makeText( + context, + error.message ?: context.getString(R.string.tv_pair_scan_failed), + Toast.LENGTH_LONG, + ).show() + } + }.onFailure { error -> + Toast.makeText( + context, + error.message ?: context.getString(R.string.tv_pair_scan_failed), + Toast.LENGTH_LONG, + ).show() + } + } + val performLocalNetworkAction: (LocalTvNetworkAction) -> Unit = { action -> + when (action) { + LocalTvNetworkAction.Discover -> viewModel.discoverLocalTvs() + LocalTvNetworkAction.ScanQr -> scanTvQr() + LocalTvNetworkAction.SignIn -> viewModel.signInLocalTv() + } + } + var pendingLocalNetworkAction by remember { mutableStateOf(null) } + val localNetworkPermissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { granted -> + val pendingAction = pendingLocalNetworkAction + pendingLocalNetworkAction = null + if (granted && pendingAction != null) performLocalNetworkAction(pendingAction) + } + val requestLocalNetworkAction: (LocalTvNetworkAction) -> Unit = { action -> + if (context.hasAndroidLocalNetworkAccess()) { + performLocalNetworkAction(action) + } else { + pendingLocalNetworkAction = action + localNetworkPermissionLauncher.launch(Manifest.permission.ACCESS_LOCAL_NETWORK) + } + } + LaunchedEffect(state.androidTvProfile) { + if ( + !state.androidTvProfile && + connector.connectedTvName == null && + context.hasAndroidLocalNetworkAccess() + ) { + viewModel.discoverLocalTvs() + } + } + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + if (showTitle) { + Text( + stringResource(R.string.tv_pair_settings_title), + color = SettingsText, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + } + if (state.androidTvProfile) { + TvPhonePairingPanel(state = state, viewModel = viewModel) + } else { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.76f), + ) { + Column( + Modifier.padding(14.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + stringResource(R.string.tv_pair_phone_title), + color = SettingsText, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + Text( + connector.connectedTvName?.let { + stringResource(R.string.tv_pair_phone_connected, it) + } ?: stringResource(R.string.tv_pair_phone_instructions), + color = SettingsTextMuted, + style = MaterialTheme.typography.bodySmall, + ) + if (connector.connectedTvName == null) { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Button( + onClick = { requestLocalNetworkAction(LocalTvNetworkAction.ScanQr) }, + enabled = !connector.busy, + modifier = Modifier.weight(1f), + ) { + Text(stringResource(R.string.tv_pair_scan_qr), maxLines = 1) + } + OutlinedButton( + onClick = { requestLocalNetworkAction(LocalTvNetworkAction.Discover) }, + enabled = !connector.discovering && !connector.busy, + modifier = Modifier.weight(1f), + ) { + Text( + stringResource( + if (connector.discovering) R.string.tv_pair_discovering + else R.string.tv_pair_find_tv, + ), + maxLines = 1, + ) + } + } + connector.discoveredTvs.forEach { tv -> + DiscoveredTvPairingRow( + tv = tv, + busy = connector.busy, + onPair = { code -> viewModel.pairDiscoveredLocalTv(tv, code) }, + ) + } + if (connector.discoveryCompleted && connector.discoveredTvs.isEmpty() && connector.error == null) { + Text( + stringResource(R.string.tv_pair_none_found), + color = SettingsTextMuted, + style = MaterialTheme.typography.bodySmall, + ) + } + } + connector.message?.let { message -> + Text(message, color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.bodySmall) + } + connector.error?.let { error -> + Text(error, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) + } + if (connector.connectedTvName != null) { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Button( + onClick = { requestLocalNetworkAction(LocalTvNetworkAction.SignIn) }, + enabled = !connector.busy, + modifier = Modifier.weight(1f), + ) { + Text(stringResource(R.string.tv_pair_phone_sign_in), maxLines = 1) + } + OutlinedButton( + onClick = viewModel::forgetLocalTvConnector, + enabled = !connector.busy, + modifier = Modifier.weight(1f), + ) { + Text(stringResource(R.string.tv_pair_phone_forget), maxLines = 1) + } + } + } + } + } + } + } +} + +private enum class LocalTvNetworkAction { + Discover, + ScanQr, + SignIn, +} + +@Composable +private fun DiscoveredTvPairingRow( + tv: DiscoveredLocalTv, + busy: Boolean, + onPair: (String) -> Unit, +) { + var code by remember(tv.pairUri) { mutableStateOf("") } + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.72f), + ) { + Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(tv.name, color = SettingsText, fontWeight = FontWeight.SemiBold) + Text( + stringResource(R.string.tv_pair_code_hint), + color = SettingsTextMuted, + style = MaterialTheme.typography.bodySmall, + ) + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + value = code, + onValueChange = { next -> code = next.filter(Char::isDigit).take(4) }, + label = { Text(stringResource(R.string.tv_pair_code)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword), + singleLine = true, + modifier = Modifier.weight(1f), + ) + Button( + onClick = { onPair(code) }, + enabled = code.length == 4 && !busy, + ) { + Text(stringResource(R.string.tv_pair_pair_action)) + } + } + } + } +} + +@Composable +internal fun AccountSettingsPanel( + state: OpenNowUiState, + viewModel: OpenNowViewModel, + onOpenTvPairing: () -> Unit, + searchMode: Boolean = false, +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + if (!searchMode) { + ControlNavigationRow( + label = stringResource(R.string.tv_pair_settings_title), + value = state.localTvConnector.connectedTvName?.let { + stringResource(R.string.tv_pair_phone_connected_short, it) + } ?: stringResource(R.string.tv_pair_settings_summary), + onClick = onOpenTvPairing, + ) + } + AccountServicesSettingsPanel(state = state, viewModel = viewModel) + } +} + +@Composable +private fun AccountServicesSettingsPanel(state: OpenNowUiState, viewModel: OpenNowViewModel) { + val currentSession = state.authSession + val currentUserId = currentSession?.user?.userId + val context = LocalContext.current + var addAccountPromptOpen by remember { mutableStateOf(false) } + val addAccountProviders = remember(state.providers, state.selectedProvider) { + accountProviderOptions(state.providers, state.selectedProvider) + } + if (addAccountPromptOpen) { + AddAccountProviderDialog( + providers = addAccountProviders, + selectedProvider = state.selectedProvider, + onProviderSelected = { provider -> + addAccountPromptOpen = false + viewModel.selectProvider(provider) + viewModel.login(provider) + }, + onDismiss = { addAccountPromptOpen = false }, + ) + } + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + state.savedAccounts.ifEmpty { + state.authSession?.toSavedAccount()?.let { listOf(it) } ?: emptyList() + }.forEach { account -> + val selected = account.userId == currentUserId + val membershipTier = if (selected) { + state.subscriptionInfo?.membershipTier?.takeIf { it.isNotBlank() } + ?: currentSession?.user?.membershipTier?.takeIf { it.isNotBlank() } + ?: account.membershipTier + } else { + account.membershipTier + } + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + color = if (selected) MaterialTheme.colorScheme.primary.copy(alpha = 0.16f) else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.76f), + ) { + Row( + Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Column(Modifier.weight(1f)) { + Text(account.displayName.ifBlank { "NVIDIA Account" }, color = SettingsText, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text( + listOfNotNull(account.email?.takeIf { it.isNotBlank() }, account.providerCode, membershipTier).joinToString(" - "), + color = SettingsTextMuted, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + if (selected) { + Text(stringResource(R.string.common_active), color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold) + } else { + OutlinedButton(onClick = { viewModel.switchAccount(account.userId) }, contentPadding = PaddingValues(horizontal = 10.dp, vertical = 6.dp)) { + Text(stringResource(R.string.account_switch)) + } + } + } + } + } + AndroidUpdateNoticeRow( + update = state.androidUpdate, + dismissedKey = state.dismissedAndroidUpdateNoticeKey, + onOpenUpdates = viewModel::openAndroidUpdateSettings, + onDismiss = viewModel::dismissAndroidUpdateNotice, + ) + state.deviceLoginPrompt?.let { prompt -> + DeviceLoginPanel( + prompt = prompt, + phase = state.launchPhase, + onCancel = viewModel::cancelLogin, + modifier = Modifier.fillMaxWidth(), + qrMaxSize = 240.dp, + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + Button(onClick = { addAccountPromptOpen = true }, modifier = Modifier.weight(1f)) { Text(stringResource(R.string.account_add)) } + OutlinedButton(onClick = viewModel::logout, modifier = Modifier.weight(1f)) { Text(stringResource(R.string.account_sign_out)) } + } + OutlinedButton(onClick = viewModel::logoutAll, modifier = Modifier.fillMaxWidth()) { Text(stringResource(R.string.account_sign_out_all)) } + AccountPlayTimeStatsPanel( + subscriptionInfo = state.subscriptionInfo, + fallbackMembershipTier = state.authSession?.user?.membershipTier, + ) + StorageAddonPanel( + storageAddon = state.subscriptionInfo?.storageAddon, + openExternal = { url -> + if (!openExternalUrl(context, url)) { + Toast.makeText(context, context.getString(R.string.error_no_browser), Toast.LENGTH_SHORT).show() + } + }, + ) + AccountConnectorsPanel( + connectors = state.accountConnectors, + loading = state.loadingAccountConnectors, + actionStore = state.connectorActionStore, + onRefresh = viewModel::refreshAccountConnectors, + onConnect = { connector -> + viewModel.connectAccountConnector(connector.store) { url -> + if (!openExternalUrl(context, url)) { + Toast.makeText(context, context.getString(R.string.error_no_browser), Toast.LENGTH_SHORT).show() + } + } + }, + onDisconnect = { connector -> + viewModel.disconnectAccountConnector(connector.store) + }, + openExternal = { url -> + if (!openExternalUrl(context, url)) { + Toast.makeText(context, context.getString(R.string.error_no_browser), Toast.LENGTH_SHORT).show() + } + }, + ) + } +} + +@Composable +private fun AddAccountProviderDialog( + providers: List, + selectedProvider: LoginProvider, + onProviderSelected: (LoginProvider) -> Unit, + onDismiss: () -> Unit, +) { + var providerChoice by remember(providers, selectedProvider) { + mutableStateOf(providers.preferredProvider(selectedProvider)) + } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.account_choose_provider)) }, + text = { + Column( + Modifier + .heightIn(max = 360.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + stringResource(R.string.settings_select_provider), + color = SettingsTextMuted, + style = MaterialTheme.typography.bodySmall, + ) + providers.forEach { provider -> + ProviderChoiceRow( + provider = provider, + selected = provider.sameProvider(providerChoice), + onClick = { providerChoice = provider }, + ) + } + } + }, + confirmButton = { + Button(onClick = { onProviderSelected(providerChoice) }) { + Text(stringResource(R.string.action_continue)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.action_cancel)) + } + }, + ) +} + +@Composable +private fun ProviderChoiceRow(provider: LoginProvider, selected: Boolean, onClick: () -> Unit) { + Surface( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable(onClick = onClick), + shape = RoundedCornerShape(12.dp), + color = if (selected) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.16f) + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.76f) + }, + ) { + Row( + Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Column(Modifier.weight(1f)) { + Text( + provider.displayName, + color = SettingsText, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + provider.code, + color = SettingsTextMuted, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + if (selected) { + Text(stringResource(R.string.store_selector_selected), color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold) + } + } + } +} + +@Composable +private fun AccountPlayTimeStatsPanel(subscriptionInfo: SubscriptionInfo?, fallbackMembershipTier: String?) { + val sessionLimit = smartSessionLimitFor(subscriptionInfo, fallbackMembershipTier) + val monthlyLimit = monthlyHourLimitFor(subscriptionInfo, fallbackMembershipTier) + val monthlyRemaining = monthlyHoursRemainingFor(subscriptionInfo, fallbackMembershipTier) + val usedHours = subscriptionInfo?.usedHours?.takeIf { it > 0.0 } + val progressFraction = if (monthlyLimit != null && monthlyLimit > 0.0) { + ((usedHours ?: 0.0) / monthlyLimit).toFloat().coerceIn(0f, 1f) + } else { + null + } + val freePlan = sessionLimit.mode == SessionTimerMode.Countdown + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.76f), + ) { + Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text(stringResource(R.string.account_play_time_stats), color = SettingsText, fontWeight = FontWeight.SemiBold) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + UsageMetricTile( + label = "Session", + value = "${sessionLimit.limitHours}h", + detail = when (sessionLimit.mode) { + SessionTimerMode.Countdown -> "countdown" + SessionTimerMode.Stopwatch -> "stopwatch" + }, + modifier = Modifier.weight(1f), + ) + UsageMetricTile( + label = "Monthly left", + value = monthlyRemaining?.let(::formatPlayTimeHours) ?: "--", + detail = monthlyLimit?.let { "of ${formatPlayTimeHours(it)}" } ?: if (freePlan) "paid plans" else "refresh account", + modifier = Modifier.weight(1f), + ) + } + if (progressFraction != null && monthlyLimit != null) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Text( + "${formatPlayTimeHours(usedHours ?: 0.0)} used", + color = SettingsTextMuted, + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.weight(1f), + ) + Text( + "${formatPlayTimePercent(progressFraction)} of ${formatPlayTimeHours(monthlyLimit)}", + color = SettingsTextMuted, + style = MaterialTheme.typography.labelSmall, + ) + } + Box( + modifier = Modifier + .fillMaxWidth() + .height(4.dp) + .clip(RoundedCornerShape(999.dp)) + .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.7f)) + .semantics { + contentDescription = "Monthly play time ${formatPlayTimePercent(progressFraction)} used" + progressBarRangeInfo = ProgressBarRangeInfo(progressFraction, 0f..1f) + }, + ) { + Box( + Modifier + .fillMaxWidth(progressFraction) + .height(4.dp) + .background( + when { + progressFraction >= 0.9f -> Color(0xffff8a65) + progressFraction >= 0.75f -> Color(0xffffc266) + else -> MaterialTheme.colorScheme.primary + }, + ), + ) + } + } + } else { + Text( + if (freePlan) { + "Paid plans show monthly play-time usage here when NVIDIA reports it." + } else { + "Refresh Account settings after sign-in to load monthly play-time usage." + }, + color = SettingsTextMuted, + style = MaterialTheme.typography.bodySmall, + ) + } + } + } +} + +@Composable +private fun UsageMetricTile(label: String, value: String, detail: String, modifier: Modifier = Modifier) { + Surface( + modifier = modifier, + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.52f), + ) { + Column(Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(label, color = SettingsTextMuted, style = MaterialTheme.typography.labelSmall, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(value, color = SettingsText, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(detail, color = SettingsTextMuted, style = MaterialTheme.typography.labelSmall, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } +} + +@Composable +internal fun AndroidUpdateNoticeRow( + update: AndroidUpdateState, + dismissedKey: String?, + onOpenUpdates: () -> Unit, + onDismiss: () -> Unit, +) { + val noticeKey = update.visibleNoticeKey(dismissedKey) ?: return + Surface( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .clickable(onClick = onOpenUpdates), + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + ) { + Row( + Modifier.padding(start = 12.dp, top = 10.dp, bottom = 10.dp, end = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(accountUpdateTitle(update), color = SettingsText, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(accountUpdateSubtitle(update), color = SettingsTextMuted, style = MaterialTheme.typography.bodySmall, maxLines = 2, overflow = TextOverflow.Ellipsis) + } + if (update.status == AndroidUpdateStatus.Downloading) { + CircularUpdateProgress(update.progress) + } + IconButton( + onClick = onDismiss, + modifier = Modifier.semantics { contentDescription = "Dismiss update ${noticeKey.takeLast(12)}" }, + ) { + Icon( + painter = painterResource(R.drawable.ic_clear), + contentDescription = null, + tint = SettingsTextMuted, + modifier = Modifier.size(20.dp), + ) + } + } + } +} + +@Composable +private fun CircularUpdateProgress(progress: AndroidUpdateProgress?) { + val label = progress?.let(::formatAndroidUpdateProgress) ?: "Downloading" + Text( + label, + color = SettingsTextMuted, + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) +} + +private fun accountUpdateTitle(update: AndroidUpdateState): String = + when (update.status) { + AndroidUpdateStatus.Downloaded -> "Update ready" + AndroidUpdateStatus.Downloading -> "Downloading OpenNOW" + else -> "OpenNOW update available" + } + +private fun accountUpdateSubtitle(update: AndroidUpdateState): String = + update.availableVersionName?.let { "Version $it is ready for this device." } + ?: update.message + +@Composable +private fun StorageAddonPanel(storageAddon: StorageAddon?, openExternal: (String) -> Unit) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.76f), + ) { + Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(stringResource(R.string.account_cloud_storage), color = SettingsText, fontWeight = FontWeight.SemiBold) + if (storageAddon == null) { + Text(stringResource(R.string.settings_no_storage_addon), color = SettingsTextMuted, style = MaterialTheme.typography.bodySmall) + OutlinedButton(onClick = { openExternal(GFN_ADD_STORAGE_URL) }, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.account_add_storage), maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } else { + val used = storageAddon.usedGb + val total = storageAddon.sizeGb + val usageFraction = storageUsageFraction(used, total) + Text( + listOfNotNull( + total?.let { "Total ${formatStorageGb(it)}" }, + used?.let { "Used ${formatStorageGb(it)}" }, + if (used != null && total != null) "Available ${formatStorageGb((total - used).coerceAtLeast(0.0))}" else null, + ).joinToString(" - "), + color = SettingsTextMuted, + style = MaterialTheme.typography.bodySmall, + ) + if (usageFraction != null) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Text( + stringResource(R.string.settings_storage_usage), + color = SettingsText, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f), + ) + Text( + "${formatStoragePercent(usageFraction)} used", + color = SettingsTextMuted, + style = MaterialTheme.typography.labelSmall, + ) + } + val usageColor = when { + usageFraction >= 0.9f -> Color(0xffff8a65) + usageFraction >= 0.75f -> Color(0xffffc266) + else -> MaterialTheme.colorScheme.primary + } + Box( + modifier = Modifier + .fillMaxWidth() + .height(4.dp) + .clip(RoundedCornerShape(999.dp)) + .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.7f)) + .semantics { + contentDescription = "Cloud storage ${formatStoragePercent(usageFraction)} used" + progressBarRangeInfo = ProgressBarRangeInfo(usageFraction, 0f..1f) + }, + ) { + Box( + Modifier + .fillMaxWidth(usageFraction.coerceIn(0f, 1f)) + .height(4.dp) + .background(usageColor), + ) + } + } + } + storageAddon.regionName?.takeIf { it.isNotBlank() }?.let { region -> + Text("Location: $region", color = SettingsTextMuted, style = MaterialTheme.typography.bodySmall) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + Button(onClick = { openExternal(GFN_STORAGE_MANAGEMENT_URL) }, modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.action_manage), maxLines = 1, overflow = TextOverflow.Ellipsis) + } + OutlinedButton(onClick = { openExternal(GFN_STORAGE_RESET_URL) }, modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.action_reset), maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + OutlinedButton(onClick = { openExternal(GFN_STORAGE_MANAGEMENT_URL) }, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.account_change_storage_location), maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } + } +} + +@Composable +private fun AccountConnectorsPanel( + connectors: List, + loading: Boolean, + actionStore: String?, + onRefresh: () -> Unit, + onConnect: (AccountConnector) -> Unit, + onDisconnect: (AccountConnector) -> Unit, + openExternal: (String) -> Unit, +) { + var disconnecting by remember { mutableStateOf(null) } + val controllerNavigationEnabled = LocalSettingsControllerNavigationEnabled.current + disconnecting?.let { connector -> + AlertDialog( + onDismissRequest = { disconnecting = null }, + title = { Text("Disconnect ${connector.label}?") }, + text = { Text("This removes the linked ${connector.label} account from GeForce NOW. You can connect it again later.") }, + confirmButton = { + Button( + onClick = { + disconnecting = null + onDisconnect(connector) + }, + ) { + Text(stringResource(R.string.action_disconnect)) + } + }, + dismissButton = { + TextButton(onClick = { disconnecting = null }) { + Text(stringResource(R.string.action_cancel)) + } + }, + ) + } + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.76f), + ) { + Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text(stringResource(R.string.settings_store_connections), color = SettingsText, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f)) + if (!controllerNavigationEnabled) { + TextButton(onClick = onRefresh, enabled = !loading) { + Text(if (loading) "Refreshing..." else "Refresh") + } + } + } + if (controllerNavigationEnabled) { + OutlinedButton(onClick = onRefresh, enabled = !loading, modifier = Modifier.fillMaxWidth()) { + Text(if (loading) "Refreshing..." else "Refresh") + } + } + if (connectors.isEmpty()) { + Text( + if (loading) "Loading connected stores..." else "Connect Steam, Epic, Xbox, and other supported stores to sync your GeForce NOW library.", + color = SettingsTextMuted, + style = MaterialTheme.typography.bodySmall, + ) + } else { + connectors.take(6).forEach { connector -> + ConnectorRow( + connector = connector, + busy = actionStore == connector.store, + onConnect = { onConnect(connector) }, + onDisconnect = { disconnecting = connector }, + ) + } + } + OutlinedButton(onClick = { openExternal(GFN_ACCOUNT_HELP_URL) }, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.settings_connection_help), maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } +} + +@Composable +private fun ConnectorRow( + connector: AccountConnector, + busy: Boolean, + onConnect: () -> Unit, + onDisconnect: () -> Unit, +) { + val actionEnabled = !busy && (connector.isLinked || connector.supported) + val badge = launcherBadgeForStoreKey(splitGameStoreKeys(connector.store).firstOrNull()) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = actionEnabled) { + if (connector.isLinked) onDisconnect() else onConnect() + }, + ) { + ConnectorStoreIcon(badge) + Column(Modifier.weight(1f)) { + Text(connector.label, color = SettingsText, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(connectorStatusText(connector), color = SettingsTextMuted, style = MaterialTheme.typography.bodySmall, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + if (connector.isLinked) { + OutlinedButton(onClick = onDisconnect, enabled = !busy, contentPadding = PaddingValues(horizontal = 10.dp, vertical = 6.dp)) { + Text(if (busy) "Removing..." else "Disconnect") + } + } else { + Button(onClick = onConnect, enabled = connector.supported && !busy, contentPadding = PaddingValues(horizontal = 10.dp, vertical = 6.dp)) { + Text(if (busy) "Opening..." else "Connect") + } + } + } +} + +@Composable +internal fun ConnectorStoreIcon(badge: LauncherBadge) { + Surface( + modifier = Modifier + .size(34.dp) + .semantics { contentDescription = "${badge.name} store" }, + shape = RoundedCornerShape(10.dp), + color = badge.background.copy(alpha = 0.88f), + ) { + Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) { + Icon( + painter = painterResource(badge.iconRes), + contentDescription = null, + tint = badge.foreground, + modifier = Modifier.size(19.dp), + ) + } + } +} + +private fun AuthSession.toSavedAccount(): SavedAccount = + SavedAccount( + userId = user.userId, + displayName = user.displayName, + email = user.email, + avatarUrl = user.avatarUrl, + membershipTier = user.membershipTier, + providerCode = provider.code, + ) + +private fun accountProviderOptions(providers: List, selectedProvider: LoginProvider): List = + (providers + selectedProvider) + .distinctBy { it.providerIdentityKey() } + .ifEmpty { listOf(selectedProvider) } + +private fun List.preferredProvider(provider: LoginProvider): LoginProvider = + firstOrNull { it.sameProvider(provider) } + ?: firstOrNull() + ?: provider + +private fun LoginProvider.sameProvider(other: LoginProvider): Boolean = + providerIdentityKey() == other.providerIdentityKey() + +private fun LoginProvider.providerIdentityKey(): String = + idpId.ifBlank { code }.lowercase(Locale.US) + +private const val GFN_STORAGE_MANAGEMENT_URL = "https://gfn.link/cloudstorage" +private const val GFN_STORAGE_RESET_URL = "https://gfn.link/resetstorage" +private const val GFN_ADD_STORAGE_URL = "https://gfn.link/addstorage" +private const val GFN_ACCOUNT_HELP_URL = "https://gfn.link/5399" +private const val OPENNOW_GITHUB_URL = "https://github.com/OpenCloudGaming/OpenNOW" + +private data class DeveloperCredit( + val name: String, + val githubUrl: String, +) + +private val DEVELOPER_CREDITS = listOf( + DeveloperCredit("Kiefer", "https://github.com/Kief5555"), + DeveloperCredit("Zortos", "https://github.com/zortos293"), +) + +private fun formatStorageGb(value: Double): String = + if (value % 1.0 == 0.0) "${value.toInt()} GB" else "%.1f GB".format(Locale.US, value) + +private fun storageUsageFraction(usedGb: Double?, totalGb: Double?): Float? { + if (usedGb == null || totalGb == null || totalGb <= 0.0) return null + return (usedGb / totalGb).coerceIn(0.0, 1.0).toFloat() +} + +private fun formatStoragePercent(fraction: Float): String = + "${(fraction * 100).roundToInt().coerceIn(0, 100)}%" + +private fun formatPlayTimeHours(value: Double): String = + if (value >= 10.0 || value % 1.0 == 0.0) { + "${value.roundToInt()}h" + } else { + "%.1fh".format(Locale.US, value) + } + +private fun formatPlayTimePercent(fraction: Float): String = + "${(fraction * 100).roundToInt().coerceIn(0, 100)}%" + +private fun connectorStatusText(connector: AccountConnector): String { + if (!connector.isLinked) return if (connector.required) "Required for some games" else "Available to connect" + val identity = connector.userDisplayName?.takeIf { it.isNotBlank() } + ?: connector.userIdentifier?.takeIf { it.isNotBlank() } + val sync = when { + connector.syncedGameCount != null -> "${connector.syncedGameCount} synced games" + !connector.syncState.isNullOrBlank() -> connector.syncState.replace('_', ' ').lowercase(Locale.US) + .replaceFirstChar { it.titlecase(Locale.US) } + else -> null + } + return listOfNotNull(identity, sync).joinToString(" - ").ifBlank { "Connected" } +} + +@Composable +internal fun CodecDiagnosticsPanel(report: RuntimeCodecReport?) { + if (report == null) { + Text(stringResource(R.string.settings_codec_diagnostics_unavailable), color = SettingsTextMuted) + return + } + val clipboard = LocalClipboardManager.current + var copied by remember(report) { mutableStateOf(false) } + val safeDecoders = report.capabilities.count { it.streamingRealtimeSafe() } + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Button( + onClick = { + clipboard.setText(AnnotatedString(formatCodecDiagnosticReport(report))) + copied = true + }, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + if (copied) { + stringResource(R.string.settings_codec_diagnostics_copied) + } else { + stringResource(R.string.settings_codec_diagnostics_copy) + }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + CodecSummaryChip("${safeDecoders}/${report.capabilities.size}", "real-time decoders") + CodecSummaryChip(if (report.lowPowerGpuProfile) "Low power" else "Standard", "device profile") + CodecSummaryChip(if (report.androidTvProfile) "TV" else "Mobile", "shell") + } + report.capabilities.forEach { capability -> + CodecCapabilityRow(capability) + } + Text( + report.nativeRuntimeSummary.replace("{", "").replace("}", "").replace("\"", ""), + color = SettingsTextMuted, + style = MaterialTheme.typography.bodySmall, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } +} + +private fun formatCodecDiagnosticReport(report: RuntimeCodecReport): String = buildString { + appendLine("OpenNOW Android codec diagnostics") + appendLine("nativeRuntimeSummary=${report.nativeRuntimeSummary}") + appendLine("androidTvProfile=${report.androidTvProfile}") + appendLine("lowPowerGpuProfile=${report.lowPowerGpuProfile}") + appendLine("constrainedRuntimeProfile=${report.constrainedRuntimeProfile}") + report.capabilities.forEach { capability -> + appendLine() + appendLine("codec=${capability.codec}") + appendLine("decoderAvailable=${capability.decoderAvailable}") + appendLine("decoderName=${capability.decoderName ?: "none"}") + appendLine("hardwareDecoder=${capability.hardwareDecoder}") + appendLine("realtimeSafe=${capability.realtimeSafe}") + appendLine("nativeDecoderAvailable=${capability.nativeDecoderAvailable ?: "unknown"}") + appendLine("webRtcDecoderAvailable=${capability.webRtcDecoderAvailable ?: "unknown"}") + appendLine("webRtcDecoderName=${capability.webRtcDecoderName ?: "none"}") + appendLine("webRtcHardwareDecoderAvailable=${capability.webRtcHardwareDecoderAvailable ?: "unknown"}") + appendLine("webRtcProfiles=${capability.webRtcCodecProfiles.joinToString(", ").ifBlank { "none" }}") + appendLine("encoderAvailable=${capability.encoderAvailable}") + appendLine("encoderName=${capability.encoderName ?: "none"}") + appendLine("hardwareEncoder=${capability.hardwareEncoder}") + } +} + +@Composable +private fun RowScope.CodecSummaryChip(value: String, label: String) { + Surface( + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(14.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.76f), + ) { + Column(Modifier.padding(horizontal = 10.dp, vertical = 8.dp)) { + Text(value, color = SettingsText, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(label, color = SettingsTextMuted, style = MaterialTheme.typography.labelSmall, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } +} + +@Composable +private fun CodecCapabilityRow(capability: CodecCapability) { + val streamingReady = capability.streamingDecoderAvailable() + val healthy = capability.streamingRealtimeSafe() + val status = when { + healthy -> "Ready" + streamingReady -> "WebRTC ready" + capability.decoderAvailable -> "Platform only" + else -> "Unavailable" + } + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.76f), + ) { + Column(Modifier.padding(horizontal = 12.dp, vertical = 10.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text(capability.codec.name, color = SettingsText, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f)) + Text( + status, + color = if (healthy) MaterialTheme.colorScheme.primary else Color(0xffffc266), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + ) + } + Text( + "WebRTC: ${capability.streamingDecoderName() ?: "none"}", + color = SettingsTextMuted, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + "Hardware decode ${yesNo(capability.streamingHardwareDecoderAvailable())} - native ${capability.nativeDecoderAvailable ?: "unknown"} - platform ${capability.decoderName ?: "none"}", + color = SettingsTextMuted, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +private fun yesNo(value: Boolean): String = if (value) "yes" else "no" + +internal val StreamStatsStyle.label: String + get() = when (this) { + StreamStatsStyle.Compact -> "Single line" + StreamStatsStyle.Detailed -> "Multiline" + } + +internal fun StreamStatsStyle.next(): StreamStatsStyle = + when (this) { + StreamStatsStyle.Compact -> StreamStatsStyle.Detailed + StreamStatsStyle.Detailed -> StreamStatsStyle.Compact + } + +internal val StreamStatsPosition.label: String + get() = when (this) { + StreamStatsPosition.Left -> "Left" + StreamStatsPosition.Center -> "Center" + StreamStatsPosition.Right -> "Right" + } + +internal fun StreamStatsPosition.next(): StreamStatsPosition = + when (this) { + StreamStatsPosition.Left -> StreamStatsPosition.Center + StreamStatsPosition.Center -> StreamStatsPosition.Right + StreamStatsPosition.Right -> StreamStatsPosition.Left + } + +/** + * About > version, and the gesture that reveals Settings > Developer options. + * + * Tapping the build number is the platform convention for this, so it is the one place a developer + * will look. The counter is local to the composable: it resets whenever About is left, which is + * what stops a handful of stray taps spread over a session from eventually tripping it. + */ +@Composable +internal fun AppVersionPanel(settings: AppSettings, onSettingsChange: (AppSettings) -> Unit) { + val context = LocalContext.current + var tapCount by remember { mutableStateOf(0) } + Row( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .background(SettingsPanelAlt) + .clickable { + tapCount += 1 + when (val result = developerOptionsTapResult(tapCount, settings.developerOptionsUnlocked)) { + is DeveloperOptionsTapResult.Silent -> Unit + is DeveloperOptionsTapResult.Countdown -> Toast.makeText( + context, + context.getString(R.string.dev_unlock_countdown, result.remaining), + Toast.LENGTH_SHORT, + ).show() + is DeveloperOptionsTapResult.Unlocked -> { + tapCount = 0 + onSettingsChange(settings.unlockingDeveloperOptions()) + Toast.makeText(context, R.string.dev_unlock_done, Toast.LENGTH_LONG).show() + } + is DeveloperOptionsTapResult.AlreadyUnlocked -> { + tapCount = 0 + Toast.makeText(context, R.string.dev_unlock_already, Toast.LENGTH_SHORT).show() + } + } + } + .padding(horizontal = 14.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text("OpenNOW Android", color = SettingsText, fontWeight = FontWeight.SemiBold) + Text("Version ${BuildConfig.VERSION_NAME}", color = SettingsTextMuted, style = MaterialTheme.typography.bodySmall) + } + Text("Build ${BuildConfig.VERSION_CODE}", color = SettingsTextMuted, style = MaterialTheme.typography.labelMedium) + } +} + +@Composable +internal fun OpenNowGitHubPanel() { + val context = LocalContext.current + val clipboard = LocalClipboardManager.current + Row( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .background(SettingsPanelAlt) + .padding(horizontal = 14.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text("OpenNOW Repository", color = SettingsText, fontWeight = FontWeight.SemiBold) + Text("OpenCloudGaming/OpenNOW", color = SettingsTextMuted, style = MaterialTheme.typography.bodySmall, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + OutlinedButton(onClick = { openExternalUrlOrCopy(context, clipboard, OPENNOW_GITHUB_URL, "GitHub link copied") }) { + Text("GitHub", maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } +} + +@Composable +internal fun DeveloperPanel() { + val context = LocalContext.current + val clipboard = LocalClipboardManager.current + Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) { + DEVELOPER_CREDITS.forEach { developer -> + Row( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .background(SettingsPanelAlt) + .padding(horizontal = 14.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Surface( + modifier = Modifier.size(52.dp), + shape = CircleShape, + color = MaterialTheme.colorScheme.surfaceVariant, + ) { + UrlImage("${developer.githubUrl}.png?size=160", Modifier.fillMaxSize().clip(CircleShape)) + } + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(developer.name, color = SettingsText, fontWeight = FontWeight.SemiBold) + Text(stringResource(R.string.settings_developer_label), color = SettingsTextMuted, style = MaterialTheme.typography.bodySmall, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + OutlinedButton(onClick = { openExternalUrlOrCopy(context, clipboard, developer.githubUrl, "GitHub link copied") }) { + Text("GitHub", maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } + } +} + +@Composable +internal fun ThanksPanel() { + val context = LocalContext.current + val clipboard = LocalClipboardManager.current + Text( + stringResource(R.string.settings_thanks_body), + color = SettingsTextMuted, + style = MaterialTheme.typography.bodyMedium, + ) + Row( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .background(SettingsPanelAlt) + .padding(horizontal = 14.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(stringResource(R.string.settings_thanks_darkevilpt), color = SettingsText, fontWeight = FontWeight.SemiBold) + Text(stringResource(R.string.settings_thanks_darkevilpt_note), color = SettingsTextMuted, style = MaterialTheme.typography.bodySmall) + } + } + DiscordCommunityLink( + summary = stringResource(R.string.discord_community_credits_summary), + ) + Button( + onClick = { + openExternalUrlOrCopy(context, clipboard, DONATE_URL, context.getString(R.string.settings_donate_link_copied)) + }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.settings_donate), maxLines = 1, overflow = TextOverflow.Ellipsis) + } +} + +private fun openExternalUrlOrCopy( + context: android.content.Context, + clipboard: androidx.compose.ui.platform.ClipboardManager, + url: String, + copiedMessage: String, +) { + if (!openExternalUrl(context, url)) { + clipboard.setText(AnnotatedString(url)) + Toast.makeText(context, copiedMessage, Toast.LENGTH_SHORT).show() + } +} + +@Composable +internal fun DebugLogsPanel(state: OpenNowUiState, viewModel: OpenNowViewModel) { + val context = LocalContext.current + val clipboard = LocalClipboardManager.current + val scope = rememberCoroutineScope() + var copied by remember { mutableStateOf(false) } + var saved by remember { mutableStateOf(false) } + var saveError by remember { mutableStateOf(null) } + var pendingLogText by remember { mutableStateOf("") } + var diagnosticActionInProgress by remember { mutableStateOf(false) } + val saveLauncher = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("text/plain")) { uri -> + if (uri == null) { + diagnosticActionInProgress = false + return@rememberLauncherForActivityResult + } + val logText = pendingLogText + scope.launch { + val result = withContext(Dispatchers.IO) { + runCatching { + context.contentResolver.openOutputStream(uri)?.use { output -> + output.write(logText.toByteArray(Charsets.UTF_8)) + } ?: error("Could not open log file") + } + } + result.onSuccess { + saved = true + saveError = null + }.onFailure { error -> + saveError = error.message ?: "Could not save logs" + } + diagnosticActionInProgress = false + } + } + Text( + stringResource(R.string.settings_debug_logs_desc), + color = SettingsTextMuted, + ) + if (state.androidTvProfile) { + Button( + onClick = viewModel::uploadDiagnosticShare, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.settings_upload_logs_qr), maxLines = 1, overflow = TextOverflow.Ellipsis) + } + Text( + stringResource(R.string.settings_upload_logs_desc), + color = SettingsTextMuted, + style = MaterialTheme.typography.bodySmall, + ) + } else { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + Button( + onClick = { + diagnosticActionInProgress = true + saveError = null + scope.launch { + runCatching { viewModel.sanitizedDebugLogText() } + .onSuccess { logs -> + clipboard.setText(AnnotatedString(logs)) + copied = true + } + .onFailure { error -> + saveError = error.message ?: "Could not copy logs" + } + diagnosticActionInProgress = false + } + }, + enabled = !diagnosticActionInProgress, + modifier = Modifier.weight(1f), + ) { + Text(if (copied) "Copied logs" else "Copy logs", maxLines = 1, overflow = TextOverflow.Ellipsis) + } + OutlinedButton( + onClick = { + diagnosticActionInProgress = true + saved = false + saveError = null + scope.launch { + runCatching { viewModel.sanitizedDebugLogText() } + .onSuccess { logs -> + pendingLogText = logs + saveLauncher.launch(viewModel.debugLogFileName()) + } + .onFailure { error -> + saveError = error.message ?: "Could not prepare logs" + diagnosticActionInProgress = false + } + } + }, + enabled = !diagnosticActionInProgress, + modifier = Modifier.weight(1f), + ) { + Text(if (saved) "Exported" else "Export logs", maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } + state.error?.let { error -> + OutlinedButton( + onClick = { + clipboard.setText(AnnotatedString(error)) + copied = true + }, + ) { + Text(stringResource(R.string.action_copy_error)) + } + } + saveError?.let { + Text(it, color = Color(0xffff9f9f), style = MaterialTheme.typography.bodySmall) + } +} + +@Composable +internal fun rememberDeviceHasBattery(): Boolean { + val appContext = LocalContext.current.applicationContext + return remember(appContext) { deviceHasBattery(appContext) } +} + +internal fun shouldShowBatteryOptimization(explicitBatteryPresent: Boolean?): Boolean = + explicitBatteryPresent != false + +private fun deviceHasBattery(context: Context): Boolean { + val batteryStatus = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + context.registerReceiver( + null, + IntentFilter(Intent.ACTION_BATTERY_CHANGED), + Context.RECEIVER_NOT_EXPORTED, + ) + } else { + @Suppress("DEPRECATION") + context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED)) + } + val explicitBatteryPresent = batteryStatus + ?.takeIf { it.hasExtra(BatteryManager.EXTRA_PRESENT) } + ?.getBooleanExtra(BatteryManager.EXTRA_PRESENT, true) + return shouldShowBatteryOptimization(explicitBatteryPresent) +} + +@Composable +internal fun BatteryOptimizationPanel() { + val context = LocalContext.current + var isIgnoring by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + val pm = context.getSystemService(Context.POWER_SERVICE) as? PowerManager + while (true) { + isIgnoring = pm?.isIgnoringBatteryOptimizations(context.packageName) == true + delay(1000L) + } + } + + Column( + verticalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = stringResource(R.string.settings_battery_optimization_desc), + style = MaterialTheme.typography.bodyMedium, + color = SettingsTextMuted + ) + Spacer(Modifier.height(4.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.settings_background_activity), + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.bodyMedium + ) + Text( + text = stringResource( + if (isIgnoring) { + R.string.settings_background_activity_unlimited + } else { + R.string.settings_background_activity_optimized + }, + ), + color = if (isIgnoring) Color(0xff81c784) else Color(0xffffb74d), + style = MaterialTheme.typography.bodySmall + ) + } + if (!isIgnoring) { + Button( + onClick = { + val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply { + data = Uri.parse("package:${context.packageName}") + } + try { + context.startActivity(intent) + } catch (e: Exception) { + try { + context.startActivity(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)) + } catch (_: Exception) {} + } + } + ) { + Text(stringResource(R.string.action_allow)) + } + } else { + OutlinedButton( + onClick = { + try { + context.startActivity(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)) + } catch (_: Exception) {} + } + ) { + Text(stringResource(R.string.nav_settings)) + } + } + } + } +} diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowSettingsScreens.kt b/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowSettingsScreens.kt new file mode 100644 index 000000000..734f1735f --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowSettingsScreens.kt @@ -0,0 +1,2041 @@ +package com.opencloudgaming.opennow + +import android.Manifest +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.widget.Toast +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.gestures.BringIntoViewSpec +import androidx.compose.foundation.gestures.LocalBringIntoViewSpec +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.annotation.StringRes +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.DeveloperMode +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material.icons.outlined.Monitor +import androidx.compose.material.icons.outlined.Palette +import androidx.compose.material.icons.outlined.Person +import androidx.compose.material.icons.outlined.Science +import androidx.compose.material.icons.outlined.SportsEsports +import androidx.compose.material.icons.outlined.Tune +import androidx.compose.material.icons.outlined.Tv +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.Alignment +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import com.opencloudgaming.opennow.ui.controls.ControlActionRow +import com.opencloudgaming.opennow.ui.theme.LocalReduceMotion +import com.opencloudgaming.opennow.ui.theme.OpenNowMotion +import com.opencloudgaming.opennow.ui.theme.OpenNowPalette +import kotlinx.coroutines.withContext +import kotlin.math.roundToInt + +// Aliases onto the shared token layer — these used to be a byte-for-byte copy of the palette in +// OpenNowScreens.kt, which meant any colour change had to be made twice or the two would drift. +internal val SettingsPanel = OpenNowPalette.Panel +internal val SettingsPanelAlt = OpenNowPalette.PanelAlt +internal val SettingsText = OpenNowPalette.TextPrimary +internal val SettingsTextMuted = OpenNowPalette.TextMuted +internal const val DONATE_URL = "https://printedwaste.com/donate" +internal val PHONE_NAV_RAIL_MAX_SMALLEST_WIDTH = 600.dp +internal val APP_NAV_RAIL_WIDTH = 80.dp +internal const val PHONE_ULTRAWIDE_MIN_STREAM_ASPECT = 2.2f +internal const val PHONE_ULTRAWIDE_MIN_VIEWPORT_ASPECT = 2.0f +internal val LocalSettingsControllerNavigationEnabled = androidx.compose.runtime.staticCompositionLocalOf { false } +private val SettingsFocusTopClearance = 16.dp +private val SettingsFocusBottomClearance = 40.dp + +internal fun settingsFocusScrollDistance( + itemOffsetPx: Float, + itemSizePx: Float, + containerSizePx: Float, + topClearancePx: Float, + bottomClearancePx: Float, +): Float { + if (itemSizePx <= 0f || containerSizePx <= 0f || itemSizePx >= containerSizePx) return 0f + val availableClearance = (containerSizePx - itemSizePx).coerceAtLeast(0f) + val requestedClearance = topClearancePx.coerceAtLeast(0f) + bottomClearancePx.coerceAtLeast(0f) + val clearanceScale = if (requestedClearance > availableClearance && requestedClearance > 0f) { + availableClearance / requestedClearance + } else { + 1f + } + val safeTop = topClearancePx.coerceAtLeast(0f) * clearanceScale + val safeBottom = containerSizePx - bottomClearancePx.coerceAtLeast(0f) * clearanceScale + val itemBottom = itemOffsetPx + itemSizePx + return when { + itemOffsetPx < safeTop -> itemOffsetPx - safeTop + itemBottom > safeBottom -> itemBottom - safeBottom + else -> 0f + } +} + +internal data class SettingsChoiceOption(val value: String, val label: String) +internal data class ChoiceMenuOption( + val value: String, + val label: String, + val enabled: Boolean = true, + val badge: String? = null, +) + +internal data class AndroidCodecChoicePresentation( + val options: List, + val selectedLabel: String, +) + +/** One codec availability presentation shared by Settings and first-run custom setup. */ +internal fun androidCodecChoicePresentation( + stream: StreamSettings, + codecReport: RuntimeCodecReport?, + comingSoonLabel: String, + unavailableLabel: String, +): AndroidCodecChoicePresentation { + val settingsAvailableStream = stream.withAndroidSettingsAvailability() + val effectiveCodec = settingsAvailableStream.adjustedForDevice(codecReport).codec + return AndroidCodecChoicePresentation( + options = VideoCodec.entries.map { codec -> + val launchUsable = codecReport + ?.capabilities + ?.firstOrNull { it.codec == codec } + ?.streamingDecoderUsableForLaunch() + ?: true + val settingsAvailable = codec.availableForAndroidSettings() + val available = settingsAvailable && launchUsable + ChoiceMenuOption( + value = codec.name, + label = codec.name, + enabled = available, + badge = when { + available -> null + !settingsAvailable -> comingSoonLabel + else -> unavailableLabel + }, + ) + }, + selectedLabel = if (effectiveCodec == stream.codec) { + stream.codec.name + } else { + "${stream.codec.name} -> ${effectiveCodec.name}" + }, + ) +} + +internal enum class SearchTarget { + Store, + Library, + Settings, +} + +/** + * Titles and summaries are string resources rather than hardcoded English constants, so Android's + * app-owned `res/values-*` translations can cover them without a runtime translation table. + * + * Icons come from `material-icons-extended` (already a dependency) so each category gets a distinct + * one. The previous set reused `ic_tab_store` for both Interface and Account, `ic_tab_settings` for + * both General and About, and a magnifying glass for Advanced. + */ +private enum class SettingsCategory( + @StringRes val titleRes: Int, + @StringRes val summaryRes: Int, + val icon: ImageVector, +) { + General(R.string.settings_category_general, R.string.settings_category_general_summary, Icons.Outlined.Tune), + Stream(R.string.settings_category_stream, R.string.settings_category_stream_summary, Icons.Outlined.Monitor), + Input(R.string.settings_category_input, R.string.settings_category_input_summary, Icons.Outlined.SportsEsports), + Interface(R.string.settings_category_interface, R.string.settings_category_interface_summary, Icons.Outlined.Palette), + Account(R.string.settings_category_account, R.string.settings_category_account_summary, Icons.Outlined.Person), + TvPairing(R.string.tv_pair_settings_title, R.string.tv_pair_settings_summary, Icons.Outlined.Tv), + Advanced(R.string.settings_category_advanced, R.string.settings_category_advanced_summary, Icons.Outlined.Science), + About(R.string.settings_category_about, R.string.settings_category_about_summary, Icons.Outlined.Info), + + /** Hidden until the About build-number gesture unlocks it. See `AndroidDeveloperOptions.kt`. */ + Developer( + R.string.settings_category_developer, + R.string.settings_category_developer_summary, + Icons.Outlined.DeveloperMode, + ), +} + +internal data class LauncherBadge( + val iconRes: Int, + val name: String, + val background: Color, + val foreground: Color = SettingsText, +) + +internal fun shouldEnableSettingsControllerNavigation( + tvProfile: Boolean, + controllerFamily: AndroidControllerFamily?, + gamingHandheld: Boolean, +): Boolean = tvProfile || controllerFamily != null || gamingHandheld + +private val keyboardLayoutOptions = listOf( + SettingsChoiceOption("en-US", "English (US)"), + SettingsChoiceOption("en-GB", "English (UK)"), + SettingsChoiceOption("tr-TR", "Turkish Q"), + SettingsChoiceOption("de-DE", "German"), + SettingsChoiceOption("fr-FR", "French"), + SettingsChoiceOption("es-ES", "Spanish"), + SettingsChoiceOption("es-MX", "Spanish (Latin America)"), + SettingsChoiceOption("it-IT", "Italian"), + SettingsChoiceOption("pt-PT", "Portuguese (Portugal)"), + SettingsChoiceOption("pt-BR", "Portuguese (Brazil)"), + SettingsChoiceOption("pl-PL", "Polish"), + SettingsChoiceOption("ru-RU", "Russian"), + SettingsChoiceOption("ja-JP", "Japanese"), + SettingsChoiceOption("ko-KR", "Korean"), + SettingsChoiceOption("zh-CN", "Chinese (Simplified)"), + SettingsChoiceOption("zh-TW", "Chinese (Traditional)"), +) + +private val gameLanguageOptions = listOf( + SettingsChoiceOption("en_US", "English (US)"), + SettingsChoiceOption("en_GB", "English (UK)"), + SettingsChoiceOption("de_DE", "Deutsch"), + SettingsChoiceOption("fr_FR", "Français"), + SettingsChoiceOption("es_ES", "Español (ES)"), + SettingsChoiceOption("es_MX", "Español (MX)"), + SettingsChoiceOption("it_IT", "Italiano"), + SettingsChoiceOption("pt_PT", "Português (PT)"), + SettingsChoiceOption("pt_BR", "Português (BR)"), + SettingsChoiceOption("ru_RU", "Русский"), + SettingsChoiceOption("pl_PL", "Polski"), + SettingsChoiceOption("tr_TR", "Türkçe"), + SettingsChoiceOption("ar_SA", "العربية"), + SettingsChoiceOption("ja_JP", "日本語"), + SettingsChoiceOption("ko_KR", "한국어"), + SettingsChoiceOption("zh_CN", "简体中文"), + SettingsChoiceOption("zh_TW", "繁體中文"), + SettingsChoiceOption("th_TH", "ไทย"), + SettingsChoiceOption("vi_VN", "Tiếng Việt"), + SettingsChoiceOption("id_ID", "Bahasa Indonesia"), + SettingsChoiceOption("cs_CZ", "Čeština"), + SettingsChoiceOption("el_GR", "Ελληνικά"), + SettingsChoiceOption("hu_HU", "Magyar"), + SettingsChoiceOption("ro_RO", "Română"), + SettingsChoiceOption("uk_UA", "Українська"), + SettingsChoiceOption("nl_NL", "Nederlands"), + SettingsChoiceOption("sv_SE", "Svenska"), + SettingsChoiceOption("da_DK", "Dansk"), + SettingsChoiceOption("fi_FI", "Suomi"), + SettingsChoiceOption("no_NO", "Norsk"), +) + +@OptIn(ExperimentalFoundationApi::class) +@Composable +internal fun SettingsScreen( + state: OpenNowUiState, + viewModel: OpenNowViewModel, + tvProfile: Boolean, + searchRequested: Boolean, + searchQuery: String, + backRequestToken: Int, + onSearchQueryChange: (String) -> Unit, + onDetailRouteChange: (Boolean) -> Unit, +) { + var showSessionProxyWarning by remember { mutableStateOf(false) } + var selectedCategory by remember { mutableStateOf(null) } + val scrollState = rememberScrollState() + val landingListState = rememberLazyListState() + val detailListState = rememberLazyListState() + val listState = if (selectedCategory == null && searchQuery.isBlank()) landingListState else detailListState + val searchFocusRequester = remember { FocusRequester() } + val detailFocusRequester = remember { FocusRequester() } + val keyboardController = LocalSoftwareKeyboardController.current + val focusManager = LocalFocusManager.current + val controllerFamily = rememberPhysicalControllerFamily(enabled = true) + val gamingHandheld = remember { isGamingHandheldDevice() } + val controllerNavigationEnabled = shouldEnableSettingsControllerNavigation( + tvProfile = tvProfile, + controllerFamily = controllerFamily, + gamingHandheld = gamingHandheld, + ) + val showSearch = searchRequested || searchQuery.isNotBlank() + val categories = remember(state.settings.developerOptionsUnlocked) { + settingsCategories(state.settings.developerOptionsUnlocked) + } + val reduceMotion = LocalReduceMotion.current + val platformBringIntoViewSpec = LocalBringIntoViewSpec.current + val density = LocalDensity.current + val focusTopClearancePx = with(density) { SettingsFocusTopClearance.toPx() } + val focusBottomClearancePx = with(density) { SettingsFocusBottomClearance.toPx() } + val settingsBringIntoViewSpec = remember( + tvProfile, + platformBringIntoViewSpec, + focusTopClearancePx, + focusBottomClearancePx, + ) { + if (tvProfile) { + platformBringIntoViewSpec + } else { + object : BringIntoViewSpec { + override fun calculateScrollDistance( + offset: Float, + size: Float, + containerSize: Float, + ): Float = settingsFocusScrollDistance( + itemOffsetPx = offset, + itemSizePx = size, + containerSizePx = containerSize, + topClearancePx = focusTopClearancePx, + bottomClearancePx = focusBottomClearancePx, + ) + } + } + } + LaunchedEffect(searchRequested) { + if (searchRequested) { + delay(90) + runCatching { searchFocusRequester.requestFocus() } + keyboardController?.show() + } + } + LaunchedEffect(categories) { + if (selectedCategory != null && + selectedCategory !in settingsDetailCategories(state.settings.developerOptionsUnlocked) + ) { + selectedCategory = null + } + } + LaunchedEffect(state.settingsRouteTarget) { + val routeTarget = state.settingsRouteTarget ?: return@LaunchedEffect + val routeCategory = when (routeTarget) { + SettingsRouteTarget.Account -> SettingsCategory.Account + SettingsRouteTarget.General -> SettingsCategory.General + SettingsRouteTarget.Stream -> SettingsCategory.Stream + SettingsRouteTarget.Interface -> SettingsCategory.Interface + } + if (selectedCategory != routeCategory || searchQuery.isNotBlank()) { + onSearchQueryChange("") + selectedCategory = routeCategory + } + viewModel.consumeSettingsRouteTarget(routeTarget) + } + BackHandler(enabled = selectedCategory != null) { + selectedCategory = settingsCategoryParent(selectedCategory) + } + LaunchedEffect(selectedCategory, controllerNavigationEnabled) { + val detailOpen = selectedCategory != null + onDetailRouteChange(detailOpen) + if (detailOpen && !tvProfile) { + detailListState.scrollToItem(0) + } + if (detailOpen && controllerNavigationEnabled) { + delay(90) + runCatching { detailFocusRequester.requestFocus() } + } + if (tvProfile) { + // Focus can scroll the first control into view while AnimatedContent is + // still measuring the new route. Reset afterward so every TV detail + // page opens with its header and remote Back hint fully visible. + delay(30) + scrollState.scrollTo(0) + } + } + LaunchedEffect(backRequestToken) { + if (backRequestToken > 0 && selectedCategory != null) { + selectedCategory = settingsCategoryParent(selectedCategory) + } + } + DisposableEffect(Unit) { + onDispose { onDetailRouteChange(false) } + } + CompositionLocalProvider( + LocalSettingsControllerNavigationEnabled provides controllerNavigationEnabled, + LocalBringIntoViewSpec provides settingsBringIntoViewSpec, + ) { + if (showSessionProxyWarning) { + SessionProxyWarningDialog( + onCancel = { showSessionProxyWarning = false }, + onEnable = { + viewModel.updateStreamSettings { s -> s.copy(sessionProxyEnabled = true) } + showSessionProxyWarning = false + }, + ) + } + if (tvProfile) { + SwipeToRefreshContainer( + refreshing = state.settingsRefreshing, + onRefresh = viewModel::refreshSettings, + modifier = Modifier.fillMaxSize(), + enabled = false, + ) { + Column( + Modifier + .fillMaxSize() + .onPreviewKeyEvent { handleVerticalDpadFocusMove(it, focusManager) } + .verticalScroll(scrollState) + .padding( + start = 20.dp, + top = 20.dp, + end = 20.dp, + bottom = AppScrollEndSpacing, + ), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + AnimatedVisibility(visible = showSearch) { + NativeSearchField( + query = searchQuery, + onQueryChange = onSearchQueryChange, + placeholder = stringResource(R.string.search_settings), + focusRequester = searchFocusRequester, + modifier = Modifier.fillMaxWidth(), + ) + } + SettingsRouteContent( + targetState = selectedCategory, + reduceMotion = reduceMotion, + ) { category -> + SettingsBody( + state = state, + viewModel = viewModel, + tvProfile = tvProfile, + controllerFamily = controllerFamily, + searchQuery = searchQuery, + selectedCategory = category, + categories = categories, + detailFocusRequester = detailFocusRequester, + onSelectCategory = { selectedCategory = it }, + onBack = { selectedCategory = settingsCategoryParent(selectedCategory) }, + showSessionProxyWarning = { showSessionProxyWarning = true }, + ) + } + } + } + } else { + SwipeToRefreshContainer( + refreshing = state.settingsRefreshing, + onRefresh = viewModel::refreshSettings, + modifier = Modifier.fillMaxSize(), + ) { + LazyColumn( + Modifier + .fillMaxSize(), + state = listState, + contentPadding = PaddingValues( + start = 14.dp, + top = 14.dp, + end = 14.dp, + bottom = AppScrollEndSpacing, + ), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + item { + AnimatedVisibility(visible = showSearch) { + NativeSearchField( + query = searchQuery, + onQueryChange = onSearchQueryChange, + placeholder = stringResource(R.string.search_settings), + focusRequester = searchFocusRequester, + modifier = Modifier.fillMaxWidth(), + ) + } + } + item { + SettingsRouteContent( + targetState = selectedCategory, + reduceMotion = reduceMotion, + ) { category -> + SettingsBody( + state = state, + viewModel = viewModel, + tvProfile = tvProfile, + controllerFamily = controllerFamily, + searchQuery = searchQuery, + selectedCategory = category, + categories = categories, + detailFocusRequester = detailFocusRequester, + onSelectCategory = { selectedCategory = it }, + onBack = { selectedCategory = settingsCategoryParent(selectedCategory) }, + showSessionProxyWarning = { showSessionProxyWarning = true }, + ) + } + } + } + } + } + } +} + +/** + * Slides only the incoming Settings route using layout placement. + * + * The outgoing route is discarded immediately, so two control-heavy Settings trees are never + * measured or drawn together. Unlike the former alpha/translation implementation, this does not + * create a page-sized RenderNode animation layer. + */ +@Composable +private fun SettingsRouteContent( + targetState: SettingsCategory?, + reduceMotion: Boolean, + content: @Composable (SettingsCategory?) -> Unit, +) { + var initialized by remember { mutableStateOf(false) } + var previousDepth by remember { mutableStateOf(settingsRouteDepth(targetState)) } + var direction by remember { mutableStateOf(1f) } + val animateEntrance = initialized && !reduceMotion + val progress = remember(targetState, reduceMotion) { + Animatable(if (animateEntrance) 0f else 1f) + } + val slideDistancePx = with(LocalDensity.current) { 28.dp.toPx() } + + LaunchedEffect(targetState, reduceMotion) { + val nextDepth = settingsRouteDepth(targetState) + direction = if (nextDepth < previousDepth) -1f else 1f + previousDepth = nextDepth + if (!initialized) { + initialized = true + } else if (!reduceMotion) { + progress.animateTo( + targetValue = 1f, + animationSpec = tween( + durationMillis = OpenNowMotion.DurationStandard, + easing = OpenNowMotion.EasingEmphasizedDecel, + ), + ) + } + } + + Box( + Modifier + .fillMaxWidth() + .offset { + IntOffset( + x = (direction * slideDistancePx * (1f - progress.value)).roundToInt(), + y = 0, + ) + }, + ) { + content(targetState) + } +} + +@Composable +private fun SettingsBody( + state: OpenNowUiState, + viewModel: OpenNowViewModel, + tvProfile: Boolean, + controllerFamily: AndroidControllerFamily?, + searchQuery: String, + selectedCategory: SettingsCategory?, + categories: List, + detailFocusRequester: FocusRequester, + onSelectCategory: (SettingsCategory) -> Unit, + onBack: () -> Unit, + showSessionProxyWarning: () -> Unit, +) { + when { + searchQuery.isNotBlank() -> { + SettingsContent( + state = state, + viewModel = viewModel, + searchQuery = searchQuery, + selectedCategory = null, + onSelectCategory = onSelectCategory, + showSessionProxyWarning = showSessionProxyWarning, + ) + } + selectedCategory == null -> { + SettingsCategoryLanding( + state = state, + viewModel = viewModel, + categories = categories, + onSelectCategory = onSelectCategory, + ) + } + else -> { + Column( + Modifier + .fillMaxWidth() + .lockedFocusGroup(), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + SettingsDetailHeader( + category = selectedCategory, + tvProfile = tvProfile, + controllerFamily = controllerFamily, + onBack = onBack, + ) + Box( + Modifier + .fillMaxWidth() + .focusRequester(detailFocusRequester) + .focusGroup(), + ) { + SettingsContent( + state = state, + viewModel = viewModel, + searchQuery = searchQuery, + selectedCategory = selectedCategory, + onSelectCategory = onSelectCategory, + showSessionProxyWarning = showSessionProxyWarning, + ) + } + } + } + } +} + +@Composable +private fun SettingsContent( + state: OpenNowUiState, + viewModel: OpenNowViewModel, + searchQuery: String, + selectedCategory: SettingsCategory?, + onSelectCategory: (SettingsCategory) -> Unit, + showSessionProxyWarning: () -> Unit, +) { + val settings = state.settings + val context = LocalContext.current + val gyroscopeAvailable = remember(context) { hasMobileGyroscope(context) } + val deviceHasBattery = rememberDeviceHasBattery() + val fallbackMembershipTier = state.authSession?.user?.membershipTier + var pendingMicrophoneMode by remember { mutableStateOf(null) } + val microphonePermissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { granted -> + val requestedMode = pendingMicrophoneMode + pendingMicrophoneMode = null + if (granted && requestedMode != null) { + viewModel.updateSettings( + settings.copy( + stream = settings.stream.copy(microphoneMode = requestedMode), + ), + ) + } else if (!granted) { + Toast.makeText( + context, + context.getString(R.string.settings_microphone_permission_denied), + Toast.LENGTH_LONG, + ).show() + } + } + Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) { + CategorySettingsSection(selectedCategory, SettingsCategory.General, searchQuery, "App updates", "update", "updates", "disable update checking", "checking", "check", "download", "install", "apk") { + if (state.androidUpdate.apkUpdatesAllowed) { + SettingSwitch(stringResource(R.string.settings_disable_update_checking), !settings.autoCheckForUpdates) { disabled -> + viewModel.updateSettings(settings.copy(autoCheckForUpdates = !disabled)) + } + } + AndroidUpdatePanel(state = state, viewModel = viewModel) + } + CategorySettingsSection(selectedCategory, SettingsCategory.General, searchQuery, stringResource(R.string.settings_section_language), "language", "locale", "english", "system default", "app language") { + val appLocale = currentAndroidAppLocale(context) + val systemDefaultLabel = stringResource(R.string.app_language_system_default) + val languageOptions = listOf( + ChoiceMenuOption(ANDROID_APP_LANGUAGE_SYSTEM, systemDefaultLabel), + ChoiceMenuOption(ANDROID_APP_LANGUAGE_ENGLISH, stringResource(R.string.app_language_english)), + ChoiceMenuOption(ANDROID_APP_LANGUAGE_ARABIC, stringResource(R.string.app_language_arabic)), + ChoiceMenuOption(ANDROID_APP_LANGUAGE_GERMAN, stringResource(R.string.app_language_german)), + ChoiceMenuOption(ANDROID_APP_LANGUAGE_SPANISH, stringResource(R.string.app_language_spanish)), + ChoiceMenuOption(ANDROID_APP_LANGUAGE_FRENCH, stringResource(R.string.app_language_french)), + ChoiceMenuOption(ANDROID_APP_LANGUAGE_JAPANESE, stringResource(R.string.app_language_japanese)), + ChoiceMenuOption(ANDROID_APP_LANGUAGE_KOREAN, stringResource(R.string.app_language_korean)), + ChoiceMenuOption(ANDROID_APP_LANGUAGE_DUTCH, stringResource(R.string.app_language_dutch)), + ChoiceMenuOption(ANDROID_APP_LANGUAGE_POLISH, stringResource(R.string.app_language_polish)), + ChoiceMenuOption(ANDROID_APP_LANGUAGE_PORTUGUESE, stringResource(R.string.app_language_portuguese)), + ChoiceMenuOption(ANDROID_APP_LANGUAGE_ROMANIAN, stringResource(R.string.app_language_romanian)), + ChoiceMenuOption(ANDROID_APP_LANGUAGE_RUSSIAN, stringResource(R.string.app_language_russian)), + ChoiceMenuOption(ANDROID_APP_LANGUAGE_TURKISH, stringResource(R.string.app_language_turkish)), + ChoiceMenuOption(ANDROID_APP_LANGUAGE_SIMPLIFIED_CHINESE, stringResource(R.string.app_language_simplified_chinese)), + ) + ChoiceMenuRow( + label = stringResource(R.string.settings_app_language), + options = languageOptions, + selectedLabel = if (appLocale.selectedLanguageTag.isBlank()) { + "$systemDefaultLabel (${appLocale.effectiveLanguageTag.ifBlank { "unknown" }})" + } else { + languageOptions.firstOrNull { it.value == appLocale.selectedLanguageTag }?.label + ?: appLocale.selectedLanguageTag + }, + ) { languageTag -> + setAndroidAppLanguage(context, languageTag) + } + } + CategorySettingsSection(selectedCategory, SettingsCategory.Advanced, searchQuery, stringResource(R.string.settings_nerd_mode), "advanced", "advanced options", "nerd", "experimental", "diagnostics") { + AdvancedOptionsSettings(settings = settings, viewModel = viewModel) + } + CategorySettingsSection(selectedCategory, SettingsCategory.General, searchQuery, "Privacy", "privacy", "analytics", "telemetry", "posthog", "usage", "tracking", "opt out") { + SettingSwitch("Share usage analytics", settings.analyticsSharingEnabled) { enabled -> + viewModel.updateSettings( + settings.copy( + analyticsConsentAsked = true, + analyticsOptOut = !enabled, + ), + ) + } + } + CategorySettingsSection(selectedCategory, SettingsCategory.Stream, searchQuery, stringResource(R.string.settings_section_stream_quality), "stream", "quality", "preset", "data saver", "low", "medium", "high", "custom", "resolution", "aspect ratio", "fps", "bitrate") { + ChoiceMenuRow( + label = stringResource(R.string.settings_stream_preset), + options = StreamPreset.entries.map { preset -> + ChoiceMenuOption( + value = preset.name, + label = streamPresetLabel(preset), + ) + }, + selectedLabel = streamPresetLabel(settings.streamPreset), + ) { value -> + viewModel.applyStreamPreset(StreamPreset.valueOf(value)) + } + state.recommendedStreamSettings?.let { recommended -> + Text( + stringResource( + R.string.settings_detected_recommendation, + recommended.recommendationSummary(), + ), + color = SettingsTextMuted, + style = MaterialTheme.typography.bodySmall, + ) + } + val recommendationOverrides = settings.stream.performanceOverridesComparedTo( + recommended = state.recommendedStreamSettings, + report = state.codecReport, + ) + val performanceWarningReasons = settings.stream.lowPowerPerformanceWarningReasons(state.codecReport) + val performanceWarnings = recommendationOverrides.ifEmpty { performanceWarningReasons } + if (performanceWarnings.isNotEmpty()) { + DeviceStreamRecommendationWarning( + reasons = performanceWarnings, + recommended = state.recommendedStreamSettings, + ) + } + val resolutionChoices = streamResolutionChoicesForAspect(settings.stream.aspectRatio).ifEmpty { + streamResolutionChoicesForAspect("16:9") + } + val selectedResolution = normalizeStreamResolutionForAspectAndPlan( + settings.stream.resolution, + settings.stream.aspectRatio, + state.subscriptionInfo, + fallbackMembershipTier, + ) + ChoiceMenuRow( + label = stringResource(R.string.settings_resolution), + options = resolutionChoices.map { choice -> + val available = choice.isAvailableFor(state.subscriptionInfo, fallbackMembershipTier) + ChoiceMenuOption( + value = choice.value, + label = choice.label, + enabled = available, + badge = if (available) null else choice.requiredPlanLabel, + ) + }, + selectedLabel = resolutionChoices.firstOrNull { it.value == selectedResolution }?.label ?: selectedResolution, + ) { + viewModel.updateStreamSettings { s -> s.copy(resolution = it) } + } + ChoiceMenuRow( + label = stringResource(R.string.settings_aspect_ratio), + options = streamAspectRatioOptions().map { aspectRatio -> + val choices = streamResolutionChoicesForAspect(aspectRatio) + val available = choices.any { it.isAvailableFor(state.subscriptionInfo, fallbackMembershipTier) } + ChoiceMenuOption( + value = aspectRatio, + label = aspectRatio, + enabled = available, + badge = if (available) null else choices.firstNotNullOfOrNull { it.requiredPlanLabel }, + ) + }, + selectedLabel = settings.stream.aspectRatio, + ) { + viewModel.updateStreamSettings { s -> + s.copy( + aspectRatio = it, + resolution = normalizeStreamResolutionForAspectAndPlan( + s.resolution, + it, + state.subscriptionInfo, + fallbackMembershipTier, + ), + ) + } + } + SettingSwitch( + label = stringResource(R.string.settings_stretch_stream_to_fit), + checked = settings.stretchStreamToFit, + ) { enabled -> + viewModel.updateSettings( + settings.copy( + legacyCropStreamToFill = false, + stretchStreamToFit = enabled, + ), + ) + } + val maxFps = maxStreamFpsFor(state.subscriptionInfo, fallbackMembershipTier) + NumberSlider( + label = stringResource(R.string.settings_fps), + value = settings.stream.fps.coerceAtMost(maxFps).toFloat(), + min = 30f, + max = maxFps.toFloat(), + step = 30f, + unit = "FPS", + ) { + val fps = it.roundToInt().coerceIn(30, maxFps) + viewModel.updateStreamSettings { s -> s.copy(fps = fps) } + } + NumberSlider( + label = stringResource(R.string.settings_bitrate), + value = settings.stream.maxBitrateMbps.toFloat(), + min = 1f, + max = 150f, + step = 1f, + descriptionProvider = { mbps -> streamBitrateUsageEstimate(mbps) }, + ) { + viewModel.updateStreamSettings { s -> s.copy(maxBitrateMbps = it.roundToInt()) } + } + } + CategorySettingsSection(selectedCategory, SettingsCategory.Stream, searchQuery, stringResource(R.string.settings_section_stream_video), "stream", "video", "codec", "color", "hdr", "sharpening", "native streamer", "low latency", "native decoder", "decoder") { + val comingSoonLabel = stringResource(R.string.option_coming_soon) + val unavailableLabel = stringResource(R.string.common_unavailable) + val h264H265OnlyLabel = stringResource(R.string.settings_av1_ten_bit_badge) + val settingsAvailableStream = settings.stream.withAndroidSettingsAvailability() + val codecChoices = androidCodecChoicePresentation( + stream = settings.stream, + codecReport = state.codecReport, + comingSoonLabel = comingSoonLabel, + unavailableLabel = unavailableLabel, + ) + ChoiceMenuRow( + label = stringResource(R.string.settings_codec), + options = codecChoices.options, + selectedLabel = codecChoices.selectedLabel, + description = stringResource(R.string.settings_codec_desc), + ) { value -> + val selectedCodec = VideoCodec.valueOf(value) + val downgradedTenBit = selectedCodec == VideoCodec.AV1 && + settings.stream.usesTenBitStreamProfile() + viewModel.updateStreamSettings { s -> + s.copy(codec = selectedCodec).withCodecColorCompatibility() + } + if (downgradedTenBit) { + Toast.makeText( + context, + context.getString(R.string.settings_av1_ten_bit_downgraded), + Toast.LENGTH_LONG, + ).show() + } + } + val effectiveColorQuality = settingsAvailableStream.withCodecColorCompatibility().colorQuality + ChoiceMenuRow( + label = stringResource(R.string.settings_color), + options = ColorQuality.entries.map { quality -> + val available = quality.availableForCodec(settingsAvailableStream.codec) + ChoiceMenuOption( + value = quality.name, + label = quality.label, + enabled = available, + badge = when { + available -> null + settingsAvailableStream.codec == VideoCodec.AV1 && quality == ColorQuality.TenBit420 -> h264H265OnlyLabel + else -> comingSoonLabel + }, + ) + }, + selectedLabel = if (effectiveColorQuality == settings.stream.colorQuality) { + settings.stream.colorQuality.label + } else { + "${settings.stream.colorQuality.label} -> ${effectiveColorQuality.label}" + }, + description = stringResource(R.string.settings_color_desc), + ) { value -> + viewModel.updateStreamSettings { s -> + s.copy(colorQuality = ColorQuality.valueOf(value)).withCodecColorCompatibility() + } + } + if (settingsAvailableStream.codec == VideoCodec.AV1) { + Text( + stringResource(R.string.settings_av1_ten_bit_hint), + color = SettingsTextMuted, + style = MaterialTheme.typography.bodySmall, + ) + } + val hdrAvailable = hasHdrStreamingPlan(state.subscriptionInfo, fallbackMembershipTier) && + settingsAvailableStream.hdrAvailableForAndroid(state.androidTvProfile) + SettingSwitch( + label = stringResource(R.string.settings_hdr), + checked = settings.stream.hdrEnabled && hdrAvailable, + enabled = hdrAvailable, + description = stringResource(R.string.settings_hdr_desc), + ) { enabled -> + viewModel.updateStreamSettings { s -> + s.copy( + hdrEnabled = enabled, + colorQuality = if (enabled && !s.colorQuality.name.startsWith("TenBit")) ColorQuality.TenBit420 else s.colorQuality, + ).withCodecColorCompatibility() + } + } + if (!settingsAvailableStream.hdrAvailableForAndroid(state.androidTvProfile)) { + Text( + if (state.androidTvProfile) { + stringResource(R.string.settings_hdr_android_tv_compatibility_hint) + } else { + stringResource(R.string.settings_hdr_android_handheld_hint) + }, + color = SettingsTextMuted, + style = MaterialTheme.typography.bodySmall, + ) + } + SettingSwitch( + label = stringResource(R.string.stream_panel_sharpening), + checked = settings.stream.streamSharpeningEnabled, + description = stringResource(R.string.settings_stream_sharpening_desc), + ) { + viewModel.updateStreamSettings { s -> s.copy(streamSharpeningEnabled = it) } + } + if (settings.stream.streamSharpeningEnabled) { + NumberSlider( + label = stringResource(R.string.stream_panel_sharpening_amount), + value = settings.stream.streamSharpeningAmount, + min = 0f, + max = 1f, + step = 0.05f, + description = stringResource(R.string.settings_stream_sharpening_amount_desc), + ) { + viewModel.updateStreamSettings { s -> s.copy(streamSharpeningAmount = it) } + } + } + SettingSwitch( + label = stringResource(R.string.settings_native_streamer), + checked = settings.nativeLowLatencyDecoder, + description = stringResource(R.string.settings_native_streamer_desc), + ) { enabled -> + viewModel.updateSettings(settings.copy(nativeLowLatencyDecoder = enabled)) + } + } + CategorySettingsSection(selectedCategory, SettingsCategory.Stream, searchQuery, stringResource(R.string.settings_section_stream_connection), "stream", "connection", "network", "region", "session proxy", "proxy") { + ChoiceRow(stringResource(R.string.settings_region), listOf(stringResource(R.string.option_auto)) + state.regions.map { it.name }, state.regions.firstOrNull { it.url == settings.stream.region }?.name ?: stringResource(R.string.option_auto)) { label -> + val url = state.regions.firstOrNull { it.name == label }?.url.orEmpty() + viewModel.updateStreamSettings { s -> s.copy(region = url) } + } + SettingSwitch(stringResource(R.string.settings_session_proxy), settings.stream.sessionProxyEnabled) { enabled -> + if (enabled) { + showSessionProxyWarning() + } else { + viewModel.updateStreamSettings { s -> s.copy(sessionProxyEnabled = false) } + } + } + Text( + stringResource(R.string.settings_session_proxy_hint), + color = SettingsTextMuted, + style = MaterialTheme.typography.bodySmall, + ) + if (settings.stream.sessionProxyEnabled) { + OutlinedTextField( + value = settings.stream.sessionProxyUrl, + onValueChange = { value -> viewModel.updateStreamSettings { s -> s.copy(sessionProxyUrl = value) } }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + label = { Text(stringResource(R.string.settings_session_proxy_url)) }, + placeholder = { Text("http://127.0.0.1:8080") }, + ) + } + } + CategorySettingsSection(selectedCategory, SettingsCategory.Input, searchQuery, stringResource(R.string.settings_section_audio_keyboard), "input", "microphone", "mic", "voice", "audio", "keyboard", "shortcut", "layout", "language", "clipboard", "paste") { + SettingSwitch( + label = stringResource(R.string.settings_microphone), + checked = settings.stream.microphoneMode != MicrophoneMode.Disabled, + description = stringResource(R.string.settings_microphone_desc), + ) { enabled -> + if (!enabled) { + viewModel.updateSettings( + settings.copy( + stream = settings.stream.copy(microphoneMode = MicrophoneMode.Disabled), + ), + ) + } else if ( + ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == + PackageManager.PERMISSION_GRANTED + ) { + viewModel.updateSettings( + settings.copy( + stream = settings.stream.copy(microphoneMode = MicrophoneMode.VoiceActivity), + ), + ) + } else { + pendingMicrophoneMode = MicrophoneMode.VoiceActivity + microphonePermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) + } + } + ChoiceOptionRow(stringResource(R.string.settings_keyboard_layout), keyboardLayoutOptions, settings.stream.keyboardLayout) { + viewModel.updateStreamSettings { s -> s.copy(keyboardLayout = it) } + } + ChoiceOptionRow(stringResource(R.string.settings_game_language), gameLanguageOptions, settings.stream.gameLanguage) { + viewModel.updateStreamSettings { s -> s.copy(gameLanguage = it) } + } + SettingSwitch(stringResource(R.string.settings_clipboard_paste), settings.clipboardPaste) { enabled -> viewModel.updateSettings(settings.copy(clipboardPaste = enabled)) } + } + CategorySettingsSection(selectedCategory, SettingsCategory.Input, searchQuery, stringResource(R.string.settings_section_pointer_input), "input", "pointer", "mouse", "lock", "grab", "capture", "fullscreen", "sensitivity", "acceleration", "scroll", "controller mouse", "mode", "native touch", "tap", "stability", "finger", "direct click") { + SettingSwitch( + label = stringResource(R.string.settings_mouse_lock), + checked = settings.externalMousePointerLock, + description = stringResource(R.string.settings_mouse_lock_desc), + ) { enabled -> + viewModel.updateSettings(settings.copy(externalMousePointerLock = enabled)) + } + NumberSlider("Mouse sensitivity", settings.stream.mouseSensitivity, 0.25f, 3f, 0.05f, valueFormatter = { "%.2fx".format(it) }) { + viewModel.updateStreamSettings { s -> s.copy(mouseSensitivity = it) } + } + NumberSlider("Mouse acceleration", settings.stream.mouseAcceleration.toFloat(), 1f, 150f, 1f) { + viewModel.updateStreamSettings { s -> s.copy(mouseAcceleration = it.roundToInt()) } + } + val scrollHint = when { + settings.stream.mouseScrollSensitivity <= 20 -> "Very fast" + settings.stream.mouseScrollSensitivity <= 40 -> "Standard" + settings.stream.mouseScrollSensitivity <= 60 -> "Precise" + else -> "Slow" + } + NumberSlider( + label = "Mouse scroll sensitivity", + value = settings.stream.mouseScrollSensitivity.toFloat(), + min = 10f, + max = 100f, + step = 5f, + unit = " ($scrollHint)", + ) { value -> + viewModel.updateStreamSettings { s -> s.copy(mouseScrollSensitivity = value.toInt()) } + } + SettingSwitch( + label = stringResource(R.string.stream_panel_mouse_mode), + checked = settings.controllerMouseEmulation, + description = "Toggle in Stream Controls per session. Left stick moves the cursor, right stick scrolls, A button clicks, B button right-clicks.", + ) { enabled -> + viewModel.updateSettings(settings.copy(controllerMouseEmulation = enabled)) + } + if (!state.androidTvProfile) { + // Sends fingers to the PC as a real touchscreen, so games with a touch mode switch + // to it themselves. Auto is limited to catalog variants that advertise touch. + val effectiveNativeTouchMode = settings.androidTouch.effectiveNativeTouchMode() + ChoiceMenuRow( + label = "Native touch", + options = NativeTouchMode.entries.map { mode -> + ChoiceMenuOption(value = mode.name, label = nativeTouchModeLabel(mode)) + }, + selectedLabel = nativeTouchModeLabel(effectiveNativeTouchMode), + ) { value -> + val mode = NativeTouchMode.entries.firstOrNull { it.name == value } ?: NativeTouchMode.Off + viewModel.updateSettings( + settings.copy(androidTouch = settings.androidTouch.withNativeTouchMode(mode)), + ) + } + if ( + effectiveNativeTouchMode == NativeTouchMode.Auto && + settings.stream.requiresNativeDesktopCloudMatchMode() + ) { + Text( + text = stringResource(R.string.settings_native_touch_high_performance_hint), + color = SettingsTextMuted, + style = MaterialTheme.typography.bodySmall, + ) + } + if (effectiveNativeTouchMode != NativeTouchMode.Off) { + Box(Modifier.padding(start = 24.dp)) { + Column { + val scrollSpeedLabel = when { + settings.androidTouch.nativeTouchScrollScale <= 0.5f -> "Very slow" + settings.androidTouch.nativeTouchScrollScale <= 0.8f -> "Slow" + settings.androidTouch.nativeTouchScrollScale <= 1.2f -> "Normal" + settings.androidTouch.nativeTouchScrollScale <= 1.6f -> "Fast" + else -> "Very fast" + } + NumberSlider( + label = "Native touch scroll speed", + value = settings.androidTouch.nativeTouchScrollScale, + min = 0.25f, + max = 2.0f, + step = 0.05f, + unit = " ($scrollSpeedLabel)", + ) { value -> + viewModel.updateSettings(settings.copy(androidTouch = settings.androidTouch.copy(nativeTouchScrollScale = value))) + } + NumberSlider( + label = "Native touch tap stability", + value = settings.androidTouch.nativeTouchJitterThresholdDp, + min = 0f, + max = 24f, + step = 1f, + unit = "dp", + ) { value -> + viewModel.updateSettings(settings.copy(androidTouch = settings.androidTouch.copy(nativeTouchJitterThresholdDp = value))) + } + } + } + } + } + SettingSwitch(stringResource(R.string.stream_panel_finger_mouse), settings.androidTouch.mousePad) { enabled -> viewModel.updateSettings(settings.copy(androidTouch = settings.androidTouch.copy(mousePad = enabled))) } + if (settings.androidTouch.mousePad) { + Box(Modifier.padding(start = 24.dp)) { + SettingSwitch(stringResource(R.string.stream_panel_direct_click), settings.androidTouch.mouseDirectClick) { enabled -> viewModel.updateSettings(settings.copy(androidTouch = settings.androidTouch.copy(mouseDirectClick = enabled))) } + } + } + } + CategorySettingsSection(selectedCategory, SettingsCategory.Input, searchQuery, stringResource(R.string.settings_section_controller_touch), "input", "rumble", "touch", "controller", "style", "skin", "theme", "colour", "color", "labels", "layout", "scale", "size", "opacity", "edge", "padding", "offset", "horizontal", "vertical", "controls", "visible", "hide", "programmable", "extra", "accessibility", "guide", "home", "stick", "joystick", "analog", "dynamic", "dead zone", "button") { + SettingSwitch( + label = stringResource(R.string.stream_panel_vibration), + checked = settings.vibrationEnabled, + ) { enabled -> + viewModel.updateSettings(settings.copy(vibrationEnabled = enabled)) + } + if (settings.vibrationEnabled) { + val hapticsOutputOptions = listOf( + SettingsChoiceOption( + HapticsOutputPreference.Auto.name, + stringResource(R.string.settings_haptics_output_auto), + ), + SettingsChoiceOption( + HapticsOutputPreference.Controller.name, + stringResource(R.string.settings_haptics_output_controller), + ), + SettingsChoiceOption( + HapticsOutputPreference.Device.name, + stringResource(R.string.settings_haptics_output_device), + ), + ) + ChoiceOptionRow( + stringResource(R.string.settings_haptics_output), + hapticsOutputOptions, + settings.hapticsOutput.name, + description = stringResource(R.string.settings_haptics_output_desc), + ) { name -> + viewModel.updateSettings( + settings.copy(hapticsOutput = HapticsOutputPreference.valueOf(name)), + ) + } + } + SettingSwitch(stringResource(R.string.stream_touch_controls_title), settings.androidTouch.enabled) { enabled -> viewModel.updateSettings(settings.copy(androidTouch = settings.androidTouch.copy(enabled = enabled))) } + val touchStyleOptions = TouchControllerStyle.entries.map { style -> + SettingsChoiceOption(style.name, touchControllerStyleLabel(style)) + } + ChoiceOptionRow( + stringResource(R.string.settings_touch_skin), + touchStyleOptions, + settings.androidTouch.touchControllerStyle.name, + description = stringResource(R.string.settings_touch_skin_desc), + ) { styleName -> + val style = TouchControllerStyle.valueOf(styleName) + viewModel.updateSettings(settings.copy(androidTouch = settings.androidTouch.copy(touchControllerStyle = style))) + } + // The skins differ by shape now, which a list of names cannot show. + TouchControllerSkinPreview( + style = settings.androidTouch.touchControllerStyle, + tint = settings.androidTouch.touchSkinTint, + opacity = settings.androidTouch.opacity, + showLabels = settings.androidTouch.touchButtonLabels, + modifier = Modifier.padding(vertical = 8.dp), + ) + val touchTintOptions = TOUCH_SKIN_TINTS.map { SettingsChoiceOption(it.id, it.label) } + ChoiceOptionRow( + stringResource(R.string.settings_touch_skin_tint), + touchTintOptions, + touchSkinTintId(settings.androidTouch.touchSkinTint), + description = stringResource(R.string.settings_touch_skin_tint_desc), + ) { tintId -> + viewModel.updateSettings( + settings.copy( + androidTouch = settings.androidTouch.copy(touchSkinTint = touchSkinTintForId(tintId)), + ), + ) + } + SettingSwitch( + label = stringResource(R.string.settings_touch_button_labels), + checked = settings.androidTouch.touchButtonLabels, + ) { enabled -> + viewModel.updateSettings( + settings.copy(androidTouch = settings.androidTouch.copy(touchButtonLabels = enabled)), + ) + } + Text( + text = stringResource(R.string.settings_touch_visible_controls), + color = SettingsText, + style = MaterialTheme.typography.titleSmall, + ) + Text( + text = stringResource(R.string.settings_touch_visible_controls_desc), + color = SettingsTextMuted, + style = MaterialTheme.typography.bodySmall, + ) + TouchControlGroup.entries.forEach { group -> + SettingSwitch( + label = stringResource(touchControlGroupLabelRes(group)), + checked = settings.androidTouch.isControlVisible(group), + ) { visible -> + viewModel.updateSettings( + settings.copy(androidTouch = settings.androidTouch.withControlVisible(group, visible)), + ) + } + } + Text( + text = stringResource(R.string.settings_touch_extra_buttons), + color = SettingsText, + style = MaterialTheme.typography.titleSmall, + ) + Text( + text = stringResource(R.string.settings_touch_extra_buttons_desc), + color = SettingsTextMuted, + style = MaterialTheme.typography.bodySmall, + ) + val extraButtonOptions = TouchExtraButtonAction.entries.map { action -> + SettingsChoiceOption(action.name, touchExtraButtonActionLabel(action)) + } + repeat(TOUCH_EXTRA_BUTTON_COUNT) { index -> + ChoiceOptionRow( + label = stringResource(R.string.settings_touch_extra_button, index + 1), + options = extraButtonOptions, + selectedValue = settings.androidTouch.extraButtonAction(index).name, + ) { actionName -> + val action = TouchExtraButtonAction.valueOf(actionName) + viewModel.updateSettings( + settings.copy(androidTouch = settings.androidTouch.withExtraButtonAction(index, action)), + ) + } + } + NumberSlider( + stringResource(R.string.settings_touch_extra_button_size), + settings.androidTouch.extraButtonScale, + 0.6f, + 1.6f, + 0.05f, + ) { value -> + viewModel.updateSettings( + settings.copy(androidTouch = settings.androidTouch.copy(extraButtonScale = value)), + ) + } + val touchAimOptions = listOf( + SettingsChoiceOption(TouchAimMode.LockJoystick.name, stringResource(R.string.stream_joysticks_lock_joystick)), + SettingsChoiceOption(TouchAimMode.LockZone.name, stringResource(R.string.stream_joysticks_lock_zone)), + ) + ChoiceOptionRow(stringResource(R.string.stream_joysticks_aim_mode), touchAimOptions, settings.androidTouch.aimMode.name) { modeName -> + val mode = TouchAimMode.valueOf(modeName) + viewModel.updateSettings(settings.copy(androidTouch = settings.androidTouch.copy(aimMode = mode))) + } + if (settings.androidTouch.aimMode == TouchAimMode.LockZone) { + NumberSlider( + stringResource(R.string.stream_joysticks_aim_zone_scale), + settings.androidTouch.aimZoneScale, + 0.5f, + 1.5f, + 0.05f, + ) { value -> + viewModel.updateSettings( + settings.copy(androidTouch = settings.androidTouch.copy(aimZoneScale = value)), + ) + } + NumberSlider( + stringResource(R.string.stream_joysticks_aim_zone_sensitivity), + settings.androidTouch.aimZoneSensitivity, + 0.25f, + 3f, + 0.05f, + ) { value -> + viewModel.updateSettings( + settings.copy(androidTouch = settings.androidTouch.copy(aimZoneSensitivity = value)), + ) + } + } + val joystickModeOptions = listOf( + SettingsChoiceOption(TouchJoystickMode.Fixed.name, stringResource(R.string.stream_panel_joystick_fixed)), + SettingsChoiceOption(TouchJoystickMode.Dynamic.name, stringResource(R.string.stream_panel_joystick_dynamic)), + ) + ChoiceOptionRow("Touch joystick", joystickModeOptions, settings.androidTouch.joystickMode.name) { modeName -> + val mode = TouchJoystickMode.valueOf(modeName) + viewModel.updateSettings(settings.copy(androidTouch = settings.androidTouch.copy(joystickMode = mode))) + } + NumberSlider("Joystick dead zone", settings.androidTouch.joystickDeadZone, 0f, 0.3f, 0.01f) { value -> + viewModel.updateSettings(settings.copy(androidTouch = settings.androidTouch.copy(joystickDeadZone = value))) + } + SettingSwitch( + label = stringResource(R.string.settings_touch_gyro), + checked = settings.androidTouch.gyroscopeEnabled && gyroscopeAvailable, + enabled = gyroscopeAvailable, + description = stringResource( + if (gyroscopeAvailable) R.string.settings_touch_gyro_desc + else R.string.settings_touch_gyro_unavailable, + ), + ) { enabled -> + viewModel.updateSettings(settings.copy(androidTouch = settings.androidTouch.copy(gyroscopeEnabled = enabled))) + } + if (settings.androidTouch.gyroscopeEnabled && gyroscopeAvailable) { + NumberSlider(stringResource(R.string.settings_touch_gyro_sensitivity), settings.androidTouch.gyroscopeSensitivity, 0.25f, 3f, 0.05f) { value -> + viewModel.updateSettings(settings.copy(androidTouch = settings.androidTouch.copy(gyroscopeSensitivity = value))) + } + NumberSlider(stringResource(R.string.settings_touch_gyro_dead_zone), settings.androidTouch.gyroscopeDeadZone, 0f, 0.2f, 0.005f) { value -> + viewModel.updateSettings(settings.copy(androidTouch = settings.androidTouch.copy(gyroscopeDeadZone = value))) + } + NumberSlider(stringResource(R.string.settings_touch_gyro_smoothing), settings.androidTouch.gyroscopeSmoothing, 0f, 0.9f, 0.05f) { value -> + viewModel.updateSettings(settings.copy(androidTouch = settings.androidTouch.copy(gyroscopeSmoothing = value))) + } + SettingSwitch( + stringResource(R.string.settings_touch_gyro_invert_horizontal), + settings.androidTouch.gyroscopeInvertHorizontal, + ) { enabled -> + viewModel.updateSettings(settings.copy(androidTouch = settings.androidTouch.copy(gyroscopeInvertHorizontal = enabled))) + } + SettingSwitch( + stringResource(R.string.settings_touch_gyro_invert_vertical), + settings.androidTouch.gyroscopeInvertVertical, + ) { enabled -> + viewModel.updateSettings(settings.copy(androidTouch = settings.androidTouch.copy(gyroscopeInvertVertical = enabled))) + } + } + NumberSlider("Touch layout scale", settings.androidTouch.scale, 0.6f, 1.4f, 0.05f) { value -> viewModel.updateSettings(settings.copy(androidTouch = settings.androidTouch.copy(scale = value))) } + NumberSlider("Touch button size", settings.androidTouch.buttonScale, 0.65f, 1.5f, 0.05f) { value -> viewModel.updateSettings(settings.copy(androidTouch = settings.androidTouch.copy(buttonScale = value))) } + NumberSlider("Touch stick size", settings.androidTouch.stickScale, 0.65f, 1.5f, 0.05f) { value -> viewModel.updateSettings(settings.copy(androidTouch = settings.androidTouch.copy(stickScale = value))) } + NumberSlider(stringResource(R.string.settings_touch_face_size), settings.androidTouch.faceButtonScale, 0.6f, 1.5f, 0.05f) { value -> viewModel.updateSettings(settings.copy(androidTouch = settings.androidTouch.copy(faceButtonScale = value))) } + NumberSlider(stringResource(R.string.settings_touch_dpad_size), settings.androidTouch.dpadScale, 0.6f, 1.5f, 0.05f) { value -> viewModel.updateSettings(settings.copy(androidTouch = settings.androidTouch.copy(dpadScale = value))) } + NumberSlider(stringResource(R.string.settings_touch_shoulders_size), settings.androidTouch.shoulderButtonScale, 0.6f, 1.5f, 0.05f) { value -> viewModel.updateSettings(settings.copy(androidTouch = settings.androidTouch.copy(shoulderButtonScale = value))) } + NumberSlider(stringResource(R.string.settings_touch_center_size), settings.androidTouch.centerButtonScale, 0.6f, 1.5f, 0.05f) { value -> viewModel.updateSettings(settings.copy(androidTouch = settings.androidTouch.copy(centerButtonScale = value))) } + NumberSlider(stringResource(R.string.settings_touch_left_stick_size), settings.androidTouch.leftStickScale, 0.6f, 1.5f, 0.05f) { value -> viewModel.updateSettings(settings.copy(androidTouch = settings.androidTouch.copy(leftStickScale = value))) } + NumberSlider(stringResource(R.string.settings_touch_right_stick_size), settings.androidTouch.rightStickScale, 0.6f, 1.5f, 0.05f) { value -> viewModel.updateSettings(settings.copy(androidTouch = settings.androidTouch.copy(rightStickScale = value))) } + NumberSlider(stringResource(R.string.settings_touch_stick_knob_size), settings.androidTouch.stickKnobScale, 0.28f, 0.72f, 0.02f) { value -> viewModel.updateSettings(settings.copy(androidTouch = settings.androidTouch.copy(stickKnobScale = value))) } + NumberSlider("Touch opacity", settings.androidTouch.opacity, 0f, 1f, 0.05f) { value -> viewModel.updateSettings(settings.copy(androidTouch = settings.androidTouch.copy(opacity = value))) } + NumberSlider("Touch edge padding", settings.androidTouch.edgePaddingDp, 0f, 72f, 1f, unit = "dp") { value -> viewModel.updateSettings(settings.copy(androidTouch = settings.androidTouch.copy(edgePaddingDp = value))) } + NumberSlider("Touch bottom padding", settings.androidTouch.bottomPaddingDp, 0f, 120f, 1f, unit = "dp") { value -> viewModel.updateSettings(settings.copy(androidTouch = settings.androidTouch.copy(bottomPaddingDp = value))) } + NumberSlider("Left controls horizontal offset", settings.androidTouch.leftOffsetXDp, -220f, 220f, 2f, unit = "dp") { value -> viewModel.updateSettings(settings.copy(androidTouch = settings.androidTouch.copy(leftOffsetXDp = value))) } + NumberSlider("Left controls vertical offset", settings.androidTouch.leftOffsetYDp, -160f, 160f, 2f, unit = "dp") { value -> viewModel.updateSettings(settings.copy(androidTouch = settings.androidTouch.copy(leftOffsetYDp = value))) } + NumberSlider("Right controls horizontal offset", settings.androidTouch.rightOffsetXDp, -220f, 220f, 2f, unit = "dp") { value -> viewModel.updateSettings(settings.copy(androidTouch = settings.androidTouch.copy(rightOffsetXDp = value))) } + NumberSlider("Right controls vertical offset", settings.androidTouch.rightOffsetYDp, -160f, 160f, 2f, unit = "dp") { value -> viewModel.updateSettings(settings.copy(androidTouch = settings.androidTouch.copy(rightOffsetYDp = value))) } + } + CategorySettingsSection(selectedCategory, SettingsCategory.Interface, searchQuery, stringResource(R.string.settings_section_appearance), "interface", "ui", "appearance", "dynamic color", "system colors", "accent", "expressive", "border", "effects", "bonanza", "cinema", "catalog", "background", "wallpaper", "image", "custom", "tv", "safe area", "screen padding", "overscan") { + val accentOptions = selectableUiAccents().map { it to uiAccentLabel(it) } + SettingSwitch(stringResource(R.string.settings_dynamic_color), settings.dynamicColor) { viewModel.updateSettings(settings.copy(dynamicColor = it)) } + ChoiceRow( + label = stringResource(R.string.settings_accent), + options = accentOptions.map { it.second }, + selected = accentOptions.firstOrNull { it.first == settings.uiAccent }?.second ?: accentOptions.first().second, + activeOutlineColor = LocalActiveSelectionColor.current.takeIf { LocalActiveSelectionEnabled.current }, + activeOutlineSecondaryColor = LocalActiveSelectionSecondaryColor.current.takeIf { LocalActiveSelectionEnabled.current }, + ) { label -> + accentOptions.firstOrNull { it.second == label }?.first?.let { accent -> + viewModel.updateSettings(settings.copy(uiAccent = accent)) + } + } + SettingSwitch( + label = stringResource(R.string.settings_live_selected_outlines), + checked = settings.liveSelectedOutlines, + description = stringResource(R.string.settings_live_selected_outlines_desc), + ) { enabled -> + viewModel.updateSettings(settings.copy(liveSelectedOutlines = enabled)) + } + SettingSwitch( + label = stringResource(R.string.settings_absolute_cinema_effects), + checked = settings.absoluteCinemaEffects, + description = stringResource(R.string.settings_absolute_cinema_effects_desc), + ) { enabled -> + viewModel.updateSettings( + settings.copy( + absoluteCinemaEffects = enabled, + absoluteCinemaEverywhere = settings.absoluteCinemaEverywhere && enabled, + ), + ) + } + SettingSwitch( + label = stringResource(R.string.settings_im_crazy), + checked = settings.absoluteCinemaEverywhere, + enabled = settings.absoluteCinemaEffects, + description = stringResource(R.string.settings_im_crazy_desc), + indentLevel = 1, + ) { enabled -> + viewModel.updateSettings(settings.copy(absoluteCinemaEverywhere = enabled)) + } + SettingSwitch( + label = stringResource(R.string.settings_expressive_ui), + checked = settings.expressiveUi, + ) { + viewModel.updateSettings(settings.copy(expressiveUi = it)) + } + if (BuildConfig.LOCAL_APP_LAUNCHER_SUPPORTED) { + SettingSwitch( + label = stringResource(R.string.settings_local_apps), + checked = settings.localAppsEnabled, + description = stringResource(R.string.settings_local_apps_desc), + ) { enabled -> + viewModel.updateSettings(settings.copy(localAppsEnabled = enabled)) + } + if (settings.localAppsEnabled) { + DefaultLauncherSetting() + } + } + NumberSlider(stringResource(R.string.settings_tv_safe_area), settings.tvSafeAreaPaddingDp, 0f, 72f, 2f, unit = "dp") { value -> + viewModel.updateSettings(settings.copy(tvSafeAreaPaddingDp = value)) + } + CatalogBackgroundSettings(settings = settings, viewModel = viewModel) + } + CategorySettingsSection(selectedCategory, SettingsCategory.Interface, searchQuery, stringResource(R.string.settings_section_library_navigation), "interface", "launch page", "default page", "store", "library", "compact", "cards", "titles", "favorites", "favourites", "save", "icon", "game card size", "server selector", "hero", "banner", "featured", "landscape", "new games") { + SettingSwitch( + label = stringResource(R.string.settings_landscape_new_games), + checked = settings.landscapeNewGamesHero, + description = stringResource(R.string.settings_landscape_new_games_desc), + ) { enabled -> + viewModel.updateSettings(settings.copy(landscapeNewGamesHero = enabled)) + } + val launchPageOptions = AppLaunchPage.entries.map { page -> page to appLaunchPageLabel(page) } + ChoiceRow( + stringResource(R.string.settings_launch_page), + launchPageOptions.map { it.second }, + launchPageOptions.firstOrNull { it.first == settings.launchPage }?.second + ?: launchPageOptions.first().second, + ) { label -> + launchPageOptions.firstOrNull { it.second == label }?.first?.let { page -> + viewModel.updateSettings(settings.copy(launchPage = page)) + } + } + SettingSwitch(stringResource(R.string.settings_compact_cards), settings.compactGameCards) { viewModel.updateSettings(settings.copy(compactGameCards = it)) } + SettingSwitch(stringResource(R.string.settings_show_card_titles), settings.showCardTitles) { viewModel.updateSettings(settings.copy(showCardTitles = it)) } + SettingSwitch( + label = stringResource(R.string.settings_show_favorite_icon), + checked = settings.showFavoriteIconOnGameCards, + ) { enabled -> + viewModel.updateSettings(settings.copy(showFavoriteIconOnGameCards = enabled)) + } + NumberSlider( + label = stringResource(R.string.settings_card_size), + value = settings.posterSizeScale, + min = MIN_GAME_CARD_SCALE, + max = MAX_GAME_CARD_SCALE, + step = 0.05f, + description = stringResource(R.string.settings_card_size_desc), + ) { value -> + viewModel.updateSettings(settings.copy(posterSizeScale = value)) + } + SettingSwitch(stringResource(R.string.settings_hide_server_selector), settings.hideServerSelector) { viewModel.updateSettings(settings.copy(hideServerSelector = it)) } + } + CategorySettingsSection(selectedCategory, SettingsCategory.Interface, searchQuery, stringResource(R.string.settings_section_status_bar), "interface", "stats", "status bar", "position", "fps", "ping", "bitrate", "keyboard", "button") { + SettingSwitch(stringResource(R.string.settings_show_stats), settings.showStatsOnLaunch) { viewModel.updateSettings(settings.copy(showStatsOnLaunch = it)) } + SettingSwitch( + label = stringResource(R.string.settings_stream_keyboard_button), + checked = !settings.hideStreamButtons, + ) { enabled -> + viewModel.updateSettings(settings.copy(hideStreamButtons = !enabled)) + } + ChoiceRow("Status bar appearance", StreamStatsStyle.entries.map { it.label }, settings.streamStatsStyle.label) { label -> + StreamStatsStyle.entries.firstOrNull { it.label == label }?.let { style -> + viewModel.updateSettings(settings.copy(streamStatsStyle = style)) + } + } + ChoiceRow(stringResource(R.string.settings_stats_position), StreamStatsPosition.entries.map { it.label }, settings.streamStatsPosition.label) { label -> + StreamStatsPosition.entries.firstOrNull { it.label == label }?.let { position -> + viewModel.updateSettings(settings.copy(streamStatsPosition = position)) + } + } + } + CategorySettingsSection(selectedCategory, SettingsCategory.Interface, searchQuery, stringResource(R.string.settings_section_sounds_sessions), "interface", "controller", "sounds", "button", "tone", "session counter", "session report", "quality summary", "intro", "music", "queue") { + SettingSwitch( + label = stringResource(R.string.settings_button_press_tones), + checked = settings.controllerUiSounds, + ) { enabled -> + viewModel.updateSettings(settings.copy(controllerUiSounds = enabled)) + } + SettingSwitch(stringResource(R.string.settings_session_counter), settings.sessionCounterEnabled) { viewModel.updateSettings(settings.copy(sessionCounterEnabled = it)) } + SettingSwitch( + label = stringResource(R.string.settings_show_session_report), + checked = settings.showSessionReportAfterStream, + description = stringResource(R.string.settings_show_session_report_desc), + ) { enabled -> + viewModel.updateSettings(settings.copy(showSessionReportAfterStream = enabled)) + } + SettingSwitch(stringResource(R.string.settings_stream_intro_music), settings.streamIntroMusic) { enabled -> + viewModel.updateSettings(settings.copy(streamIntroMusic = enabled)) + } + if (settings.streamIntroMusic) { + val introStartOptions = IntroMusicStartMode.entries.map { mode -> + mode to introMusicStartModeLabel(mode) + } + ChoiceRow( + stringResource(R.string.settings_stream_intro_music_start), + introStartOptions.map { it.second }, + introStartOptions.firstOrNull { it.first == settings.streamIntroStartMode }?.second + ?: introStartOptions.first().second, + ) { label -> + introStartOptions.firstOrNull { it.second == label }?.first?.let { mode -> + viewModel.updateSettings(settings.copy(streamIntroStartMode = mode)) + } + } + } + SettingSwitch(stringResource(R.string.settings_queue_ready_music), settings.queueReadyMusic) { enabled -> + viewModel.updateSettings(settings.copy(queueReadyMusic = enabled)) + } + } + CategorySettingsSection(selectedCategory, SettingsCategory.General, searchQuery, stringResource(R.string.settings_section_setup), "setup", "intro", "onboarding", "welcome", "first run", "walkthrough", "tour", "getting started") { + ControlActionRow( + label = stringResource(R.string.settings_run_setup_again), + actionLabel = stringResource(R.string.action_open), + value = stringResource(R.string.settings_run_setup_again_desc), + onClick = { viewModel.updateSettings(settings.restartingSetupFlow()) }, + ) + } + CategorySettingsSection(selectedCategory, SettingsCategory.General, searchQuery, "App Data", "app data", "data", "cache", "clear", "reset", "settings", "tutorial", "guide", "wipe", "relaunch", "fresh install") { + AppDataSettingsPanel(viewModel = viewModel) + } + CategorySettingsSection(selectedCategory, SettingsCategory.Account, searchQuery, stringResource(R.string.settings_category_account), "account", "login", "logout", "sign in", "saved", "provider", "membership", "subscription", "tv", "pair", "phone", "qr") { + AccountSettingsPanel( + state = state, + viewModel = viewModel, + onOpenTvPairing = { onSelectCategory(SettingsCategory.TvPairing) }, + searchMode = searchQuery.isNotBlank(), + ) + } + CategorySettingsSection(selectedCategory, SettingsCategory.TvPairing, searchQuery, stringResource(R.string.tv_pair_settings_title), "tv", "pair", "phone", "qr", "code", "network") { + LocalTvSettingsPanel( + state = state, + viewModel = viewModel, + showTitle = false, + ) + } + CategorySettingsSection(selectedCategory, SettingsCategory.Advanced, searchQuery, stringResource(R.string.settings_experimental_streaming), "experimental", "stream", "l4s", "session", "launch", "failure") { + Text( + stringResource(R.string.settings_experimental_streaming_warning), + color = SettingsTextMuted, + style = MaterialTheme.typography.labelSmall, + ) + SettingSwitch( + label = stringResource(R.string.settings_l4s), + checked = settings.stream.enableL4S, + description = stringResource(R.string.settings_l4s_desc), + ) { + viewModel.updateStreamSettings { s -> s.copy(enableL4S = it) } + } + } + CategorySettingsSection(selectedCategory, SettingsCategory.Advanced, searchQuery, "Codec Diagnostics", "codec", "diagnostics", "probe", "av1", "h264", "h265", "hevc", "decode") { + CodecDiagnosticsPanel(state.codecReport) + } + CategorySettingsSection(selectedCategory, SettingsCategory.Advanced, searchQuery, "Debug Logs", "debug", "logs", "logcat", "events", "export", "json", "cloudmatch", "queue", "stream") { + DebugLogsPanel(state = state, viewModel = viewModel) + } + if (deviceHasBattery) { + CategorySettingsSection(selectedCategory, SettingsCategory.Advanced, searchQuery, "Battery Optimization", "battery", "optimization", "background", "activity", "ignore", "allow", "run") { + BatteryOptimizationPanel() + } + } + CategorySettingsSection(selectedCategory, SettingsCategory.About, searchQuery, stringResource(R.string.settings_category_about), "about", "version", "build", "app", "github", "developer", "kiefer", "zortos", "opennow", "repository") { + AppVersionPanel(settings = settings, onSettingsChange = viewModel::updateSettings) + OpenNowGitHubPanel() + DeveloperPanel() + } + CategorySettingsSection(selectedCategory, SettingsCategory.About, searchQuery, stringResource(R.string.settings_section_thanks), "thanks", "credits", "contributors", "darkevilpt", "discord", "community", "support", "donate", "paypal", "printedwaste") { + ThanksPanel() + } + if (settings.developerOptionsUnlocked) { + CategorySettingsSection(selectedCategory, SettingsCategory.Developer, searchQuery, stringResource(R.string.settings_category_developer), "developer", "developer options", "debug", "reset", "wipe", "diagnostics", "runtime", "environment", "flows", "onboarding", "cache") { + DeveloperOptionsPanel(state = state, viewModel = viewModel) + } + } + } +} + +@Composable +private fun DeviceStreamRecommendationWarning( + reasons: List, + recommended: StreamSettings?, +) { + val warningColor = Color(0xffffc266) + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + color = warningColor.copy(alpha = 0.10f), + contentColor = SettingsText, + border = BorderStroke(1.dp, warningColor.copy(alpha = 0.38f)), + ) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text( + stringResource(R.string.settings_above_recommendation), + color = warningColor, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.labelLarge, + ) + Text( + reasons.joinToString(", "), + color = SettingsText, + style = MaterialTheme.typography.bodyMedium, + ) + Text( + recommended?.let { + "Use Recommended (${it.recommendationSummary()}) and restart the stream before reporting lag. You can still use Custom and send a report after acknowledging the warning." + } ?: "The Recommended preset is the safer option for lag troubleshooting.", + color = SettingsTextMuted, + style = MaterialTheme.typography.bodySmall, + ) + } + } +} + +@Composable +private fun SettingsCategoryLanding( + state: OpenNowUiState, + viewModel: OpenNowViewModel, + categories: List, + onSelectCategory: (SettingsCategory) -> Unit, +) { + Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(14.dp)) { + SettingsAccountCard( + state = state, + onClick = { onSelectCategory(SettingsCategory.Account) }, + ) + AndroidUpdateNoticeRow( + update = state.androidUpdate, + dismissedKey = state.dismissedAndroidUpdateNoticeKey, + onOpenUpdates = { onSelectCategory(SettingsCategory.General) }, + onDismiss = viewModel::dismissAndroidUpdateNotice, + ) + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(28.dp), + color = MaterialTheme.colorScheme.surface, + ) { + Column(Modifier.fillMaxWidth().padding(vertical = 8.dp)) { + categories.filterNot { it == SettingsCategory.Account }.forEach { category -> + SettingsCategoryRow( + category = category, + onClick = { onSelectCategory(category) }, + ) + } + } + } + // About/Thanks used to be pasted in here because the category had no row of its own. It has + // one now, so this duplicate is gone and About renders once, inside its own category. + } +} + +@Composable +private fun AdvancedOptionsSettings(settings: AppSettings, viewModel: OpenNowViewModel) { + SettingSwitch( + label = stringResource(R.string.settings_nerd_mode), + checked = settings.nerdMode, + description = stringResource(R.string.settings_nerd_mode_desc), + ) { + viewModel.updateSettings(settings.copy(nerdMode = it)) + } +} + +@Composable +private fun CatalogBackgroundSettings(settings: AppSettings, viewModel: OpenNowViewModel) { + val choices = listOf( + AppBackgroundChoice.Default to stringResource(R.string.setup_background_default), + AppBackgroundChoice.Nothing to stringResource(R.string.setup_background_nothing), + AppBackgroundChoice.Wallpaper to stringResource(R.string.settings_background_wallpaper), + ) + ChoiceRow( + label = stringResource(R.string.settings_background_style), + options = choices.map { it.second }, + selected = choices.first { it.first == appBackgroundChoiceFor(settings) }.second, + ) { selectedLabel -> + choices.firstOrNull { it.second == selectedLabel }?.first?.let { choice -> + viewModel.updateSettings(settings.withAppBackgroundChoice(choice)) + } + } + // Keep the choices discoverable while the backdrop is off. Choosing an image or preset turns + // the backdrop on, so users do not have to know that the switch used to gate these controls. + CatalogBackgroundPicker(settings = settings, onSettingsChange = viewModel::updateSettings) +} + +/** Est. bandwidth a stream at [mbps] pulls per hour, shared by Settings and the in-stream panel. */ +internal fun streamBitrateUsageEstimate(mbps: Float): String = + "Est. data usage: %.1f GB/hour".format((mbps * 3600f) / (8f * 1000f)) + +@Composable +private fun streamPresetLabel(preset: StreamPreset): String = + when (preset) { + StreamPreset.Recommended -> stringResource(R.string.stream_preset_recommended) + StreamPreset.Custom -> stringResource(R.string.stream_preset_custom) + StreamPreset.LowDataSaver -> stringResource(R.string.stream_preset_low_data_saver) + StreamPreset.Medium -> stringResource(R.string.stream_preset_medium) + StreamPreset.High -> stringResource(R.string.stream_preset_high) + } + +@Composable +private fun introMusicStartModeLabel(mode: IntroMusicStartMode): String = + when (mode) { + IntroMusicStartMode.Muted -> stringResource(R.string.intro_music_start_muted) + IntroMusicStartMode.Playing -> stringResource(R.string.intro_music_start_playing) + } + +@Composable +private fun appLaunchPageLabel(page: AppLaunchPage): String = + when (page) { + AppLaunchPage.Store -> stringResource(R.string.launch_page_store) + AppLaunchPage.Library -> stringResource(R.string.launch_page_library) + } + +@Composable +private fun SettingsAccountCard(state: OpenNowUiState, onClick: () -> Unit) { + val account = state.savedAccounts.firstOrNull { it.userId == state.authSession?.user?.userId } + ?: state.savedAccounts.firstOrNull() + val displayName = account?.displayName?.takeIf { it.isNotBlank() } + ?: state.authSession?.user?.displayName?.takeIf { it.isNotBlank() } + ?: "NVIDIA Account" + val email = account?.email?.takeIf { it.isNotBlank() } + ?: state.authSession?.user?.email?.takeIf { it.isNotBlank() } + val tier = state.subscriptionInfo?.membershipTier?.takeIf { it.isNotBlank() } + ?: state.authSession?.user?.membershipTier?.takeIf { it.isNotBlank() } + ?: account?.membershipTier?.takeIf { it.isNotBlank() } + val detail = listOfNotNull(email, tier).joinToString(" - ").ifBlank { + if (state.authSession == null && account == null) "Sign in to sync your GeForce NOW account" else "Manage account" + } + var focused by remember { mutableStateOf(false) } + val shape = RoundedCornerShape(28.dp) + Box(Modifier.fillMaxWidth()) { + Surface( + modifier = Modifier + .fillMaxWidth() + .clip(shape) + .onFocusChanged { focused = it.isFocused } + .clickable(onClick = onClick), + shape = shape, + color = if (focused) MaterialTheme.colorScheme.surfaceVariant else MaterialTheme.colorScheme.surface, + ) { + Row( + modifier = Modifier.padding(horizontal = 18.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp), + ) { + Surface( + modifier = Modifier.size(52.dp), + shape = CircleShape, + color = MaterialTheme.colorScheme.primary, + ) { + Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) { + Text( + displayName.firstOrNull()?.uppercaseChar()?.toString() ?: "N", + color = MaterialTheme.colorScheme.onPrimary, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + ) + } + } + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + displayName, + color = SettingsText, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + detail, + color = SettingsTextMuted, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Icon( + painter = painterResource(R.drawable.ic_chevron_right), + contentDescription = null, + tint = SettingsTextMuted, + modifier = Modifier.size(22.dp), + ) + } + } + InteractionFocusFrame( + visible = focused, + cornerRadius = 28.dp, + cinemaEffectEnabled = LocalAbsoluteCinemaEffects.current, + ) + } +} + +@Composable +private fun SettingsCategoryRow(category: SettingsCategory, onClick: () -> Unit) { + var focused by remember { mutableStateOf(false) } + val shape = RoundedCornerShape(14.dp) + val accent = MaterialTheme.colorScheme.primary + Box( + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(shape) + .onFocusChanged { focused = it.isFocused || it.hasFocus } + .background(if (focused) accent.copy(alpha = 0.22f) else Color.Transparent) + .clickable(onClick = onClick) + .padding(horizontal = 20.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(18.dp), + ) { + Surface( + modifier = Modifier.size(42.dp), + shape = RoundedCornerShape(14.dp), + color = if (focused) accent else accent.copy(alpha = 0.16f), + ) { + Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) { + Icon( + imageVector = category.icon, + contentDescription = null, + tint = if (focused) MaterialTheme.colorScheme.onPrimary else accent, + modifier = Modifier.size(22.dp), + ) + } + } + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + stringResource(category.titleRes), + color = if (focused) Color.White else SettingsText, + style = MaterialTheme.typography.titleMedium, + fontWeight = if (focused) FontWeight.ExtraBold else FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + stringResource(category.summaryRes), + color = if (focused) Color.White.copy(alpha = 0.86f) else SettingsTextMuted, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Icon( + painter = painterResource(R.drawable.ic_chevron_right), + contentDescription = null, + tint = if (focused) accent else SettingsTextMuted, + modifier = Modifier.size(22.dp), + ) + } + InteractionFocusFrame( + visible = focused, + cornerRadius = 14.dp, + cinemaEffectEnabled = LocalAbsoluteCinemaEffects.current, + ) + } +} + +@Composable +private fun SettingsDetailHeader( + category: SettingsCategory, + tvProfile: Boolean, + controllerFamily: AndroidControllerFamily?, + onBack: () -> Unit, +) { + val controllerNavigationEnabled = LocalSettingsControllerNavigationEnabled.current + val showHardwareBackHint = tvProfile || controllerNavigationEnabled + var backFocused by remember { mutableStateOf(false) } + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + if (showHardwareBackHint) { + Surface( + modifier = Modifier + .onFocusChanged { backFocused = it.isFocused || it.hasFocus } + .border( + width = if (backFocused) 3.dp else 1.dp, + color = cinemaBorderColor( + LocalAbsoluteCinemaEffects.current, + LocalActiveSelectionColor.current, + ), + shape = RoundedCornerShape(999.dp), + ) + .clickable(onClick = onBack), + shape = RoundedCornerShape(999.dp), + color = if (backFocused) MaterialTheme.colorScheme.primary.copy(alpha = 0.24f) else Color.Transparent, + ) { + Row( + // The badge touches the capsule edge so its white ring and the focused outer + // ring read as one continuous controller affordance instead of two outlines + // separated by a dark crescent. + modifier = Modifier.padding(end = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(7.dp), + ) { + val backBadgeColor = when (controllerFamily) { + null, AndroidControllerFamily.Google -> Color.White + AndroidControllerFamily.Xbox -> Color(0xFFFFC107) + AndroidControllerFamily.PlayStation -> Color(0xFFE94B5F) + AndroidControllerFamily.Nintendo -> Color(0xFFE60012) + AndroidControllerFamily.Generic -> MaterialTheme.colorScheme.primary + } + Surface( + modifier = Modifier + .size(38.dp) + .border( + 2.dp, + cinemaBorderColor( + LocalAbsoluteCinemaEffects.current, + LocalActiveSelectionColor.current, + ), + CircleShape, + ), + shape = CircleShape, + color = backBadgeColor, + ) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + when (controllerFamily) { + null, AndroidControllerFamily.Google -> Icon( + painter = painterResource(R.drawable.ic_arrow_back), + contentDescription = stringResource(R.string.cd_remote_back), + tint = Color.Black, + modifier = Modifier.size(17.dp), + ) + AndroidControllerFamily.PlayStation -> Text( + "○", + color = Color.White, + fontWeight = FontWeight.Black, + style = MaterialTheme.typography.titleMedium, + ) + AndroidControllerFamily.Xbox, + AndroidControllerFamily.Nintendo, + AndroidControllerFamily.Generic, + -> Text( + "B", + color = if (controllerFamily == AndroidControllerFamily.Xbox) Color.Black else Color.White, + fontWeight = FontWeight.Black, + style = MaterialTheme.typography.labelLarge, + ) + } + } + } + Text( + stringResource(R.string.remote_back_label), + color = SettingsText, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.padding(end = 7.dp), + ) + } + } + } else { + Surface( + modifier = Modifier + .size(42.dp) + .border(1.dp, SettingsTextMuted.copy(alpha = 0.5f), CircleShape) + .clickable(onClick = onBack), + shape = CircleShape, + color = Color.Transparent, + ) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Icon( + painter = painterResource(R.drawable.ic_arrow_back), + contentDescription = stringResource(R.string.action_back), + tint = SettingsText, + modifier = Modifier.size(21.dp), + ) + } + } + } + Column(Modifier.fillMaxWidth()) { + Text( + stringResource(category.titleRes), + color = SettingsText, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + stringResource(category.summaryRes), + color = SettingsTextMuted, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@Composable +private fun CategorySettingsSection( + selectedCategory: SettingsCategory?, + category: SettingsCategory, + searchQuery: String, + title: String, + vararg keywords: String, + content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit, +) { + if (searchQuery.isNotBlank() || selectedCategory == null || selectedCategory == category) { + SearchableSettingsSection(searchQuery, title, *keywords, content = content) + } +} + +/** + * Every category the user can currently reach. Account was previously reachable only via the + * account card, and About had no row at all — its content was instead duplicated inline into the + * landing list, so it rendered twice in the body while being absent from the category list. + * + * Developer options are absent until the About build-number gesture unlocks them, and disappear + * again when hidden from inside the page. + */ +private fun settingsCategories(developerOptionsUnlocked: Boolean): List = + SettingsCategory.entries.filter { + it != SettingsCategory.TvPairing && + (it != SettingsCategory.Developer || developerOptionsUnlocked) + } + +private fun settingsDetailCategories(developerOptionsUnlocked: Boolean): List = + SettingsCategory.entries.filter { it != SettingsCategory.Developer || developerOptionsUnlocked } + +private fun settingsCategoryParent(category: SettingsCategory?): SettingsCategory? = + if (category == SettingsCategory.TvPairing) SettingsCategory.Account else null + +private fun settingsRouteDepth(category: SettingsCategory?): Int = when (category) { + null -> 0 + SettingsCategory.TvPairing -> 2 + else -> 1 +} diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowSetupScreens.kt b/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowSetupScreens.kt new file mode 100644 index 000000000..61accf3a0 --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowSetupScreens.kt @@ -0,0 +1,1974 @@ +package com.opencloudgaming.opennow + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +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.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +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.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import coil3.compose.AsyncImage +import com.opencloudgaming.opennow.ui.controls.ControlRow +import com.opencloudgaming.opennow.ui.controls.ControlRowStyle +import com.opencloudgaming.opennow.ui.controls.ControlRowLabels +import com.opencloudgaming.opennow.ui.controls.ControlSection +import com.opencloudgaming.opennow.ui.controls.LocalControlRowStyle +import com.opencloudgaming.opennow.ui.controls.controlRowStyle +import com.opencloudgaming.opennow.ui.theme.OpenNowRadius +import com.opencloudgaming.opennow.ui.theme.OpenNowSpacing +import kotlin.math.abs +import kotlin.math.roundToInt +import kotlinx.coroutines.delay + +/** + * First-run setup. + * + * Runs after sign-in, over the whole app, and writes every choice straight through to + * [AppSettings] as it is made. Applying live is what makes the appearance step a real preview + * rather than a mock-up of one: the screen behind the content *is* [CatalogWallpaperBackdrop] with + * the user's current pick, the box art on it comes from their own catalog, and the accent + * recolours this flow's chrome as they choose it. Waiting until after sign-in is what buys that + * artwork — before it there is no catalog to preview against. + * + * Step order, gating, and the settings written on exit live in `AndroidSetupFlow.kt`. + */ +@Composable +internal fun SetupFlowScreen(state: OpenNowUiState, viewModel: OpenNowViewModel) { + val settings = state.settings + val tvProfile = state.androidTvProfile + val controllerNavigationEnabled = tvProfile || rememberPhysicalControllerConnected(enabled = true) + val focusManager = LocalFocusManager.current + val primaryFocusRequester = remember { FocusRequester() } + val scrollState = rememberScrollState() + var step by rememberSaveable { mutableStateOf(SetupStep.Welcome) } + var furthestStepOrdinal by rememberSaveable { mutableStateOf(SetupStep.Welcome.ordinal) } + val furthestStep = SetupStep.entries[furthestStepOrdinal] + + fun finish(skipped: Boolean) { + OpenNowAnalytics.capture( + event = "setup_flow_finished", + properties = mapOf( + "skipped" to skipped, + "last_step" to step.name, + "furthest_step" to furthestStep.name, + ), + ) + viewModel.updateSettings(settings.completingSetupFlow(furthestStep)) + } + + BackHandler(enabled = step != SetupStep.Welcome) { + setupStepBefore(step)?.let { step = it } + } + // Land on the primary action, not on "Skip" — which is what a D-pad's first key press would + // otherwise reach, since it comes first in the footer. The requester is not attached until the + // step has been laid out, so retry rather than betting on a single delay. + LaunchedEffect(step) { + scrollState.scrollTo(0) + repeat(6) { attempt -> + if (runCatching { primaryFocusRequester.requestFocus() }.isSuccess) return@LaunchedEffect + if (attempt < 5) delay(80) + } + } + + val edgePadding = if (tvProfile) { + OpenNowSpacing.xl + settings.tvSafeAreaPaddingDp.coerceIn(0f, 120f).dp + } else { + OpenNowSpacing.lg + } + + CompositionLocalProvider( + LocalSettingsControllerNavigationEnabled provides controllerNavigationEnabled, + // Settings rows are translucent because in Settings they sit on a flat background. Here + // they sit on the user's wallpaper, where 76% opacity puts box art behind the labels. + // Opaque rows keep the picture vivid everywhere it is not covering text. + LocalControlRowStyle provides ControlRowStyle.settings().let { style -> + style.copy( + containerRest = MaterialTheme.colorScheme.surfaceVariant, + containerFocused = MaterialTheme.colorScheme.surfaceVariant, + ) + }, + ) { + BoxWithConstraints(Modifier.fillMaxSize()) { + val wideLayout = maxWidth >= 720.dp + Box(Modifier.matchParentSize().background(MaterialTheme.colorScheme.background)) + if (settings.nerdCatalogBackground) { + CatalogWallpaperBackdrop( + settings = settings, + tvProfile = tvProfile, + width = maxWidth, + height = maxHeight, + ) + // The wallpaper's own scrim is cut for artwork sitting on it, not for paragraphs. + // Weight this one towards the bottom so the controls stay readable while the top of + // the picture — the part being chosen — comes through close to untouched. + val background = MaterialTheme.colorScheme.background + Box( + Modifier + .matchParentSize() + .background( + Brush.verticalGradient( + listOf( + background.copy(alpha = 0.25f), + background.copy(alpha = 0.6f), + background.copy(alpha = 0.88f), + ), + ), + ), + ) + } else if (settings.ambientBackgroundEnabled) { + AmbientBackground() + } + Column( + Modifier + .fillMaxSize() + .systemBarsPadding() + .padding(horizontal = edgePadding, vertical = OpenNowSpacing.lg), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Column( + Modifier + .widthIn(max = SetupContentMaxWidth) + .fillMaxSize() + // On the whole flow, not just the scrolling part: a D-pad has to be able to + // cross between the step's controls and the footer, and Compose's default + // vertical search does not reliably step into and out of the animated + // content container on its own. + .onPreviewKeyEvent { handleVerticalDpadFocusMove(it, focusManager) }, + verticalArrangement = Arrangement.spacedBy(OpenNowSpacing.md), + ) { + SetupProgressBar(step) + Box(Modifier.weight(1f).fillMaxWidth()) { + AnimatedContent( + targetState = step, + transitionSpec = { fadeIn(tween(160)) togetherWith fadeOut(tween(120)) }, + label = "setup-step", + ) { currentStep -> + // The hero centres itself in the viewport, so it must not be inside a + // scroll container — that would measure it against an infinite height + // and vertical centring would resolve to "hug the top". + if (currentStep == SetupStep.Welcome) { + SetupWelcomeStep(wideLayout = wideLayout) + } else { + Column( + Modifier.fillMaxSize().verticalScroll(scrollState), + verticalArrangement = Arrangement.spacedBy(OpenNowSpacing.md), + ) { + SetupStepHeading(currentStep) + when (currentStep) { + SetupStep.Appearance -> SetupAppearanceStep( + state = state, + onSettingsChange = viewModel::updateSettings, + ) + SetupStep.Streaming -> SetupStreamingStep( + state = state, + viewModel = viewModel, + ) + SetupStep.Play -> SetupPlayStep( + settings = settings, + tvProfile = tvProfile, + onSettingsChange = viewModel::updateSettings, + ) + SetupStep.Feedback -> SetupFeedbackStep( + settings = settings, + onSettingsChange = viewModel::updateSettings, + ) + else -> SetupReadyStep(settings = settings, tvProfile = tvProfile) + } + Spacer(Modifier.height(OpenNowSpacing.sm)) + } + } + } + } + SetupStepFooter( + step = step, + primaryFocusRequester = primaryFocusRequester, + onBack = { setupStepBefore(step)?.let { step = it } }, + onSkip = { finish(skipped = true) }, + onNext = { + val next = setupStepAfter(step) + if (next == null) { + finish(skipped = false) + } else { + step = next + if (next.ordinal > furthestStepOrdinal) furthestStepOrdinal = next.ordinal + } + }, + ) + } + } + } + } +} + +@Composable +private fun SetupProgressBar(step: SetupStep) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) { + setupSteps().forEach { candidate -> + Box( + Modifier + .weight(1f) + .height(3.dp) + .clip(CircleShape) + .background( + if (candidate.ordinal <= step.ordinal) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.22f) + }, + ), + ) + } + } +} + +@Composable +private fun SetupStepHeading(step: SetupStep) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + stringResource(step.titleRes), + color = MaterialTheme.colorScheme.onBackground, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + ) + Text( + stringResource(step.subtitleRes), + // Brighter than the usual muted body: this line sits directly on the user's wallpaper + // rather than on a panel, and onSurfaceVariant loses against busy artwork. + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.82f), + style = MaterialTheme.typography.bodyMedium, + ) + } +} + +@Composable +private fun SetupStepFooter( + step: SetupStep, + primaryFocusRequester: FocusRequester, + onBack: () -> Unit, + onSkip: () -> Unit, + onNext: () -> Unit, +) { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(OpenNowSpacing.sm), + verticalAlignment = Alignment.CenterVertically, + ) { + if (step != SetupStep.Welcome) { + TextButton(onClick = onBack) { + Text(stringResource(R.string.setup_action_back)) + } + } + Spacer(Modifier.weight(1f)) + if (!isFinalSetupStep(step)) { + TextButton(onClick = onSkip) { + Text(stringResource(R.string.setup_action_skip), maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + Button(onClick = onNext, modifier = Modifier.focusRequester(primaryFocusRequester)) { + Text( + stringResource( + when { + step == SetupStep.Welcome -> R.string.setup_action_start + isFinalSetupStep(step) -> R.string.setup_action_finish + else -> R.string.setup_action_next + }, + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@Composable +private fun SetupWelcomeStep(wideLayout: Boolean) { + Column( + Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + OpenNowMark(size = if (wideLayout) 88.dp else 64.dp) + Spacer(Modifier.height(OpenNowSpacing.lg)) + Text( + stringResource(R.string.app_name), + color = MaterialTheme.colorScheme.onBackground, + style = MaterialTheme.typography.displaySmall, + fontWeight = FontWeight.Bold, + ) + Spacer(Modifier.height(OpenNowSpacing.xs)) + Text( + stringResource(R.string.setup_welcome_tagline), + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.titleMedium, + ) + } +} + +@Composable +private fun SetupAppearanceStep(state: OpenNowUiState, onSettingsChange: (AppSettings) -> Unit) { + val settings = state.settings + val context = LocalContext.current + val launchImagePicker = rememberCatalogBackgroundImagePicker(settings, onSettingsChange) + val customUri = settings.nerdCatalogBackgroundUri?.takeIf { it.isNotBlank() } + val backgroundName = when { + appBackgroundChoiceFor(settings) == AppBackgroundChoice.Default -> + stringResource(R.string.setup_background_default) + appBackgroundChoiceFor(settings) == AppBackgroundChoice.Nothing -> + stringResource(R.string.setup_background_nothing) + customUri != null -> stringResource(R.string.settings_catalog_background_image_custom) + else -> catalogBackgroundPresetLabel(settings.catalogBackgroundPreset) + } + Column( + Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(OpenNowSpacing.md), + ) { + SetupAppearancePreview(state) + + Column(verticalArrangement = Arrangement.spacedBy(OpenNowSpacing.sm)) { + SetupSectionLabel(stringResource(R.string.setup_background), backgroundName) + SetupPeekRow { tileWidth -> + SetupTile( + width = tileWidth, + selected = appBackgroundChoiceFor(settings) == AppBackgroundChoice.Default, + onClick = { onSettingsChange(settings.withAppBackgroundChoice(AppBackgroundChoice.Default)) }, + ) { + Box(Modifier.matchParentSize().background(MaterialTheme.colorScheme.background)) + AmbientBackground(Modifier.matchParentSize()) + Text( + stringResource(R.string.setup_background_default), + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.labelMedium, + ) + } + SetupTile( + width = tileWidth, + selected = appBackgroundChoiceFor(settings) == AppBackgroundChoice.Nothing, + onClick = { onSettingsChange(settings.withAppBackgroundChoice(AppBackgroundChoice.Nothing)) }, + ) { + Box(Modifier.matchParentSize().background(MaterialTheme.colorScheme.background)) + Text( + stringResource(R.string.setup_background_nothing), + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.labelMedium, + ) + } + CatalogBackgroundPreset.entries.forEach { preset -> + SetupTile( + width = tileWidth, + selected = settings.nerdCatalogBackground && + customUri == null && + settings.catalogBackgroundPreset == preset, + onClick = { applyCatalogBackgroundPreset(context, settings, preset, onSettingsChange) }, + ) { + Image( + painter = painterResource(preset.drawableRes), + contentDescription = catalogBackgroundPresetLabel(preset), + modifier = Modifier.matchParentSize(), + contentScale = ContentScale.Crop, + ) + } + } + SetupTile( + width = tileWidth, + selected = settings.nerdCatalogBackground && customUri != null, + onClick = launchImagePicker, + ) { + if (customUri == null) { + Box( + Modifier + .matchParentSize() + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.7f)), + ) + Text( + stringResource(R.string.setup_background_your_image), + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.labelMedium, + ) + } else { + AsyncImage( + model = imageDataForSource(customUri), + contentDescription = stringResource(R.string.settings_catalog_background_image_custom), + modifier = Modifier.matchParentSize(), + contentScale = ContentScale.Crop, + ) + } + } + } + } + + Column(verticalArrangement = Arrangement.spacedBy(OpenNowSpacing.sm)) { + SetupSectionLabel( + stringResource(R.string.setup_appearance_accent), + uiAccentLabel(settings.uiAccent), + ) + Row( + Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(OpenNowSpacing.sm), + ) { + selectableUiAccents().forEach { accent -> + SetupAccentSwatch( + accent = accent, + selected = settings.uiAccent == accent, + onClick = { onSettingsChange(settings.copy(uiAccent = accent)) }, + ) + } + } + } + + // Everything below changes the preview above. That is the point of putting them here + // rather than leaving them to be discovered in Settings > Interface much later. + Column(verticalArrangement = Arrangement.spacedBy(OpenNowSpacing.sm)) { + SetupSectionLabel( + stringResource(R.string.setup_appearance_layout), + stringResource(R.string.setup_appearance_layout_hint), + ) + SettingSwitch( + label = stringResource(R.string.setup_appearance_titles), + checked = settings.showCardTitles, + description = stringResource(R.string.setup_appearance_titles_desc), + ) { + onSettingsChange(settings.copy(showCardTitles = it)) + } + SettingSwitch( + label = stringResource(R.string.setup_appearance_compact), + checked = settings.compactGameCards, + description = stringResource(R.string.setup_appearance_compact_desc), + ) { + onSettingsChange(settings.copy(compactGameCards = it)) + } + SettingSwitch( + label = stringResource(R.string.setup_appearance_favorite_icon), + checked = settings.showFavoriteIconOnGameCards, + description = stringResource(R.string.setup_appearance_favorite_icon_desc), + ) { + onSettingsChange(settings.copy(showFavoriteIconOnGameCards = it)) + } + SettingSwitch( + label = stringResource(R.string.setup_appearance_expressive), + checked = settings.expressiveUi, + description = stringResource(R.string.setup_appearance_expressive_desc), + ) { + onSettingsChange(settings.copy(expressiveUi = it)) + } + SettingSwitch( + label = stringResource(R.string.settings_live_selected_outlines), + checked = settings.liveSelectedOutlines, + description = stringResource(R.string.settings_live_selected_outlines_desc), + ) { + onSettingsChange(settings.copy(liveSelectedOutlines = it)) + } + SettingSwitch( + label = stringResource(R.string.settings_absolute_cinema_effects), + checked = settings.absoluteCinemaEffects, + description = stringResource(R.string.settings_absolute_cinema_effects_desc), + ) { enabled -> + onSettingsChange( + settings.copy( + absoluteCinemaEffects = enabled, + absoluteCinemaEverywhere = settings.absoluteCinemaEverywhere && enabled, + ), + ) + } + SettingSwitch( + label = stringResource(R.string.settings_im_crazy), + checked = settings.absoluteCinemaEverywhere, + enabled = settings.absoluteCinemaEffects, + description = stringResource(R.string.settings_im_crazy_desc), + indentLevel = 1, + ) { + onSettingsChange(settings.copy(absoluteCinemaEverywhere = it)) + } + SettingSwitch( + label = stringResource(R.string.setup_appearance_animations), + checked = settings.controllerBackgroundAnimations, + description = stringResource(R.string.setup_appearance_animations_desc), + ) { + onSettingsChange(settings.copy(controllerBackgroundAnimations = it)) + } + } + + // Feedback belongs on this step rather than buried in Settings > Interface: both fire on + // the very next tap, so setup is the one moment where turning them off costs nothing to + // find out about. + Column(verticalArrangement = Arrangement.spacedBy(OpenNowSpacing.sm)) { + SetupSectionLabel( + stringResource(R.string.setup_appearance_feedback), + stringResource(R.string.setup_appearance_feedback_hint), + ) + SettingSwitch( + label = stringResource(R.string.setup_appearance_haptics), + checked = settings.vibrationEnabled, + description = stringResource(R.string.setup_appearance_haptics_desc), + ) { + onSettingsChange(settings.copy(vibrationEnabled = it)) + } + SettingSwitch( + label = stringResource(R.string.setup_appearance_sounds), + checked = settings.controllerUiSounds, + description = stringResource(R.string.setup_appearance_sounds_desc), + ) { + onSettingsChange(settings.copy(controllerUiSounds = it)) + } + } + } +} + +/** + * A working miniature of the app, on the user's own backdrop and box art. + * + * This replaces a flat strip of posters that showed the backdrop and nothing else. It is drawn as + * the app rather than as a sample of one — its own window with the Store's top bar, a section + * heading, the grid, and the tab bar underneath — because the choices on this step apply to the + * chrome as much as to the cards, and a bare row of posters could not show that. Every switch below + * redraws it: corner radius, captions, card shape, the favourite badge, and the frame on the + * selected card. + * + * Non-interactive by design: it is the sample, and the controls below it are the step. + * + * Renders nothing until the catalogue has artwork. Placeholder blocks would preview nothing and + * read as an unfinished screen. + */ +@Composable +private fun SetupAppearancePreview(state: OpenNowUiState) { + val settings = state.settings + val tvProfile = state.androidTvProfile + val games = remember(state.games, state.libraryGames, tvProfile) { + (state.libraryGames + state.games) + .distinctBy { it.id } + .filter { !catalogCardImageUrl(it, tvProfile).isNullOrBlank() } + .take(SETUP_PREVIEW_CARD_COUNT) + } + if (games.isEmpty()) return + val cardShape = RoundedCornerShape(if (settings.expressiveUi) OpenNowRadius.md else OpenNowRadius.sm) + val windowShape = RoundedCornerShape(if (settings.expressiveUi) OpenNowRadius.lg else OpenNowRadius.sm) + Column( + Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(OpenNowSpacing.xs), + ) { + Text( + stringResource(R.string.setup_appearance_preview_label), + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.72f), + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.SemiBold, + ) + Surface( + modifier = Modifier.fillMaxWidth(), + shape = windowShape, + // Opaque, and outlined: this is meant to read as a screenshot of the app sitting on the + // wallpaper, not as another translucent panel belonging to setup. + color = MaterialTheme.colorScheme.background, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.onBackground.copy(alpha = 0.14f)), + ) { + Column(Modifier.fillMaxWidth()) { + SetupPreviewTopBar() + Column( + Modifier + .fillMaxWidth() + .padding(horizontal = OpenNowSpacing.sm), + verticalArrangement = Arrangement.spacedBy(OpenNowSpacing.xs), + ) { + Text( + stringResource(R.string.store_results), + color = MaterialTheme.colorScheme.onBackground, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + ) + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(OpenNowSpacing.sm), + ) { + games.forEachIndexed { index, game -> + // The first card stands in for the focused one, so the accent and the + // Absolute Cinema frame have somewhere to show. + val selected = index == 0 + Column( + Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(OpenNowSpacing.xs), + ) { + Box( + Modifier + .fillMaxWidth() + .aspectRatio( + if (settings.compactGameCards) 1f else GAME_BOX_ART_ASPECT_RATIO, + ), + ) { + Box( + Modifier + .matchParentSize() + .clip(cardShape) + .then( + if (selected && !LocalAbsoluteCinemaEffects.current) { + Modifier.border( + 2.dp, + LocalSelectionTintColor.current, + cardShape, + ) + } else { + Modifier + }, + ), + ) { + UrlImage( + catalogCardImageUrl(game, tvProfile), + Modifier.matchParentSize(), + contentScale = ContentScale.Crop, + ) + if (settings.showFavoriteIconOnGameCards) { + SetupPreviewFavoriteBadge( + Modifier + .align(Alignment.TopStart) + .padding(4.dp), + ) + } + } + ControllerFocusFrame( + visible = selected && LocalAbsoluteCinemaEffects.current, + cornerRadius = if (settings.expressiveUi) OpenNowRadius.md else OpenNowRadius.sm, + tint = LocalActiveSelectionColor.current, + secondaryTint = LocalActiveSelectionSecondaryColor.current, + ) + } + if (settings.showCardTitles) { + Text( + game.title, + color = MaterialTheme.colorScheme.onBackground, + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } + } + SetupPreviewTabBar() + } + } + } +} + +/** The Store's top bar, reduced to the shapes that read at this size. */ +@Composable +private fun SetupPreviewTopBar() { + Row( + Modifier + .fillMaxWidth() + .padding(horizontal = OpenNowSpacing.sm, vertical = OpenNowSpacing.sm), + horizontalArrangement = Arrangement.spacedBy(OpenNowSpacing.sm), + verticalAlignment = Alignment.CenterVertically, + ) { + OpenNowMark(size = 14.dp) + Text( + stringResource(R.string.nav_store), + Modifier.weight(1f), + color = MaterialTheme.colorScheme.onBackground, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + ) + SetupPreviewChromeDot() + SetupPreviewChromeDot() + } +} + +@Composable +private fun SetupPreviewChromeDot() { + Box( + Modifier + .size(12.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.onBackground.copy(alpha = 0.22f)), + ) +} + +/** + * The tab bar, with Store selected. + * + * Worth drawing even though the accent no longer reaches it: showing the fixed navigation tint + * beside an accent-coloured card is exactly how the two read in the real app, and a preview that + * left the chrome out would imply the accent covers everything. + */ +@Composable +private fun SetupPreviewTabBar() { + val labels = listOf( + stringResource(R.string.nav_store) to true, + stringResource(R.string.nav_library) to false, + stringResource(R.string.nav_settings) to false, + ) + Row( + Modifier + .fillMaxWidth() + .padding(top = OpenNowSpacing.sm) + .background(MaterialTheme.colorScheme.surface) + .padding(vertical = 6.dp), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically, + ) { + labels.forEach { (label, selected) -> + val tint = if (selected) { + NavigationSelectionColor + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Box( + Modifier + .size(width = 20.dp, height = 4.dp) + .clip(CircleShape) + .background(tint.copy(alpha = if (selected) 1f else 0.45f)), + ) + Spacer(Modifier.height(3.dp)) + Text( + label, + color = tint, + style = MaterialTheme.typography.labelSmall, + fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +@Composable +private fun SetupPreviewFavoriteBadge(modifier: Modifier = Modifier) { + Box( + modifier + .size(20.dp) + .clip(CircleShape) + .background(Color.Black.copy(alpha = 0.42f)), + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(R.drawable.ic_save), + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(11.dp), + ) + } +} + +/** + * A horizontally scrolling row of picture tiles that always cuts one off at the trailing edge. + * + * The tiles previously used a fixed 116dp width, which on a typical phone left the row *just* + * filled: nothing was clipped, no scrollbar showed, and the options past the fold were invisible + * unless the user happened to try dragging. Sizing the tiles from the available width instead + * guarantees a partial one at the edge, which is the affordance — a cut-off tile is read as "there + * is more" without any extra chrome. The fade reinforces it and disappears at the end of the row. + */ +@Composable +private fun SetupPeekRow(content: @Composable RowScope.(tileWidth: Dp) -> Unit) { + val scrollState = rememberScrollState() + BoxWithConstraints(Modifier.fillMaxWidth()) { + val tileWidth = ((maxWidth - OpenNowSpacing.sm * SETUP_TILE_PEEK_COUNT) / SETUP_TILE_PEEK_COUNT) + .coerceIn(SetupTileMinWidth, SetupTileMaxWidth) + Row( + Modifier.fillMaxWidth().horizontalScroll(scrollState), + horizontalArrangement = Arrangement.spacedBy(OpenNowSpacing.sm), + ) { + content(tileWidth) + } + // Edge shadows on whichever side has more to reach. Drawn rather than composed: reading the + // scroll position inside drawBehind keeps a drag in the draw phase instead of recomposing + // the whole row — and every tile in it — on every frame of the gesture. Black rather than a + // surface colour because this row sits directly on the user's wallpaper, where a flat + // theme-coloured band would read as a smear and a shadow reads as depth. + Box( + Modifier + .matchParentSize() + .drawBehind { + val fade = SetupPeekFadeWidth.toPx().coerceAtMost(size.width) + if (scrollState.canScrollForward) { + drawRect( + Brush.horizontalGradient( + colors = listOf(Color.Transparent, SetupPeekFadeColor), + startX = size.width - fade, + endX = size.width, + ), + ) + } + if (scrollState.canScrollBackward) { + drawRect( + Brush.horizontalGradient( + colors = listOf(SetupPeekFadeColor, Color.Transparent), + startX = 0f, + endX = fade, + ), + ) + } + }, + ) + } +} + +@Composable +private fun SetupStreamingStep(state: OpenNowUiState, viewModel: OpenNowViewModel) { + val settings = state.settings + val recommended = state.recommendedStreamSettings + val selected = setupStreamingChoiceFor(settings) + val fallbackMembershipTier = state.authSession?.user?.membershipTier + val entitlements = remember(state.subscriptionInfo, fallbackMembershipTier) { + streamPlanEntitlements(state.subscriptionInfo, fallbackMembershipTier) + } + Column( + Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(OpenNowSpacing.sm), + ) { + SetupMembershipCard(entitlements) + SetupChoiceRow( + label = stringResource(R.string.setup_streaming_recommended), + value = recommended?.recommendationSummary() + ?: stringResource(R.string.setup_streaming_measuring), + selected = selected == SetupStreamingChoice.Recommended, + ) { + viewModel.applyStreamPreset(setupStreamingPresetFor(SetupStreamingChoice.Recommended)) + } + SetupChoiceRow( + label = stringResource(R.string.setup_streaming_best), + value = stringResource( + R.string.setup_streaming_best_desc, + entitlements.maxResolutionLabel, + entitlements.maxFps, + ), + selected = selected == SetupStreamingChoice.Best, + ) { + viewModel.applyStreamPreset(setupStreamingPresetFor(SetupStreamingChoice.Best)) + } + SetupChoiceRow( + label = stringResource(R.string.setup_streaming_data_saver), + value = stringResource(R.string.setup_streaming_data_saver_desc), + selected = selected == SetupStreamingChoice.DataSaver, + ) { + viewModel.applyStreamPreset(setupStreamingPresetFor(SetupStreamingChoice.DataSaver)) + } + SetupChoiceRow( + label = stringResource(R.string.setup_streaming_custom), + value = if (selected == SetupStreamingChoice.Custom) { + settings.stream.recommendationSummary() + } else { + stringResource(R.string.setup_streaming_custom_desc) + }, + selected = selected == SetupStreamingChoice.Custom, + ) { + viewModel.applyStreamPreset(setupStreamingPresetFor(SetupStreamingChoice.Custom)) + } + // Only under Custom. Beside a preset these would edit values the next preset write + // discards, which reads as the controls not working. + AnimatedVisibility(visible = setupStreamingCustomControlsVisible(selected)) { + SetupCustomStreamControls( + state = state, + viewModel = viewModel, + entitlements = entitlements, + fallbackMembershipTier = fallbackMembershipTier, + ) + } + } +} + +@Composable +private fun SetupPlayStep( + settings: AppSettings, + tvProfile: Boolean, + onSettingsChange: (AppSettings) -> Unit, +) { + val touchChoice = setupTouchMouseChoiceFor(settings) + var fullScreenPreview by rememberSaveable { mutableStateOf(false) } + Column( + Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(OpenNowSpacing.sm), + ) { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(OpenNowSpacing.sm), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text( + stringResource(R.string.setup_play_preview), + color = MaterialTheme.colorScheme.onBackground, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + Text( + stringResource(R.string.setup_play_preview_hint), + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.72f), + style = MaterialTheme.typography.bodySmall, + ) + } + Button(onClick = { fullScreenPreview = true }) { + Text(stringResource(R.string.setup_play_fullscreen), maxLines = 1) + } + } + SetupStreamExperiencePreview( + settings = settings, + showTouchHint = !tvProfile, + modifier = Modifier.fillMaxWidth().aspectRatio(16f / 9f), + ) + + if (fullScreenPreview) { + SetupFullScreenStreamPreview( + settings = settings, + showTouchHint = !tvProfile, + onDismiss = { fullScreenPreview = false }, + ) + } + + if (!tvProfile) { + SetupSectionLabel( + title = stringResource(R.string.setup_play_touch), + value = setupTouchMouseChoiceLabel(touchChoice), + ) + SetupChoiceRow( + label = stringResource(R.string.setup_play_touch_direct), + value = stringResource(R.string.setup_play_touch_direct_desc), + selected = touchChoice == SetupTouchMouseChoice.Direct, + ) { + onSettingsChange(settings.withSetupTouchMouseChoice(SetupTouchMouseChoice.Direct)) + } + SetupChoiceRow( + label = stringResource(R.string.setup_play_touch_trackpad), + value = stringResource(R.string.setup_play_touch_trackpad_desc), + selected = touchChoice == SetupTouchMouseChoice.Trackpad, + ) { + onSettingsChange(settings.withSetupTouchMouseChoice(SetupTouchMouseChoice.Trackpad)) + } + SetupChoiceRow( + label = stringResource(R.string.setup_play_touch_off), + value = stringResource(R.string.setup_play_touch_off_desc), + selected = touchChoice == SetupTouchMouseChoice.Off, + ) { + onSettingsChange(settings.withSetupTouchMouseChoice(SetupTouchMouseChoice.Off)) + } + } + + SettingSwitch( + label = stringResource(R.string.setup_play_status), + checked = settings.showStatsOnLaunch, + description = stringResource(R.string.setup_play_status_desc), + ) { enabled -> + onSettingsChange(settings.copy(showStatsOnLaunch = enabled)) + } + AnimatedVisibility(visible = settings.showStatsOnLaunch) { + Column(verticalArrangement = Arrangement.spacedBy(OpenNowSpacing.sm)) { + ChoiceMenuRow( + label = stringResource(R.string.stream_statusbar_appearance), + options = StreamStatsStyle.entries.map { style -> + ChoiceMenuOption(value = style.name, label = style.label) + }, + selectedLabel = settings.streamStatsStyle.label, + ) { value -> + StreamStatsStyle.entries.firstOrNull { it.name == value }?.let { style -> + onSettingsChange(settings.copy(streamStatsStyle = style)) + } + } + ChoiceMenuRow( + label = stringResource(R.string.setup_play_status_position), + options = StreamStatsPosition.entries.map { position -> + ChoiceMenuOption(value = position.name, label = position.label) + }, + selectedLabel = settings.streamStatsPosition.label, + ) { value -> + StreamStatsPosition.entries.firstOrNull { it.name == value }?.let { position -> + onSettingsChange(settings.copy(streamStatsPosition = position)) + } + } + SetupSectionLabel( + title = stringResource(R.string.stream_statusbar_items), + value = stringResource( + R.string.setup_play_status_items_selected, + StreamStatusItem.entries.count { it.enabledIn(settings) }, + ), + ) + SetupStreamStatusItems( + settings = settings, + onSettingsChange = onSettingsChange, + ) + } + } + } +} + +@Composable +private fun SetupFullScreenStreamPreview( + settings: AppSettings, + showTouchHint: Boolean, + onDismiss: () -> Unit, +) { + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties( + usePlatformDefaultWidth = false, + decorFitsSystemWindows = false, + ), + ) { + Box( + Modifier + .fillMaxSize() + .background(Color.Black) + .systemBarsPadding() + .padding(8.dp), + ) { + SetupStreamExperiencePreview( + settings = settings, + showTouchHint = showTouchHint, + expanded = true, + modifier = Modifier.fillMaxSize(), + onDismiss = onDismiss, + ) + } + } +} + +/** A fake stream that lets setup choices be rehearsed without sending any input to a session. */ +@Composable +private fun SetupStreamExperiencePreview( + settings: AppSettings, + showTouchHint: Boolean, + modifier: Modifier, + expanded: Boolean = false, + onDismiss: (() -> Unit)? = null, +) { + val touchChoice = setupTouchMouseChoiceFor(settings) + val statusAlignment = when (settings.streamStatsPosition) { + StreamStatsPosition.Left -> Alignment.TopStart + StreamStatsPosition.Center -> Alignment.TopCenter + StreamStatsPosition.Right -> Alignment.TopEnd + } + var cursorX by remember(expanded) { mutableStateOf(0.34f) } + var cursorY by remember(expanded) { mutableStateOf(0.56f) } + var targetHits by remember(expanded) { mutableStateOf(0) } + var practiceMessage by remember(expanded) { mutableStateOf(null) } + var fakeMenuOpen by remember(expanded) { mutableStateOf(false) } + var fakeKeyboardOpen by remember(expanded) { mutableStateOf(false) } + val moveToTargetMessage = stringResource(R.string.setup_play_move_to_target) + + fun cursorOnTarget(): Boolean = abs(cursorX - 0.5f) <= 0.13f && abs(cursorY - 0.48f) <= 0.16f + fun clickCursor() { + if (cursorOnTarget()) { + targetHits += 1 + practiceMessage = null + } else { + practiceMessage = moveToTargetMessage + } + } + + Surface( + modifier = modifier, + shape = RoundedCornerShape(if (expanded) OpenNowRadius.md else OpenNowRadius.lg), + color = Color(0xFF08141C), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.25f)), + ) { + BoxWithConstraints( + Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + listOf(Color(0xFF16384A), Color(0xFF0B2731), Color(0xFF071116)), + ), + ), + ) { + SetupPracticeBackdrop(expanded = expanded) + // This sibling is behind every fake button. It owns only the empty play field, so a + // drag detector can never consume Menu/Keys/A/B or the fullscreen exit action. + Box( + Modifier + .matchParentSize() + .pointerInput(touchChoice) { + detectTapGestures { position -> + if (touchChoice == SetupTouchMouseChoice.Direct) { + cursorX = (position.x / size.width.toFloat()).coerceIn(0.02f, 0.98f) + cursorY = (position.y / size.height.toFloat()).coerceIn(0.04f, 0.96f) + } + clickCursor() + } + } + .pointerInput(touchChoice) { + detectDragGestures( + onDragStart = { position -> + if (touchChoice == SetupTouchMouseChoice.Direct) { + cursorX = (position.x / size.width.toFloat()).coerceIn(0.02f, 0.98f) + cursorY = (position.y / size.height.toFloat()).coerceIn(0.04f, 0.96f) + } + }, + onDrag = { change, dragAmount -> + change.consume() + if (touchChoice == SetupTouchMouseChoice.Direct) { + cursorX = (change.position.x / size.width.toFloat()).coerceIn(0.02f, 0.98f) + cursorY = (change.position.y / size.height.toFloat()).coerceIn(0.04f, 0.96f) + } else { + cursorX = (cursorX + dragAmount.x / size.width.toFloat()).coerceIn(0.02f, 0.98f) + cursorY = (cursorY + dragAmount.y / size.height.toFloat()).coerceIn(0.04f, 0.96f) + } + practiceMessage = null + }, + ) + }, + ) + + Surface( + modifier = Modifier.align(Alignment.Center).offset(y = (-4).dp), + shape = RoundedCornerShape(OpenNowRadius.full), + color = if (cursorOnTarget()) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.primary.copy(alpha = 0.82f) + }, + border = BorderStroke(1.dp, Color.White.copy(alpha = 0.3f)), + ) { + Text( + text = if (targetHits == 0) { + stringResource(R.string.setup_play_preview_target) + } else { + stringResource(R.string.setup_play_preview_hits, targetHits) + }, + modifier = Modifier.padding( + horizontal = if (expanded) OpenNowSpacing.xl else OpenNowSpacing.lg, + vertical = if (expanded) 12.dp else 8.dp, + ), + color = MaterialTheme.colorScheme.onPrimary, + style = if (expanded) MaterialTheme.typography.titleSmall else MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + ) + } + + AnimatedVisibility( + visible = settings.showStatsOnLaunch, + modifier = Modifier.align(statusAlignment), + enter = fadeIn(tween(120)), + exit = fadeOut(tween(90)), + ) { + SetupFakeStatusLine(settings = settings, expanded = expanded) + } + + if (showTouchHint && !expanded) { + Surface( + modifier = Modifier.align(Alignment.BottomStart).padding(10.dp), + shape = RoundedCornerShape(OpenNowRadius.full), + color = Color.Black.copy(alpha = 0.48f), + ) { + Text( + text = stringResource( + when (touchChoice) { + SetupTouchMouseChoice.Direct -> R.string.setup_play_preview_direct + SetupTouchMouseChoice.Trackpad -> R.string.setup_play_preview_trackpad + SetupTouchMouseChoice.Off -> R.string.setup_play_preview_off + }, + ), + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + color = Color.White.copy(alpha = 0.9f), + style = MaterialTheme.typography.labelSmall, + ) + } + } + + SetupPracticeCursor( + modifier = Modifier.offset( + x = (maxWidth - 18.dp) * cursorX, + y = (maxHeight - 24.dp) * cursorY, + ), + ) + + if (fakeMenuOpen) { + SetupPracticeMenu( + expanded = expanded, + onClose = { fakeMenuOpen = false }, + modifier = Modifier.align(Alignment.CenterStart).padding(12.dp), + ) + } + + if (fakeKeyboardOpen) { + SetupPracticeKeyboard( + expanded = expanded, + modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = if (expanded) 66.dp else 46.dp), + ) + } + + Column( + modifier = Modifier.align(Alignment.BottomEnd).padding(if (expanded) 16.dp else 8.dp), + horizontalAlignment = Alignment.End, + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + practiceMessage?.let { message -> + Text( + message, + color = Color.White.copy(alpha = 0.86f), + style = MaterialTheme.typography.labelSmall, + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + SetupPracticeButton( + label = stringResource(R.string.setup_play_fake_menu), + compact = !expanded, + ) { + fakeMenuOpen = !fakeMenuOpen + fakeKeyboardOpen = false + } + SetupPracticeButton( + label = stringResource(R.string.setup_play_fake_keyboard), + compact = !expanded, + ) { + fakeKeyboardOpen = !fakeKeyboardOpen + fakeMenuOpen = false + } + SetupPracticeButton(label = "A", compact = !expanded, emphasized = true) { + clickCursor() + } + SetupPracticeButton(label = "B", compact = !expanded) { + cursorX = 0.34f + cursorY = 0.56f + targetHits = 0 + practiceMessage = null + } + } + } + + if (expanded) { + Surface( + modifier = Modifier.align(Alignment.TopCenter).padding(top = 54.dp), + shape = RoundedCornerShape(OpenNowRadius.full), + color = Color.Black.copy(alpha = 0.45f), + ) { + Text( + stringResource(R.string.setup_play_practice_tip), + modifier = Modifier.padding(horizontal = 14.dp, vertical = 7.dp), + color = Color.White.copy(alpha = 0.86f), + style = MaterialTheme.typography.bodySmall, + ) + } + Button( + onClick = { onDismiss?.invoke() }, + // Keep every status-line position unobstructed while the player evaluates it. + modifier = Modifier.align(Alignment.BottomStart).padding(12.dp), + ) { + Text(stringResource(R.string.setup_play_leave_fullscreen)) + } + } + } + } +} + +@Composable +private fun SetupPracticeBackdrop(expanded: Boolean) { + Canvas(Modifier.fillMaxSize()) { + val horizon = size.height * 0.58f + drawCircle( + color = Color(0xFF3E91A2).copy(alpha = 0.16f), + radius = size.minDimension * 0.34f, + center = center.copy(y = horizon * 0.82f), + ) + drawRect( + color = Color.Black.copy(alpha = 0.18f), + topLeft = center.copy(x = 0f, y = horizon), + size = size.copy(height = size.height - horizon), + ) + val lanes = if (expanded) 8 else 5 + repeat(lanes) { index -> + val fraction = index / (lanes - 1f) + drawLine( + color = Color.White.copy(alpha = 0.055f), + start = center.copy(x = size.width * fraction, y = horizon), + end = center.copy(x = size.width * (fraction * 1.25f - 0.12f), y = size.height), + strokeWidth = 1f, + ) + } + } +} + +@Composable +private fun SetupPracticeCursor(modifier: Modifier = Modifier) { + Canvas(modifier.size(18.dp, 24.dp)) { + val cursor = Path().apply { + moveTo(1f, 1f) + lineTo(size.width * 0.78f, size.height * 0.62f) + lineTo(size.width * 0.48f, size.height * 0.66f) + lineTo(size.width * 0.68f, size.height - 1f) + lineTo(size.width * 0.48f, size.height - 1f) + lineTo(size.width * 0.3f, size.height * 0.7f) + lineTo(1f, size.height * 0.9f) + close() + } + drawPath(cursor, Color.Black.copy(alpha = 0.75f)) + drawPath(cursor, Color.White) + } +} + +@Composable +private fun SetupFakeStatusLine(settings: AppSettings, expanded: Boolean) { + val enabledItems = StreamStatusItem.entries.filter { it.enabledIn(settings) } + val values = enabledItems.mapNotNull { item -> item.previewValueRes?.let { stringResource(it) } } + val keyboardEnabled = StreamStatusItem.Keyboard.enabledIn(settings) + val detailed = settings.streamStatsStyle == StreamStatsStyle.Detailed + Surface( + modifier = Modifier + .padding(if (expanded) 14.dp else 8.dp) + .widthIn(max = if (detailed) 300.dp else if (expanded) 720.dp else 360.dp), + shape = RoundedCornerShape(if (detailed) OpenNowRadius.lg else OpenNowRadius.full), + color = Color.Black.copy(alpha = 0.58f), + border = BorderStroke(1.dp, Color.White.copy(alpha = 0.18f)), + ) { + Row( + Modifier.padding(horizontal = 12.dp, vertical = if (detailed) 8.dp else 6.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = values.takeIf { it.isNotEmpty() }?.joinToString(if (detailed) "\n" else " • ") + ?: stringResource(R.string.setup_play_status_empty), + modifier = Modifier.weight(1f, fill = false), + color = Color.White, + style = MaterialTheme.typography.labelSmall, + maxLines = if (detailed) 5 else 1, + overflow = TextOverflow.Ellipsis, + ) + if (keyboardEnabled) { + Icon( + painter = painterResource(R.drawable.ic_keyboard), + contentDescription = stringResource(R.string.stream_panel_cd_keyboard), + tint = Color.White, + modifier = Modifier.size(18.dp), + ) + } + } + } +} + +@Composable +private fun SetupPracticeMenu(expanded: Boolean, onClose: () -> Unit, modifier: Modifier = Modifier) { + Surface( + modifier = modifier.widthIn(min = if (expanded) 260.dp else 170.dp, max = 320.dp), + shape = RoundedCornerShape(OpenNowRadius.lg), + color = Color(0xFF101B21).copy(alpha = 0.96f), + border = BorderStroke(1.dp, Color.White.copy(alpha = 0.18f)), + ) { + Column( + Modifier.padding(if (expanded) 16.dp else 10.dp), + verticalArrangement = Arrangement.spacedBy(if (expanded) 10.dp else 5.dp), + ) { + Text( + stringResource(R.string.stream_panel_title), + color = Color.White, + style = if (expanded) MaterialTheme.typography.titleMedium else MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + ) + listOf( + stringResource(R.string.setup_play_fake_status_line), + stringResource(R.string.setup_play_fake_input), + stringResource(R.string.setup_play_fake_quality), + ).forEach { label -> + Surface( + modifier = Modifier.fillMaxWidth().clickable(onClick = onClose), + shape = RoundedCornerShape(OpenNowRadius.md), + color = Color.White.copy(alpha = 0.07f), + ) { + Text( + label, + modifier = Modifier.padding(horizontal = 10.dp, vertical = if (expanded) 9.dp else 5.dp), + color = Color.White.copy(alpha = 0.86f), + style = MaterialTheme.typography.bodySmall, + ) + } + } + } + } +} + +@Composable +private fun SetupPracticeKeyboard(expanded: Boolean, modifier: Modifier = Modifier) { + Surface( + modifier = modifier, + shape = RoundedCornerShape(OpenNowRadius.md), + color = Color.Black.copy(alpha = 0.72f), + border = BorderStroke(1.dp, Color.White.copy(alpha = 0.16f)), + ) { + Row( + Modifier.padding(horizontal = if (expanded) 12.dp else 8.dp, vertical = 7.dp), + horizontalArrangement = Arrangement.spacedBy(5.dp), + ) { + (if (expanded) listOf("W", "A", "S", "D", "SPACE", "ENTER") else listOf("W", "A", "S", "D")).forEach { key -> + Text( + key, + modifier = Modifier + .clip(RoundedCornerShape(5.dp)) + .background(Color.White.copy(alpha = 0.12f)) + .padding(horizontal = if (key.length > 1) 8.dp else 6.dp, vertical = 4.dp), + color = Color.White, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + ) + } + } + } +} + +@Composable +private fun SetupPracticeButton( + label: String, + compact: Boolean, + emphasized: Boolean = false, + onClick: () -> Unit, +) { + Surface( + modifier = Modifier + .clip(RoundedCornerShape(OpenNowRadius.full)) + .clickable(onClick = onClick), + shape = RoundedCornerShape(OpenNowRadius.full), + color = if (emphasized) MaterialTheme.colorScheme.primary else Color.Black.copy(alpha = 0.62f), + border = BorderStroke(1.dp, Color.White.copy(alpha = 0.22f)), + ) { + Text( + label, + modifier = Modifier.padding( + horizontal = if (compact) 8.dp else 13.dp, + vertical = if (compact) 5.dp else 8.dp, + ), + color = if (emphasized) MaterialTheme.colorScheme.onPrimary else Color.White, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + maxLines = 1, + ) + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun SetupStreamStatusItems( + settings: AppSettings, + onSettingsChange: (AppSettings) -> Unit, +) { + BoxWithConstraints(Modifier.fillMaxWidth()) { + val columns = when { + maxWidth >= 720.dp -> 4 + maxWidth >= 480.dp -> 3 + else -> 2 + } + val gap = 8.dp + val itemWidth = (maxWidth - gap * (columns - 1)) / columns.toFloat() + FlowRow( + modifier = Modifier.fillMaxWidth(), + maxItemsInEachRow = columns, + horizontalArrangement = Arrangement.spacedBy(gap), + verticalArrangement = Arrangement.spacedBy(gap), + ) { + StreamStatusItem.entries.forEach { item -> + val enabled = item.enabledIn(settings) + Surface( + modifier = Modifier + .width(itemWidth) + .clip(RoundedCornerShape(OpenNowRadius.md)) + .clickable { onSettingsChange(item.setEnabled(settings, !enabled)) }, + shape = RoundedCornerShape(OpenNowRadius.md), + color = if (enabled) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.16f) + } else { + MaterialTheme.colorScheme.surfaceVariant + }, + border = BorderStroke( + 1.dp, + if (enabled) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.2f), + ), + ) { + Row( + Modifier.padding(horizontal = 10.dp, vertical = 9.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + Modifier + .size(8.dp) + .clip(CircleShape) + .background( + if (enabled) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.34f), + ), + ) + Text( + stringResource(item.labelRes), + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.labelMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } + } +} + +@Composable +private fun setupTouchMouseChoiceLabel(choice: SetupTouchMouseChoice): String = + stringResource( + when (choice) { + SetupTouchMouseChoice.Direct -> R.string.setup_play_touch_direct + SetupTouchMouseChoice.Trackpad -> R.string.setup_play_touch_trackpad + SetupTouchMouseChoice.Off -> R.string.setup_play_touch_off + }, + ) + +/** + * The membership tier, stated plainly, above the quality choices. + * + * Without it a Free account sees 1080p as the top option and reads it as OpenNOW deciding the + * device cannot do better. Naming the plan and its ceiling makes the cap attributable. + */ +@Composable +private fun SetupMembershipCard(entitlements: StreamPlanEntitlements) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(OpenNowRadius.lg), + color = MaterialTheme.colorScheme.surfaceVariant, + ) { + Column( + Modifier.fillMaxWidth().padding(OpenNowSpacing.md), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + stringResource(R.string.setup_streaming_membership, entitlements.planLabel), + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + Text( + stringResource( + R.string.setup_streaming_membership_ceiling, + entitlements.maxResolutionLabel, + entitlements.maxFps, + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, + ) + if (entitlements.cappedBelowTopTier) { + Text( + stringResource(R.string.setup_streaming_membership_upgrade), + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, + ) + } + } + } +} + +/** + * Resolution, frame rate, and bitrate, edited here rather than in Settings > Stream. + * + * Options above the plan stay listed and disabled with the tier that unlocks them, matching how + * Settings presents them — hiding them would leave the ceiling unexplained all over again. + */ +@Composable +private fun SetupCustomStreamControls( + state: OpenNowUiState, + viewModel: OpenNowViewModel, + entitlements: StreamPlanEntitlements, + fallbackMembershipTier: String?, +) { + val stream = state.settings.stream + val resolutionChoices = streamResolutionChoicesForAspect(stream.aspectRatio).ifEmpty { + streamResolutionChoicesForAspect("16:9") + } + val selectedResolution = normalizeStreamResolutionForAspectAndPlan( + stream.resolution, + stream.aspectRatio, + state.subscriptionInfo, + fallbackMembershipTier, + ) + val codecChoices = androidCodecChoicePresentation( + stream = stream, + codecReport = state.codecReport, + comingSoonLabel = stringResource(R.string.option_coming_soon), + unavailableLabel = stringResource(R.string.common_unavailable), + ) + Column(verticalArrangement = Arrangement.spacedBy(OpenNowSpacing.sm)) { + ChoiceMenuRow( + label = stringResource(R.string.settings_resolution), + options = resolutionChoices.map { choice -> + val available = choice.isAvailableFor(state.subscriptionInfo, fallbackMembershipTier) + ChoiceMenuOption( + value = choice.value, + label = choice.label, + enabled = available, + badge = if (available) null else choice.requiredPlanLabel, + ) + }, + selectedLabel = resolutionChoices.firstOrNull { it.value == selectedResolution }?.label + ?: selectedResolution, + ) { value -> + viewModel.updateStreamSettings { it.copy(resolution = value) } + } + ChoiceMenuRow( + label = stringResource(R.string.settings_codec), + options = codecChoices.options, + selectedLabel = codecChoices.selectedLabel, + description = stringResource(R.string.settings_codec_desc), + ) { value -> + viewModel.updateStreamSettings { + it.copy(codec = VideoCodec.valueOf(value)).withCodecColorCompatibility() + } + } + NumberSlider( + label = stringResource(R.string.settings_fps), + value = stream.fps.coerceAtMost(entitlements.maxFps).toFloat(), + min = 30f, + max = entitlements.maxFps.toFloat(), + step = 30f, + unit = "FPS", + ) { value -> + val fps = value.roundToInt().coerceIn(30, entitlements.maxFps) + viewModel.updateStreamSettings { it.copy(fps = fps) } + } + NumberSlider( + label = stringResource(R.string.settings_bitrate), + value = stream.maxBitrateMbps.toFloat(), + min = 1f, + max = 150f, + step = 1f, + descriptionProvider = { mbps -> streamBitrateUsageEstimate(mbps) }, + ) { value -> + viewModel.updateStreamSettings { it.copy(maxBitrateMbps = value.roundToInt()) } + } + } +} + +@Composable +private fun SetupFeedbackStep(settings: AppSettings, onSettingsChange: (AppSettings) -> Unit) { + Column( + Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(OpenNowSpacing.sm), + ) { + SetupPointsCard( + title = stringResource(R.string.setup_feedback_reporter_title), + points = listOf( + stringResource(R.string.setup_feedback_reporter_where), + stringResource(R.string.setup_feedback_reporter_preflight), + stringResource(R.string.setup_feedback_reporter_contents), + ), + ) + SettingSwitch( + label = stringResource(R.string.setup_feedback_session_report), + checked = settings.showSessionReportAfterStream, + description = stringResource(R.string.setup_feedback_session_report_desc), + ) { + onSettingsChange(settings.copy(showSessionReportAfterStream = it)) + } + SettingSwitch( + label = stringResource(R.string.setup_feedback_analytics), + checked = settings.analyticsSharingEnabled, + description = stringResource(R.string.setup_feedback_analytics_desc), + ) { enabled -> + onSettingsChange(settings.copy(analyticsConsentAsked = true, analyticsOptOut = !enabled)) + } + DiscordCommunityLink( + summary = stringResource(R.string.discord_community_bug_report_summary), + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ) + } +} + +@Composable +private fun SetupReadyStep(settings: AppSettings, tvProfile: Boolean) { + val backgroundValue = when { + appBackgroundChoiceFor(settings) == AppBackgroundChoice.Default -> + stringResource(R.string.setup_background_default) + appBackgroundChoiceFor(settings) == AppBackgroundChoice.Nothing -> + stringResource(R.string.setup_background_nothing) + !settings.nerdCatalogBackgroundUri.isNullOrBlank() -> + stringResource(R.string.settings_catalog_background_image_custom) + else -> catalogBackgroundPresetLabel(settings.catalogBackgroundPreset) + } + ControlSection(stringResource(R.string.setup_summary_title)) { + SetupSummaryRow(stringResource(R.string.setup_background), backgroundValue) + SetupSummaryRow(stringResource(R.string.setup_appearance_accent), uiAccentLabel(settings.uiAccent)) + SetupSummaryRow( + stringResource(R.string.setup_summary_streaming), + settings.stream.recommendationSummary(), + ) + if (!tvProfile) { + SetupSummaryRow( + stringResource(R.string.setup_summary_touch), + setupTouchMouseChoiceLabel(setupTouchMouseChoiceFor(settings)), + ) + } + SetupSummaryRow( + stringResource(R.string.setup_summary_status), + if (settings.showStatsOnLaunch) settings.streamStatsPosition.label + else stringResource(R.string.setup_summary_off), + ) + SetupSummaryRow( + stringResource(R.string.setup_feedback_analytics), + stringResource( + if (settings.analyticsSharingEnabled) R.string.setup_summary_on + else R.string.setup_summary_off, + ), + ) + } +} + +@Composable +private fun SetupSectionLabel(title: String, value: String) { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(OpenNowSpacing.sm), + verticalAlignment = Alignment.Bottom, + ) { + Text( + title, + color = MaterialTheme.colorScheme.onBackground, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + Text( + value, + Modifier.weight(1f), + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.72f), + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +/** A picture-sized, D-pad focusable option. Selection is the accent ring; focus is the white one. */ +@Composable +private fun SetupTile( + selected: Boolean, + onClick: () -> Unit, + width: Dp = SetupTileMaxWidth, + content: @Composable BoxScope.() -> Unit, +) { + val controllerFocusEnabled = LocalControllerFocusEnabled.current + var focused by remember { mutableStateOf(false) } + val showFocusRing = focused && controllerFocusEnabled + val shape = RoundedCornerShape(OpenNowRadius.md) + Box( + Modifier + .size(width = width, height = SetupTileHeight) + .border( + width = when { + showFocusRing -> 3.dp + selected -> 2.dp + else -> 1.dp + }, + color = when { + showFocusRing -> Color.White + selected -> MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.28f) + }, + shape = shape, + ) + .clip(shape) + .onFocusChanged { focused = it.isFocused || it.hasFocus } + .clickable(onClick = onClick), + contentAlignment = Alignment.Center, + content = content, + ) +} + +@Composable +private fun SetupAccentSwatch(accent: UiAccent, selected: Boolean, onClick: () -> Unit) { + val controllerFocusEnabled = LocalControllerFocusEnabled.current + var focused by remember { mutableStateOf(false) } + val showFocusRing = focused && controllerFocusEnabled + val ringColor = when { + showFocusRing -> Color.White + selected -> MaterialTheme.colorScheme.onBackground + else -> Color.Transparent + } + Box( + Modifier + .size(SetupSwatchTarget) + .border(if (ringColor == Color.Transparent) 0.dp else 2.dp, ringColor, CircleShape) + .clip(CircleShape) + .onFocusChanged { focused = it.isFocused || it.hasFocus } + .clickable(onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Box(Modifier.size(SetupSwatchDot).clip(CircleShape).background(accent.color)) + } +} + +/** A settings-style row whose trailing slot is a radio button — one of several exclusive options. */ +@Composable +private fun SetupChoiceRow(label: String, value: String, selected: Boolean, onSelect: () -> Unit) { + val style = controlRowStyle() + ControlRow(onClick = onSelect) { + ControlRowLabels( + label = label, + value = value, + expandedDescription = null, + enabled = true, + style = style, + ) + RadioButton(selected = selected, onClick = onSelect) + } +} + +@Composable +private fun SetupSummaryRow(label: String, value: String) { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(OpenNowSpacing.sm), + verticalAlignment = Alignment.Top, + ) { + Text( + label, + Modifier.weight(1f), + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyMedium, + ) + Text( + value, + Modifier.weight(1f), + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + ) + } +} + +@Composable +private fun SetupPointsCard(title: String, points: List) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(OpenNowRadius.lg), + color = MaterialTheme.colorScheme.surfaceVariant, + ) { + Column( + Modifier.fillMaxWidth().padding(OpenNowSpacing.md), + verticalArrangement = Arrangement.spacedBy(OpenNowSpacing.sm), + ) { + Text( + title, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + points.forEach { point -> + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(OpenNowSpacing.sm), + verticalAlignment = Alignment.Top, + ) { + Box( + Modifier + .padding(top = 7.dp) + .size(5.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary), + ) + Text( + point, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + } +} + +private val SetupContentMaxWidth = 720.dp +private val SetupTileHeight = 68.dp +private val SetupTileMinWidth = 96.dp +private val SetupTileMaxWidth = 148.dp +private val SetupSwatchTarget = 40.dp +private val SetupSwatchDot = 28.dp + +/** + * Tiles per screen width. Fractional on purpose: 2.6 leaves most of a third tile showing, which is + * what tells the user the row scrolls. + */ +private const val SETUP_TILE_PEEK_COUNT = 2.6f +private val SetupPeekFadeWidth = 36.dp +private val SetupPeekFadeColor = Color.Black.copy(alpha = 0.55f) +private const val SETUP_PREVIEW_CARD_COUNT = 3 + +private val SetupStep.titleRes: Int + get() = when (this) { + SetupStep.Welcome -> R.string.app_name + SetupStep.Appearance -> R.string.setup_appearance_title + SetupStep.Streaming -> R.string.setup_streaming_title + SetupStep.Play -> R.string.setup_play_title + SetupStep.Feedback -> R.string.setup_feedback_title + SetupStep.Ready -> R.string.setup_ready_title + } + +private val SetupStep.subtitleRes: Int + get() = when (this) { + SetupStep.Welcome -> R.string.setup_welcome_tagline + SetupStep.Appearance -> R.string.setup_appearance_subtitle + SetupStep.Streaming -> R.string.setup_streaming_subtitle + SetupStep.Play -> R.string.setup_play_subtitle + SetupStep.Feedback -> R.string.setup_feedback_subtitle + SetupStep.Ready -> R.string.setup_ready_subtitle + } diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowStreamControls.kt b/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowStreamControls.kt new file mode 100644 index 000000000..9e27100ed --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowStreamControls.kt @@ -0,0 +1,2793 @@ +package com.opencloudgaming.opennow + +import android.content.res.Configuration +import android.provider.Settings +import androidx.annotation.StringRes +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.togetherWith +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith +import androidx.compose.animation.core.tween +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.interaction.MutableInteractionSource +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.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.KeyboardArrowDown +import androidx.compose.material.icons.rounded.KeyboardArrowUp +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.minimumInteractiveComponentSize +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.key +import androidx.compose.runtime.setValue +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.layout.layout +import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.window.DialogProperties +import kotlinx.coroutines.delay +import kotlin.math.min +import com.opencloudgaming.opennow.ui.controls.ControlActionRow +import com.opencloudgaming.opennow.ui.controls.ControlNavigationRow +import com.opencloudgaming.opennow.ui.controls.ControlRowStyle +import com.opencloudgaming.opennow.ui.controls.ControlSection +import com.opencloudgaming.opennow.ui.controls.ControlSectionStyle +import com.opencloudgaming.opennow.ui.controls.ControlSliderRow +import com.opencloudgaming.opennow.ui.controls.ControlSwitchRow +import com.opencloudgaming.opennow.ui.controls.LocalControlRowStyle +import com.opencloudgaming.opennow.ui.controls.LocalControlSectionStyle +import com.opencloudgaming.opennow.ui.theme.LocalReduceMotion +import com.opencloudgaming.opennow.ui.theme.OpenNowMotion +import com.opencloudgaming.opennow.ui.theme.OpenNowPalette +import com.opencloudgaming.opennow.ui.theme.OpenNowRadius +import com.opencloudgaming.opennow.ui.theme.OpenNowSpacing +import com.opencloudgaming.opennow.ui.theme.tint +import kotlin.math.roundToInt + +@Composable +internal fun ActiveSessionDecisionScreen( + state: OpenNowUiState, + onResumeSession: () -> Unit, + onReplaceSession: () -> Unit, + onCancel: () -> Unit, +) { + val decision = state.activeSessionDecision ?: return + val active = decision.activeSession + val activeGame = activeSessionGame(state, active) + Column( + Modifier + .fillMaxSize() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Surface( + modifier = Modifier.fillMaxWidth().widthIn(max = 560.dp), + shape = RoundedCornerShape(18.dp), + color = PanelAlt.copy(alpha = 0.96f), + contentColor = TextPrimary, + tonalElevation = 4.dp, + ) { + Column( + Modifier.padding(18.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) { + UrlImage( + activeGame?.imageUrl ?: state.streamGame?.imageUrl, + Modifier + .width(56.dp) + .height(74.dp) + .clip(RoundedCornerShape(10.dp)), + ) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(stringResource(R.string.stream_session_active_title), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Text( + activeGame?.title ?: "App ${active.appId}", + color = TextPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + activeSessionSummary(active), + color = TextMuted, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + Text( + "Resume the existing session, or terminate it and start ${decision.requestedGameTitle}.", + color = TextMuted, + style = MaterialTheme.typography.bodyMedium, + ) + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + TextButton(onClick = onCancel) { Text(stringResource(R.string.action_cancel)) } + OutlinedButton(onClick = onReplaceSession) { Text(stringResource(R.string.stream_session_terminate_start)) } + Button(onClick = onResumeSession) { Text(stringResource(R.string.action_resume)) } + } + } + } + } +} + +@Composable +internal fun NoActiveStreamScreen( + canResumeSession: Boolean, + canEndSession: Boolean, + onBack: () -> Unit, + onResumeSession: () -> Unit, + onEndSession: () -> Unit, +) { + Column( + Modifier + .fillMaxSize() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text(stringResource(R.string.stream_no_active), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) + Spacer(Modifier.height(8.dp)) + Text( + stringResource(R.string.stream_no_local_stream), + color = TextMuted, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(18.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + OutlinedButton(onClick = onBack) { Text(stringResource(R.string.stream_back_to_library)) } + if (canResumeSession) { + Button(onClick = onResumeSession) { Text(stringResource(R.string.action_resume)) } + } + if (canEndSession) { + Button(onClick = onEndSession) { Text(stringResource(R.string.stream_end_cloud_session)) } + } + } + } +} + +@Composable +private fun StreamControlLauncher( + controlsOpen: Boolean, + status: String?, + onToggle: () -> Unit, + onExit: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier + .padding(top = 10.dp, end = 10.dp) + .onGloballyPositioned { coordinates -> + val bounds = coordinates.boundsInRoot() + NativeStreamInputRouter.setUiTouchPassthroughBounds( + bounds.left.roundToInt(), + bounds.top.roundToInt(), + bounds.right.roundToInt(), + bounds.bottom.roundToInt(), + ) + }, + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (status != null) { + Surface( + shape = RoundedCornerShape(999.dp), + color = Panel.copy(alpha = 0.8f), + tonalElevation = 3.dp, + ) { + Text( + status, + color = TextMuted, + style = MaterialTheme.typography.labelMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp), + ) + } + } + Button(onClick = onToggle, contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp)) { + Text(if (controlsOpen) stringResource(R.string.action_close) else stringResource(R.string.stream_panel_title)) + } + OutlinedButton(onClick = onExit, contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp)) { + Text(stringResource(R.string.stream_panel_exit)) + } + } + + DisposableEffect(Unit) { + onDispose { + NativeStreamInputRouter.clearUiTouchPassthroughBounds() + } + } +} + +@Composable +internal fun StreamFirstLaunchGuide( + step: StreamGuideStep, + controlsOpen: Boolean, + touchControlsEnabled: Boolean, + onOpenControls: () -> Unit, + onSkip: () -> Unit, +) { + val primaryFocusRequester = remember { FocusRequester() } + val overlayInteraction = remember { MutableInteractionSource() } + LaunchedEffect(step, controlsOpen) { + delay(80) + if (step == StreamGuideStep.OpenControls || !controlsOpen) { + runCatching { primaryFocusRequester.requestFocus() } + } + } + BoxWithConstraints( + if (step == StreamGuideStep.OpenControls) { + Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.62f)) + .clickable( + interactionSource = overlayInteraction, + indication = null, + onClick = {}, + ) + } else { + Modifier.fillMaxSize() + }, + ) { + val landscape = maxWidth > maxHeight + if (step == StreamGuideStep.OpenControls) { + StreamGuideEdgeCue(Modifier.align(Alignment.CenterStart)) + StreamGuideCard( + stepLabel = "Step 1 of 2", + title = "Open the stream menu", + body = "Press Android Back, Menu, or swipe from the left edge. That opens the menu without exiting the stream.", + details = listOf( + "Back or the left-edge gesture opens controls.", + if (touchControlsEnabled) { + "Touch controls pause while this guide is up." + } else { + "You can turn touch controls on from the menu." + }, + "Use Skip tutorial if you already know this flow.", + ), + modifier = Modifier + .align(Alignment.Center) + .padding(18.dp) + .fillMaxWidth(if (landscape) 0.54f else 0.92f) + .then(if (landscape) Modifier.fillMaxHeight(0.82f) else Modifier), + primaryLabel = "Open controls", + primaryFocusRequester = primaryFocusRequester, + onPrimary = onOpenControls, + secondaryLabel = "Skip tutorial", + onSecondary = onSkip, + ) + } else { + StreamGuideDoneCallout( + controlsOpen = controlsOpen, + onOpenControls = onOpenControls, + onSkip = onSkip, + primaryFocusRequester = primaryFocusRequester, + modifier = Modifier + .align(if (landscape) Alignment.TopStart else Alignment.TopCenter) + .padding(18.dp) + .then(if (landscape) Modifier.fillMaxWidth(0.34f) else Modifier.fillMaxWidth(0.86f)) + .widthIn(max = 340.dp), + ) + } + } +} + +@Composable +private fun StreamGuideCard( + stepLabel: String, + title: String, + body: String, + details: List, + primaryLabel: String, + primaryFocusRequester: FocusRequester, + onPrimary: () -> Unit, + secondaryLabel: String, + onSecondary: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier, + shape = RoundedCornerShape(20.dp), + color = Panel.copy(alpha = 0.96f), + contentColor = TextPrimary, + tonalElevation = 8.dp, + ) { + Column( + Modifier + .padding(18.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(stepLabel, color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold) + Text(title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text(body, color = TextMuted, style = MaterialTheme.typography.bodyMedium) + } + details.forEachIndexed { index, detail -> + StreamGuidePoint(number = index + 1, body = detail) + } + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + OutlinedButton( + onClick = onSecondary, + modifier = Modifier.weight(1f), + ) { + Text(secondaryLabel, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + Button( + onClick = onPrimary, + modifier = Modifier + .weight(1f) + .focusRequester(primaryFocusRequester), + ) { + Text(primaryLabel, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } + } +} + +@Composable +private fun StreamGuideDoneCallout( + controlsOpen: Boolean, + onOpenControls: () -> Unit, + onSkip: () -> Unit, + primaryFocusRequester: FocusRequester, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier, + shape = RoundedCornerShape(18.dp), + color = Panel.copy(alpha = 0.9f), + tonalElevation = 6.dp, + ) { + Row( + Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) { + Text(stringResource(R.string.stream_guide_step, 2, 2), color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.stream_guide_press_done), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + TextButton( + onClick = onSkip, + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp), + ) { + Text(stringResource(R.string.action_skip), maxLines = 1) + } + if (!controlsOpen) { + Button( + onClick = onOpenControls, + modifier = Modifier.focusRequester(primaryFocusRequester), + contentPadding = PaddingValues(horizontal = 10.dp, vertical = 6.dp), + ) { + Text(stringResource(R.string.action_open), maxLines = 1) + } + } + } + } +} + +@Composable +internal fun PhysicalControllerTouchControlsDialog( + doNotShowAgain: Boolean, + onDoNotShowAgainChange: (Boolean) -> Unit, + onOk: () -> Unit, + onUndo: () -> Unit, +) { + AlertDialog( + onDismissRequest = onOk, + title = { Text(stringResource(R.string.stream_controller_detected)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + stringResource(R.string.stream_controller_hidden), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable { onDoNotShowAgainChange(!doNotShowAgain) } + .padding(horizontal = 8.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Checkbox( + checked = doNotShowAgain, + onCheckedChange = onDoNotShowAgainChange, + ) + Text(stringResource(R.string.common_dont_show_again), color = MaterialTheme.colorScheme.onSurface) + } + } + }, + confirmButton = { + TextButton(onClick = onOk) { + Text(stringResource(R.string.action_ok)) + } + }, + dismissButton = { + TextButton(onClick = onUndo) { + Text(stringResource(R.string.action_undo)) + } + }, + ) +} + +@Composable +internal fun StreamInputModeSwitchDialog( + prompt: StreamInputModePrompt, + onStay: () -> Unit, + onSwitch: () -> Unit, +) { + val title = when (prompt) { + StreamInputModePrompt.SwitchToKeyboardMouse -> R.string.stream_keyboard_mouse_detected + StreamInputModePrompt.SwitchToNativeTouch -> R.string.stream_keyboard_mouse_disconnected + } + val body = when (prompt) { + StreamInputModePrompt.SwitchToKeyboardMouse -> R.string.stream_keyboard_mouse_detected_body + StreamInputModePrompt.SwitchToNativeTouch -> R.string.stream_keyboard_mouse_disconnected_body + } + val switchLabel = when (prompt) { + StreamInputModePrompt.SwitchToKeyboardMouse -> R.string.stream_input_switch_keyboard_mouse + StreamInputModePrompt.SwitchToNativeTouch -> R.string.stream_input_switch_native_touch + } + val stayLabel = when (prompt) { + StreamInputModePrompt.SwitchToKeyboardMouse -> R.string.stream_input_stay_native_touch + StreamInputModePrompt.SwitchToNativeTouch -> R.string.stream_input_keep_keyboard_mouse + } + AlertDialog( + onDismissRequest = onStay, + title = { Text(stringResource(title)) }, + text = { + Text( + stringResource(body), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + confirmButton = { + TextButton(onClick = onSwitch) { + Text(stringResource(switchLabel)) + } + }, + dismissButton = { + TextButton(onClick = onStay) { + Text(stringResource(stayLabel)) + } + }, + ) +} + +@Composable +private fun StreamGuideEdgeCue(modifier: Modifier = Modifier) { + Box( + modifier + .fillMaxHeight() + .width(112.dp) + .background( + Brush.horizontalGradient( + listOf( + MaterialTheme.colorScheme.primary.copy(alpha = 0.28f), + Color.Transparent, + ), + ), + ), + ) { + Surface( + modifier = Modifier + .align(Alignment.CenterStart) + .padding(start = 14.dp), + shape = RoundedCornerShape(999.dp), + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.92f), + tonalElevation = 6.dp, + ) { + Row( + Modifier.padding(horizontal = 10.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + painter = painterResource(R.drawable.ic_arrow_back), + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.size(18.dp), + ) + Text(stringResource(R.string.action_back), color = MaterialTheme.colorScheme.onPrimary, style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold) + } + } + } +} + +@Composable +private fun StreamGuidePoint(number: Int, body: String) { + Row( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(Color.White.copy(alpha = 0.06f)) + .padding(horizontal = 12.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.Top, + ) { + Surface( + modifier = Modifier.size(22.dp), + shape = CircleShape, + color = MaterialTheme.colorScheme.primary, + ) { + Box(contentAlignment = Alignment.Center) { + Text(number.toString(), color = MaterialTheme.colorScheme.onPrimary, style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.Bold) + } + } + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(body, color = TextMuted, style = MaterialTheme.typography.bodySmall) + } + } +} + +private enum class StreamControlsPage { + Main, + StatusBar, + TouchControls, + MouseMode, + ReportProblem, +} + +@Composable +private fun ControlBitrateLiveHint( + liveBitrateMbps: Int, + liveOverridden: Boolean, +) { + Column( + Modifier + .fillMaxWidth() + .padding(start = 14.dp, end = 14.dp, top = 2.dp, bottom = 6.dp), + verticalArrangement = Arrangement.spacedBy(3.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Box( + Modifier + .clip(RoundedCornerShape(999.dp)) + .background(Green.copy(alpha = if (liveOverridden) 0.18f else 0.10f)) + .padding(horizontal = 7.dp, vertical = 2.dp), + ) { + Text( + stringResource(R.string.stream_panel_bitrate_live_badge), + color = if (liveOverridden) Green else TextMuted, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + ) + } + Text( + stringResource(R.string.stream_panel_bitrate_live_summary, liveBitrateMbps), + color = TextMuted, + style = MaterialTheme.typography.labelSmall, + ) + } + Text( + stringResource(R.string.stream_panel_bitrate_next_session_hint), + color = TextMuted.copy(alpha = 0.72f), + style = MaterialTheme.typography.labelSmall, + ) + } +} + +@Composable +internal fun StreamControlsPanel( + gameTitle: String, + status: String?, + settings: AppSettings, + tvProfile: Boolean, + touchControlsVisible: Boolean, + builtInGameTouchSupported: Boolean, + nativeTouchActive: Boolean, + gyroscopeAvailable: Boolean, + controllerMouseAssistEnabled: Boolean, + controllerMouseEmulationEnabled: Boolean, + showSessionTimer: Boolean, + sessionTimerLimit: SmartSessionLimit, + sessionStartedAtMs: Long, + sessionNowMs: Long, + audioMuted: Boolean, + microphoneRequested: Boolean, + microphonePermissionGranted: Boolean, + microphoneEnabled: Boolean, + statsVisible: Boolean, + liveBitrateLimitKbps: Int?, + touchLayoutEditing: Boolean, + bugReportSubmission: BugReportSubmissionState, + bugReportVersionCheck: AndroidBugReportVersionCheckState, + update: AndroidUpdateState, + bugReportPreflightProvider: () -> BugReportPreflightDeck, + onAudioToggle: () -> Unit, + onMicrophoneToggle: () -> Unit, + onStatsToggle: () -> Unit, + onStatsStyleCycle: () -> Unit, + onStatsPositionCycle: () -> Unit, + onStatsMetricsChange: (StreamStatsMetrics) -> Unit, + onKeyboardButtonToggle: () -> Unit, + onVibrationToggle: () -> Unit, + onTouchLayoutEditingToggle: () -> Unit, + onKeyboardOpen: () -> Unit, + onEsc: () -> Unit, + onEnter: () -> Unit, + onBackspace: () -> Unit, + onSteamMenuOpen: () -> Unit, + onControllerMouseAssistToggle: () -> Unit, + onControllerMouseEmulationToggle: () -> Unit, + onExit: () -> Unit, + onTouchControlsToggle: () -> Unit, + onMousePadToggle: () -> Unit, + onMouseDirectClickToggle: () -> Unit, + onToggleTouchControllerStyle: () -> Unit, + onTouchButtonLabelsToggle: () -> Unit, + onJoystickModeToggle: () -> Unit, + onTouchAimModeToggle: () -> Unit, + onJoystickDeadZoneChange: (Float) -> Unit, + onSharpeningToggle: () -> Unit, + onSharpeningAmountChange: (Float) -> Unit, + onMaxBitrateChange: (Int) -> Unit, + onStretchToFitToggle: () -> Unit, + onTouchScaleChange: (Float) -> Unit, + onButtonScaleChange: (Float) -> Unit, + onStickScaleChange: (Float) -> Unit, + onOpacityChange: (Float) -> Unit, + onMouseSensitivityChange: (Float) -> Unit, + onMouseScrollSensitivityChange: (Int) -> Unit, + onNativeTouchScrollScaleChange: (Float) -> Unit, + onNativeTouchJitterThresholdChange: (Float) -> Unit, + onTouchEdgePaddingChange: (Float) -> Unit, + onTouchBottomPaddingChange: (Float) -> Unit, + onTouchLeftOffsetChange: (Float) -> Unit, + onTouchRightOffsetChange: (Float) -> Unit, + onTouchLayoutReset: () -> Unit, + onTouchSettingsChange: (AndroidTouchSettings) -> Unit, + onBugReportSubmit: (String, String, String?) -> Unit, + onBugReportReset: () -> Unit, + onBugReportVersionCheck: () -> Unit, + onOpenUpdate: () -> Unit, + onButtonTone: () -> Unit, + highlightDone: Boolean = false, + onClose: () -> Unit, +) { + val doneFocusRequester = remember { FocusRequester() } + val focusManager = LocalFocusManager.current + var page by remember { mutableStateOf(StreamControlsPage.Main) } + val reduceMotion = LocalReduceMotion.current + BackHandler(enabled = page != StreamControlsPage.Main) { + page = StreamControlsPage.Main + } + LaunchedEffect(page) { + delay(120) + runCatching { doneFocusRequester.requestFocus() } + } + Surface( + modifier = Modifier + .padding(14.dp) + .fillMaxWidth(0.94f) + .fillMaxHeight(0.72f) + .streamTouchPassthrough(PASSTHROUGH_ID_PANEL), + shape = RoundedCornerShape(OpenNowRadius.lg + 2.dp), + // Firmer than the old 0.93: at that alpha TextMuted did not reliably clear 4.5:1 over + // bright gameplay. The hairline keeps the panel's edge visible against a light frame. + color = OpenNowPalette.PanelOverVideo, + contentColor = TextPrimary, + border = BorderStroke(1.dp, OpenNowPalette.PanelHairline), + tonalElevation = 6.dp, + ) { + // Every control row inside the panel picks up the denser, over-video styling — and, more + // importantly, becomes properly focusable. The panel's own row widgets never were. + CompositionLocalProvider( + LocalControlRowStyle provides ControlRowStyle.stream(), + LocalControlSectionStyle provides ControlSectionStyle.stream(), + ) { + Column(Modifier.fillMaxSize()) { + // The header stays outside the scrolling area so every focused sub-page keeps navigation + // and session actions visible while its settings scroll independently. + StreamPanelHeader( + page = page, + gameTitle = gameTitle, + status = status, + highlightDone = highlightDone, + focusRequester = doneFocusRequester, + onBack = { page = StreamControlsPage.Main }, + onKeyboardOpen = onKeyboardOpen, + onExit = onExit, + onClose = onClose, + onButtonTone = onButtonTone, + ) + AnimatedContent( + targetState = page, + transitionSpec = { streamPanelPageTransition(initialState, targetState, reduceMotion) }, + label = "stream-controls-page", + ) { currentPage -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .onPreviewKeyEvent { handleVerticalDpadFocusMove(it, focusManager) }, + contentPadding = PaddingValues(OpenNowSpacing.md + 2.dp), + verticalArrangement = Arrangement.spacedBy(OpenNowSpacing.md), + ) { + when (currentPage) { + StreamControlsPage.StatusBar -> statusBarPageItems( + settings = settings, + statsVisible = statsVisible, + onStatsToggle = onStatsToggle, + onStatsStyleCycle = onStatsStyleCycle, + onStatsPositionCycle = onStatsPositionCycle, + onStatsMetricsChange = onStatsMetricsChange, + onKeyboardButtonToggle = onKeyboardButtonToggle, + onButtonTone = onButtonTone, + ) + StreamControlsPage.TouchControls -> { + if (builtInGameTouchSupported) { + item { + BuiltInGameTouchNotice(usingBuiltInTouch = nativeTouchActive) + } + } + item { + ControlSection(stringResource(R.string.stream_panel_section_touch_controller)) { + ControlSwitchRow( + label = stringResource(R.string.stream_panel_touch_controller), + checked = touchControlsVisible, + onCheckedChange = { + onButtonTone() + onTouchControlsToggle() + }, + value = when { + touchControlsVisible -> stringResource(R.string.common_visible) + nativeTouchActive -> stringResource(R.string.stream_touch_builtin_active) + else -> stringResource(R.string.common_hidden) + }, + ) + if (touchControlsVisible) { + // Cycles rather than opens a menu: this row is used mid-session, + // often one-handed, and each press shows its result immediately + // behind the panel. + ControlActionRow( + label = stringResource(R.string.stream_panel_touch_skin), + actionLabel = stringResource(R.string.common_next), + value = touchControllerStyleLabel(settings.androidTouch.touchControllerStyle), + onClick = { + onButtonTone() + onToggleTouchControllerStyle() + }, + ) + ControlActionRow( + label = stringResource(R.string.settings_touch_skin_tint), + actionLabel = stringResource(R.string.common_next), + value = touchSkinTintLabel(settings.androidTouch.touchSkinTint), + onClick = { + onButtonTone() + onTouchSettingsChange( + settings.androidTouch.copy( + touchSkinTint = nextTouchSkinTint(settings.androidTouch.touchSkinTint), + ), + ) + }, + ) + ControlSwitchRow( + label = stringResource(R.string.settings_touch_button_labels), + checked = settings.androidTouch.touchButtonLabels, + onCheckedChange = { + onButtonTone() + onTouchButtonLabelsToggle() + }, + value = onOffLabel(settings.androidTouch.touchButtonLabels), + ) + } + ControlSwitchRow( + label = stringResource(R.string.stream_panel_vibration), + checked = settings.vibrationEnabled, + onCheckedChange = { + onButtonTone() + onVibrationToggle() + }, + value = onOffLabel(settings.vibrationEnabled), + description = stringResource(R.string.stream_panel_vibration_summary), + ) + } + } + item { + ControlSection(stringResource(R.string.stream_joysticks_title)) { + val dynamic = settings.androidTouch.joystickMode == TouchJoystickMode.Dynamic + val lockZone = settings.androidTouch.aimMode == TouchAimMode.LockZone + ControlSwitchRow( + label = stringResource(R.string.stream_joysticks_aim_mode), + checked = lockZone, + onCheckedChange = { + onButtonTone() + onTouchAimModeToggle() + }, + value = stringResource( + if (lockZone) R.string.stream_joysticks_lock_zone else R.string.stream_joysticks_lock_joystick, + ), + ) + ControlSwitchRow( + label = stringResource(R.string.stream_joysticks_dynamic), + checked = dynamic, + onCheckedChange = { + onButtonTone() + onJoystickModeToggle() + }, + value = stringResource( + if (dynamic) R.string.stream_joysticks_dynamic_on else R.string.stream_joysticks_dynamic_off, + ), + ) + if (lockZone) { + TouchLayoutSlider( + R.string.stream_joysticks_aim_zone_scale, + settings.androidTouch.aimZoneScale, + 0.5f, + 1.5f, + TOUCH_SCALE_SLIDER_STEP, + onChange = { value -> + onTouchSettingsChange(settings.androidTouch.copy(aimZoneScale = value)) + }, + ) + TouchLayoutSlider( + R.string.stream_joysticks_aim_zone_sensitivity, + settings.androidTouch.aimZoneSensitivity, + 0.25f, + 3f, + TOUCH_SCALE_SLIDER_STEP, + onChange = { value -> + onTouchSettingsChange(settings.androidTouch.copy(aimZoneSensitivity = value)) + }, + ) + } + TouchLayoutSlider( + R.string.stream_joysticks_stick_size, + settings.androidTouch.stickScale, + 0.65f, + 1.5f, + TOUCH_SCALE_SLIDER_STEP, + onStickScaleChange, + ) + TouchLayoutSlider( + R.string.stream_joysticks_dead_zone, + settings.androidTouch.joystickDeadZone, + 0f, + 0.3f, + JOYSTICK_DEAD_ZONE_STEP, + onJoystickDeadZoneChange, + ) + Text( + stringResource( + if (lockZone) { + R.string.stream_joysticks_lock_zone_summary + } else { + R.string.stream_joysticks_explainer + }, + ), + color = TextMuted, + style = MaterialTheme.typography.bodySmall, + ) + } + } + item { + ControlSection(stringResource(R.string.settings_touch_visible_controls)) { + Text( + text = stringResource(R.string.settings_touch_visible_controls_desc), + color = TextMuted, + style = MaterialTheme.typography.bodySmall, + ) + TouchControlGroup.entries.forEach { group -> + val visible = settings.androidTouch.isControlVisible(group) + ControlSwitchRow( + label = stringResource(touchControlGroupLabelRes(group)), + checked = visible, + onCheckedChange = { enabled -> + onButtonTone() + onTouchSettingsChange( + settings.androidTouch.withControlVisible(group, enabled), + ) + }, + value = onOffLabel(visible), + ) + } + } + } + item { + ControlSection(stringResource(R.string.settings_touch_extra_buttons)) { + Text( + text = stringResource(R.string.settings_touch_extra_buttons_desc), + color = TextMuted, + style = MaterialTheme.typography.bodySmall, + ) + repeat(TOUCH_EXTRA_BUTTON_COUNT) { index -> + val action = settings.androidTouch.extraButtonAction(index) + ControlActionRow( + label = stringResource(R.string.settings_touch_extra_button, index + 1), + actionLabel = stringResource(R.string.common_next), + value = touchExtraButtonActionLabel(action), + onClick = { + onButtonTone() + onTouchSettingsChange( + settings.androidTouch.withExtraButtonAction( + index, + nextTouchExtraButtonAction(action), + ), + ) + }, + ) + } + TouchLayoutSlider( + R.string.settings_touch_extra_button_size, + settings.androidTouch.extraButtonScale, + 0.6f, + 1.6f, + TOUCH_SCALE_SLIDER_STEP, + onChange = { value -> + onTouchSettingsChange(settings.androidTouch.copy(extraButtonScale = value)) + }, + ) + } + } + item { + ControlSection(stringResource(R.string.stream_panel_section_touch_layout)) { + ControlSwitchRow( + label = stringResource(R.string.stream_panel_drag_edit), + checked = touchLayoutEditing, + onCheckedChange = { + onButtonTone() + onTouchLayoutEditingToggle() + }, + value = onOffLabel(touchLayoutEditing), + ) + ControlActionRow( + label = stringResource(R.string.stream_panel_reset_layout), + actionLabel = stringResource(R.string.action_reset), + onClick = { + onButtonTone() + onTouchLayoutReset() + }, + value = stringResource(R.string.stream_panel_reset_layout_summary), + ) + // These controls preview live so the player can position the overlay + // against the game without leaving the stream. + TouchLayoutSlider(R.string.stream_panel_layout_scale, settings.androidTouch.scale, 0.6f, 1.4f, TOUCH_SCALE_SLIDER_STEP, onTouchScaleChange) + TouchLayoutSlider(R.string.stream_panel_button_size, settings.androidTouch.buttonScale, 0.65f, 1.5f, TOUCH_SCALE_SLIDER_STEP, onButtonScaleChange) + TouchLayoutSlider(R.string.settings_touch_face_size, settings.androidTouch.faceButtonScale, 0.6f, 1.5f, TOUCH_SCALE_SLIDER_STEP, onChange = { value -> + onTouchSettingsChange(settings.androidTouch.copy(faceButtonScale = value)) + }) + TouchLayoutSlider(R.string.settings_touch_dpad_size, settings.androidTouch.dpadScale, 0.6f, 1.5f, TOUCH_SCALE_SLIDER_STEP, onChange = { value -> + onTouchSettingsChange(settings.androidTouch.copy(dpadScale = value)) + }) + TouchLayoutSlider(R.string.settings_touch_shoulders_size, settings.androidTouch.shoulderButtonScale, 0.6f, 1.5f, TOUCH_SCALE_SLIDER_STEP, onChange = { value -> + onTouchSettingsChange(settings.androidTouch.copy(shoulderButtonScale = value)) + }) + TouchLayoutSlider(R.string.settings_touch_center_size, settings.androidTouch.centerButtonScale, 0.6f, 1.5f, TOUCH_SCALE_SLIDER_STEP, onChange = { value -> + onTouchSettingsChange(settings.androidTouch.copy(centerButtonScale = value)) + }) + TouchLayoutSlider(R.string.settings_touch_left_stick_size, settings.androidTouch.leftStickScale, 0.6f, 1.5f, TOUCH_SCALE_SLIDER_STEP, onChange = { value -> + onTouchSettingsChange(settings.androidTouch.copy(leftStickScale = value)) + }) + TouchLayoutSlider(R.string.settings_touch_right_stick_size, settings.androidTouch.rightStickScale, 0.6f, 1.5f, TOUCH_SCALE_SLIDER_STEP, onChange = { value -> + onTouchSettingsChange(settings.androidTouch.copy(rightStickScale = value)) + }) + TouchLayoutSlider(R.string.settings_touch_stick_knob_size, settings.androidTouch.stickKnobScale, 0.28f, 0.72f, 0.02f, onChange = { value -> + onTouchSettingsChange(settings.androidTouch.copy(stickKnobScale = value)) + }) + TouchLayoutSlider(R.string.stream_panel_opacity, settings.androidTouch.opacity, 0f, 1f, TOUCH_SCALE_SLIDER_STEP, onOpacityChange) + TouchLayoutSlider(R.string.stream_panel_edge_padding, settings.androidTouch.edgePaddingDp, 0f, 72f, TOUCH_DP_SLIDER_STEP, onTouchEdgePaddingChange, unit = DP_UNIT) + TouchLayoutSlider(R.string.stream_panel_bottom_padding, settings.androidTouch.bottomPaddingDp, 0f, 120f, TOUCH_DP_SLIDER_STEP, onTouchBottomPaddingChange, unit = DP_UNIT) + TouchLayoutSlider(R.string.stream_panel_left_position, settings.androidTouch.leftOffsetYDp, -160f, 160f, TOUCH_DP_SLIDER_STEP, onTouchLeftOffsetChange, unit = DP_UNIT) + TouchLayoutSlider(R.string.stream_panel_right_position, settings.androidTouch.rightOffsetYDp, -160f, 160f, TOUCH_DP_SLIDER_STEP, onTouchRightOffsetChange, unit = DP_UNIT) + } + } + item { + ControlSection(stringResource(R.string.stream_panel_section_motion_aiming)) { + ControlSwitchRow( + label = stringResource(R.string.settings_touch_gyro), + checked = settings.androidTouch.gyroscopeEnabled && gyroscopeAvailable, + enabled = gyroscopeAvailable, + onCheckedChange = { enabled -> + onButtonTone() + onTouchSettingsChange(settings.androidTouch.copy(gyroscopeEnabled = enabled)) + }, + value = if (gyroscopeAvailable) { + onOffLabel(settings.androidTouch.gyroscopeEnabled) + } else { + stringResource(R.string.common_unavailable) + }, + description = stringResource( + if (gyroscopeAvailable) R.string.settings_touch_gyro_desc + else R.string.settings_touch_gyro_unavailable, + ), + ) + if (settings.androidTouch.gyroscopeEnabled && gyroscopeAvailable) { + ControlSliderRow( + label = stringResource(R.string.settings_touch_gyro_sensitivity), + value = settings.androidTouch.gyroscopeSensitivity, + min = 0.25f, + max = 3f, + step = 0.05f, + onChange = { value -> + onTouchSettingsChange(settings.androidTouch.copy(gyroscopeSensitivity = value)) + }, + onChangePreview = { value -> + onTouchSettingsChange(settings.androidTouch.copy(gyroscopeSensitivity = value)) + }, + ) + ControlSliderRow( + label = stringResource(R.string.settings_touch_gyro_smoothing), + value = settings.androidTouch.gyroscopeSmoothing, + min = 0f, + max = 0.9f, + step = 0.05f, + onChange = { value -> + onTouchSettingsChange(settings.androidTouch.copy(gyroscopeSmoothing = value)) + }, + ) + ControlSliderRow( + label = stringResource(R.string.settings_touch_gyro_dead_zone), + value = settings.androidTouch.gyroscopeDeadZone, + min = 0f, + max = 0.2f, + step = 0.005f, + onChange = { value -> + onTouchSettingsChange(settings.androidTouch.copy(gyroscopeDeadZone = value)) + }, + ) + ControlSwitchRow( + label = stringResource(R.string.settings_touch_gyro_invert_horizontal), + checked = settings.androidTouch.gyroscopeInvertHorizontal, + onCheckedChange = { enabled -> + onTouchSettingsChange(settings.androidTouch.copy(gyroscopeInvertHorizontal = enabled)) + }, + value = onOffLabel(settings.androidTouch.gyroscopeInvertHorizontal), + ) + ControlSwitchRow( + label = stringResource(R.string.settings_touch_gyro_invert_vertical), + checked = settings.androidTouch.gyroscopeInvertVertical, + onCheckedChange = { enabled -> + onTouchSettingsChange(settings.androidTouch.copy(gyroscopeInvertVertical = enabled)) + }, + value = onOffLabel(settings.androidTouch.gyroscopeInvertVertical), + ) + } + } + } + } + StreamControlsPage.MouseMode -> mouseModePageItems( + settings = settings, + controllerMouseEmulationEnabled = controllerMouseEmulationEnabled, + onControllerMouseEmulationToggle = onControllerMouseEmulationToggle, + onMouseSensitivityChange = onMouseSensitivityChange, + onMouseScrollSensitivityChange = onMouseScrollSensitivityChange, + onNativeTouchScrollScaleChange = onNativeTouchScrollScaleChange, + onNativeTouchJitterThresholdChange = onNativeTouchJitterThresholdChange, + onButtonTone = onButtonTone, + ) + StreamControlsPage.ReportProblem -> { + item { + StreamBugReporter( + submission = bugReportSubmission, + versionCheck = bugReportVersionCheck, + update = update, + onSubmit = onBugReportSubmit, + onReset = onBugReportReset, + onVersionCheck = onBugReportVersionCheck, + onOpenUpdate = onOpenUpdate, + onButtonTone = onButtonTone, + preflightProvider = bugReportPreflightProvider, + initiallyExpanded = true, + onExpandedClose = { page = StreamControlsPage.Main }, + ) + } + } + StreamControlsPage.Main -> { + if (showSessionTimer) { + item { + StreamSessionTimerMenuRow( + limit = sessionTimerLimit, + startedAtMs = sessionStartedAtMs, + nowMs = sessionNowMs, + ) + } + } + item { + ControlSection(stringResource(R.string.stream_panel_section_display)) { + ControlSwitchRow( + label = stringResource(R.string.stream_panel_stretch_to_fit), + checked = settings.stretchStreamToFit, + onCheckedChange = { + onButtonTone() + onStretchToFitToggle() + }, + value = onOffLabel(settings.stretchStreamToFit), + ) + ControlSwitchRow( + label = stringResource(R.string.stream_panel_audio), + checked = !audioMuted, + onCheckedChange = { + onButtonTone() + onAudioToggle() + }, + value = if (audioMuted) stringResource(R.string.stream_panel_audio_muted) else onOffLabel(true), + ) + ControlNavigationRow( + label = stringResource(R.string.stream_panel_status_bar), + onClick = { + onButtonTone() + page = StreamControlsPage.StatusBar + }, + value = if (!statsVisible) { + onOffLabel(false) + } else { + stringResource( + R.string.stream_panel_status_bar_summary, + settings.streamStatsStyle.label, + settings.streamStatsMetrics.enabledCount(), + ) + }, + ) + ControlSwitchRow( + label = stringResource(R.string.stream_panel_sharpening), + checked = settings.stream.streamSharpeningEnabled, + onCheckedChange = { + onButtonTone() + onSharpeningToggle() + }, + value = onOffLabel(settings.stream.streamSharpeningEnabled), + ) + if (settings.stream.streamSharpeningEnabled) { + ControlSliderRow( + label = stringResource(R.string.stream_panel_sharpening_amount), + value = settings.stream.streamSharpeningAmount, + min = 0f, + max = 1f, + step = SHARPENING_SLIDER_STEP, + onChange = onSharpeningAmountChange, + ) + } + ControlSliderRow( + label = stringResource(R.string.settings_bitrate), + value = settings.stream.maxBitrateMbps.toFloat(), + min = 1f, + max = 150f, + step = 1f, + unit = "Mbps", + descriptionProvider = { mbps -> streamBitrateUsageEstimate(mbps) }, + onChange = { value -> onMaxBitrateChange(value.roundToInt()) }, + ) + ControlBitrateLiveHint( + liveBitrateMbps = liveBitrateLimitKbps?.div(1000) ?: settings.stream.maxBitrateMbps, + liveOverridden = liveBitrateLimitKbps != null, + ) + } + } + item { + ControlSection(stringResource(R.string.stream_panel_section_input)) { + if (microphoneRequested) { + ControlSwitchRow( + label = stringResource(R.string.stream_panel_microphone), + checked = microphoneEnabled && microphonePermissionGranted, + onCheckedChange = { + onButtonTone() + onMicrophoneToggle() + }, + value = when { + !microphonePermissionGranted -> stringResource(R.string.stream_panel_microphone_permission) + microphoneEnabled -> onOffLabel(true) + else -> stringResource(R.string.stream_panel_audio_muted) + }, + ) + } + ControlActionRow( + label = stringResource(R.string.stream_panel_steam_menu), + actionLabel = stringResource(R.string.action_open), + onClick = { + onButtonTone() + onSteamMenuOpen() + }, + value = stringResource(R.string.stream_panel_steam_menu_summary), + ) + Row( + horizontalArrangement = Arrangement.spacedBy(OpenNowSpacing.sm), + modifier = Modifier.fillMaxWidth(), + ) { + StreamPanelKeyButton(stringResource(R.string.stream_panel_key_esc), Modifier.weight(1f)) { + onButtonTone() + onEsc() + } + StreamPanelKeyButton(stringResource(R.string.stream_panel_key_enter), Modifier.weight(1f)) { + onButtonTone() + onEnter() + } + StreamPanelKeyButton(stringResource(R.string.stream_panel_key_backspace), Modifier.weight(1f)) { + onButtonTone() + onBackspace() + } + } + if (tvProfile) { + ControlSwitchRow( + label = stringResource(R.string.stream_panel_controller_mouse), + checked = controllerMouseAssistEnabled, + onCheckedChange = { + onButtonTone() + onControllerMouseAssistToggle() + }, + value = if (controllerMouseAssistEnabled) { + stringResource(R.string.stream_panel_controller_mouse_summary) + } else { + onOffLabel(false) + }, + ) + } else { + ControlSwitchRow( + label = stringResource(R.string.stream_panel_finger_mouse), + checked = settings.androidTouch.mousePad, + onCheckedChange = { + onButtonTone() + onMousePadToggle() + }, + value = onOffLabel(settings.androidTouch.mousePad), + ) + if (settings.androidTouch.mousePad) { + ControlSwitchRow( + label = stringResource(R.string.stream_panel_direct_click), + checked = settings.androidTouch.mouseDirectClick, + onCheckedChange = { + onButtonTone() + onMouseDirectClickToggle() + }, + value = onOffLabel(settings.androidTouch.mouseDirectClick), + // Reads as a child of Finger mouse; replaces a hand-written Box. + indentLevel = 1, + ) + + val scrollHint = when { + settings.stream.mouseScrollSensitivity <= 20 -> "Fast" + settings.stream.mouseScrollSensitivity <= 40 -> "Normal" + settings.stream.mouseScrollSensitivity <= 60 -> "Precise" + else -> "Slow" + } + + ControlActionRow( + label = "Scroll sensitivity", + actionLabel = scrollHint, + onClick = { + onButtonTone() + val next = when { + settings.stream.mouseScrollSensitivity <= 20 -> 40 + settings.stream.mouseScrollSensitivity <= 40 -> 60 + settings.stream.mouseScrollSensitivity <= 60 -> 80 + else -> 20 + } + onMouseScrollSensitivityChange(next) + }, + indentLevel = 1 + ) + } + ControlNavigationRow( + label = stringResource(R.string.stream_panel_touch_controller), + onClick = { + onButtonTone() + page = StreamControlsPage.TouchControls + }, + value = when { + touchControlsVisible -> stringResource(R.string.common_visible) + nativeTouchActive -> stringResource(R.string.stream_touch_builtin_active) + else -> stringResource(R.string.common_hidden) + }, + ) + } + // Mouse mode (Left stick): shown for all profiles — works with both physical + // gamepad and touch controller. + ControlNavigationRow( + label = stringResource(R.string.stream_panel_mouse_mode), + onClick = { + onButtonTone() + page = StreamControlsPage.MouseMode + }, + value = if (controllerMouseEmulationEnabled) { + stringResource(R.string.stream_panel_mouse_mode_summary) + } else { + onOffLabel(false) + }, + ) + } + } + item { + ControlSection(stringResource(R.string.stream_panel_section_support)) { + ControlNavigationRow( + label = stringResource(R.string.bug_report_open_label), + onClick = { + onButtonTone() + page = StreamControlsPage.ReportProblem + }, + value = stringResource(R.string.bug_report_open_summary), + ) + } + } + } // StreamControlsPage.Main + } // when (currentPage) + } // LazyColumn + } // AnimatedContent + } // Column + } // CompositionLocalProvider + } // Surface + DisposableEffect(Unit) { + onDispose { + NativeStreamInputRouter.clearStreamPanelTouchPassthroughBounds() + } + } +} + +@Composable +private fun BuiltInGameTouchNotice(usingBuiltInTouch: Boolean) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + color = OpenNowPalette.StatusNotice.copy(alpha = 0.10f), + contentColor = TextPrimary, + border = BorderStroke(1.dp, OpenNowPalette.StatusNotice.copy(alpha = 0.38f)), + ) { + Column( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 11.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + stringResource(R.string.stream_touch_builtin_title), + color = OpenNowPalette.StatusNotice, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + ) + Text( + stringResource( + if (usingBuiltInTouch) { + R.string.stream_touch_builtin_available + } else { + R.string.stream_touch_builtin_overridden + }, + ), + color = TextMuted, + style = MaterialTheme.typography.bodySmall, + ) + } + } +} + +@Composable +internal fun BugReportDataDisclosure( + includeTypedTextWarning: Boolean, + modifier: Modifier = Modifier, +) { + var expanded by rememberSaveable { mutableStateOf(false) } + Surface( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + color = OpenNowPalette.StatusNotice.copy(alpha = 0.10f), + contentColor = TextPrimary, + border = BorderStroke(1.dp, OpenNowPalette.StatusNotice.copy(alpha = 0.38f)), + ) { + Column( + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .minimumInteractiveComponentSize() + .clip(RoundedCornerShape(14.dp)) + .clickable { expanded = !expanded } + .padding(horizontal = 12.dp, vertical = 11.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + "PrintedWaste API", + modifier = Modifier.weight(1f), + color = OpenNowPalette.StatusNotice, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.labelLarge, + ) + Text( + stringResource(R.string.bug_report_collected_title), + color = TextPrimary, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + ) + Icon( + imageVector = if (expanded) Icons.Rounded.KeyboardArrowUp else Icons.Rounded.KeyboardArrowDown, + contentDescription = if (expanded) "Collapse collection details" else "Expand collection details", + tint = TextMuted, + ) + } + AnimatedVisibility(visible = expanded) { + Column( + modifier = Modifier.padding(start = 12.dp, end = 12.dp, bottom = 12.dp), + verticalArrangement = Arrangement.spacedBy(7.dp), + ) { + Text( + stringResource(R.string.bug_report_collected_viewers), + color = TextMuted, + style = MaterialTheme.typography.bodySmall, + ) + Text( + stringResource(R.string.bug_report_collected_redaction), + color = TextPrimary, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.SemiBold, + ) + if (includeTypedTextWarning) { + Text( + stringResource(R.string.bug_report_collected_verbatim), + color = TextPrimary, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.SemiBold, + ) + } + Text( + stringResource(R.string.bug_report_collected_not_sold), + color = TextPrimary, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Bold, + ) + Text( + stringResource(R.string.bug_report_collected_log), + color = TextMuted, + style = MaterialTheme.typography.labelSmall, + ) + } + } + } + } +} + +/** + * Shared header for the main panel and every focused settings/support page. It stays put while the + * selected page scrolls. + */ +/** + * Publishes this composable's screen bounds to the native input router so touches landing on it are + * treated as UI rather than forwarded into the game. + * + * Two guards the hand-written version did not have: + * - a zero-size measurement is ignored, instead of publishing a degenerate rect; + * - the rect is inflated slightly, because boundsInRoot() includes graphicsLayer transforms and + * the panel enters under scaleIn(0.96f) — mid-animation it would otherwise under-report and + * leak touches around its edge. + * + * The caller must keep this on a node whose size does not depend on its content. A content-driven + * height would shrink the rect during a transition and leak touches into the game. + */ +@Composable +internal fun Modifier.streamTouchPassthrough(id: String, inflate: Dp = 8.dp): Modifier { + val inflatePx = with(LocalDensity.current) { inflate.roundToPx() } + DisposableEffect(id) { + onDispose { NativeStreamInputRouter.clearOverlayTouchPassthroughBound(id) } + } + return onGloballyPositioned { coordinates -> + val bounds = coordinates.boundsInRoot() + if (bounds.width <= 0f || bounds.height <= 0f) return@onGloballyPositioned + NativeStreamInputRouter.setOverlayTouchPassthroughBound( + id, + bounds.left.roundToInt() - inflatePx, + bounds.top.roundToInt() - inflatePx, + bounds.right.roundToInt() + inflatePx, + bounds.bottom.roundToInt() + inflatePx, + ) + } +} + +private const val PASSTHROUGH_ID_PANEL = "controls-panel" +internal const val PASSTHROUGH_ID_KEYBOARD = "keyboard-bar" +internal const val PASSTHROUGH_ID_STATUS_BAR_KEYBOARD = "status-bar-keyboard" +internal const val PASSTHROUGH_ID_EXIT = "exit-confirmation" + +@Composable +private fun StreamPanelHeader( + page: StreamControlsPage, + gameTitle: String, + status: String?, + highlightDone: Boolean, + focusRequester: FocusRequester, + onBack: () -> Unit, + onKeyboardOpen: () -> Unit, + onExit: () -> Unit, + onClose: () -> Unit, + onButtonTone: () -> Unit, +) { + val onMain = page == StreamControlsPage.Main + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + start = OpenNowSpacing.md + 2.dp, + end = OpenNowSpacing.md + 2.dp, + top = OpenNowSpacing.md + 2.dp, + bottom = OpenNowSpacing.sm, + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(OpenNowSpacing.sm), + ) { + if (!onMain) { + StreamPanelHeaderButton( + onClick = { + onButtonTone() + onBack() + }, + modifier = Modifier.focusRequester(focusRequester), + ) { + Icon( + painter = painterResource(R.drawable.ic_arrow_back), + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(6.dp)) + Text(stringResource(R.string.action_back), maxLines = 1) + } + } + Column(Modifier.weight(1f)) { + Text( + stringResource( + when (page) { + StreamControlsPage.Main -> R.string.stream_panel_title + StreamControlsPage.StatusBar -> R.string.stream_statusbar_title + StreamControlsPage.TouchControls -> R.string.stream_touch_controls_title + StreamControlsPage.MouseMode -> R.string.stream_mouse_mode_title + StreamControlsPage.ReportProblem -> R.string.stream_report_problem_title + }, + ), + style = MaterialTheme.typography.titleMedium, + ) + Text( + when (page) { + StreamControlsPage.Main -> gameTitle + StreamControlsPage.StatusBar -> stringResource(R.string.stream_statusbar_subtitle) + StreamControlsPage.TouchControls -> stringResource(R.string.stream_touch_controls_subtitle) + StreamControlsPage.MouseMode -> stringResource(R.string.stream_mouse_mode_subtitle) + StreamControlsPage.ReportProblem -> stringResource(R.string.stream_report_problem_subtitle) + }, + color = TextMuted, + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + if (onMain) { + if (status != null) { + Text(status, color = TextMuted, style = MaterialTheme.typography.labelMedium, maxLines = 1) + } + StreamPanelHeaderButton( + onClick = { + onButtonTone() + onKeyboardOpen() + }, + ) { + Icon( + painter = painterResource(R.drawable.ic_keyboard), + contentDescription = stringResource(R.string.stream_panel_cd_keyboard), + tint = TextPrimary, + modifier = Modifier.size(20.dp), + ) + } + StreamPanelHeaderButton( + onClick = { + onButtonTone() + onExit() + }, + ) { + Text(stringResource(R.string.stream_panel_exit), maxLines = 1) + } + val doneAction = { + onButtonTone() + onClose() + } + if (highlightDone) { + var doneFocused by remember { mutableStateOf(false) } + Button( + onClick = doneAction, + modifier = Modifier + .focusRequester(focusRequester) + .onFocusChanged { doneFocused = it.isFocused }, + border = BorderStroke(2.dp, if (doneFocused) MaterialTheme.colorScheme.primary else TextPrimary), + contentPadding = PaddingValues(horizontal = OpenNowSpacing.md, vertical = 6.dp), + ) { + Text(stringResource(R.string.stream_panel_done), maxLines = 1) + } + } else { + StreamPanelHeaderButton(onClick = doneAction, modifier = Modifier.focusRequester(focusRequester)) { + Text(stringResource(R.string.stream_panel_done), maxLines = 1) + } + } + } + } +} + +/** + * An outlined button that actually shows a focus ring. OutlinedButton alone gives no visible focus + * state here, so the panel used to repeat this onFocusChanged + border pattern per button. + */ +@Composable +private fun StreamPanelHeaderButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable RowScope.() -> Unit, +) { + var focused by remember { mutableStateOf(false) } + OutlinedButton( + onClick = onClick, + modifier = modifier.onFocusChanged { focused = it.isFocused }, + border = BorderStroke( + width = if (focused) 2.dp else 1.dp, + color = if (focused) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline, + ), + contentPadding = PaddingValues(horizontal = 10.dp, vertical = 6.dp), + content = content, + ) +} + +/** Slides forward going into a sub-page and back coming out of one. */ +private fun streamPanelPageTransition( + from: StreamControlsPage, + to: StreamControlsPage, + reduceMotion: Boolean, +): ContentTransform { + if (reduceMotion) { + return fadeIn(tween(0)) togetherWith fadeOut(tween(0)) + } + val forward = from == StreamControlsPage.Main && to != StreamControlsPage.Main + val duration = OpenNowMotion.DurationStandard + val easing = OpenNowMotion.EasingStandard + return ( + slideInHorizontally(tween(duration, easing = easing)) { width -> if (forward) width / 6 else -width / 6 } + + fadeIn(tween(duration, easing = easing)) + ) togetherWith ( + slideOutHorizontally(tween(duration, easing = easing)) { width -> if (forward) -width / 6 else width / 6 } + + fadeOut(tween(OpenNowMotion.DurationFast, easing = easing)) + ) +} + +@Composable +private fun BugReportSubmissionRequirements(modifier: Modifier = Modifier) { + Text( + "Bug reports require English as the OpenNOW or device language. Descriptions must contain at least $ANDROID_BUG_REPORT_MIN_MEANINGFUL_CHARS meaningful letters or numbers and explain what happened. Non-English, repeated, or random text cannot be sent.", + modifier = modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.error, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.bodyMedium, + ) +} + +@Composable +internal fun BugReportLocaleGateCard(modifier: Modifier = Modifier) { + val context = LocalContext.current + Surface( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + color = MaterialTheme.colorScheme.error.copy(alpha = 0.10f), + contentColor = TextPrimary, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.error.copy(alpha = 0.38f)), + ) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + stringResource(R.string.bug_report_english_required), + color = MaterialTheme.colorScheme.error, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.labelLarge, + ) + Text( + stringResource(R.string.bug_report_english_required_body), + color = TextMuted, + style = MaterialTheme.typography.bodySmall, + ) + Button( + onClick = { setAndroidAppLanguage(context, ANDROID_APP_LANGUAGE_ENGLISH) }, + ) { + Text(stringResource(R.string.bug_report_use_english)) + } + } + } +} + +@Composable +internal fun BugReportKnownIssueOverride( + block: BugReportKnownIssueBlock, + checked: Boolean, + enabled: Boolean, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + val warning = Color(0xffffc266) + Surface( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(10.dp), + color = warning.copy(alpha = 0.09f), + border = BorderStroke(1.dp, warning.copy(alpha = 0.34f)), + ) { + Column( + modifier = Modifier.padding(horizontal = 9.dp, vertical = 7.dp), + verticalArrangement = Arrangement.spacedBy(3.dp), + ) { + Text(block.title, color = warning, style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold) + Text(block.action, color = TextMuted, style = MaterialTheme.typography.labelSmall) + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .clickable(enabled = enabled) { onCheckedChange(!checked) }, + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox( + checked = checked, + onCheckedChange = onCheckedChange, + enabled = enabled, + modifier = Modifier.size(36.dp), + ) + Text( + stringResource(R.string.bug_report_known_issue_override), + modifier = Modifier.weight(1f), + color = TextMuted, + style = MaterialTheme.typography.labelSmall, + ) + } + } + } +} + +@Composable +internal fun BugReportVersionGateCard( + update: AndroidUpdateState, + versionCheck: AndroidBugReportVersionCheckState, + onRetry: () -> Unit, + onOpenUpdate: () -> Unit, + modifier: Modifier = Modifier, +) { + val updateRequired = update.status == AndroidUpdateStatus.Available || + versionCheck.status == AndroidBugReportVersionCheckStatus.UpdateRequired + val checking = versionCheck.status == AndroidBugReportVersionCheckStatus.Checking + Surface( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + color = MaterialTheme.colorScheme.error.copy(alpha = 0.10f), + contentColor = TextPrimary, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.error.copy(alpha = 0.38f)), + ) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + when { + updateRequired -> "Update required before reporting" + checking -> "Checking Google Play" + else -> "Google Play version check required" + }, + color = MaterialTheme.colorScheme.error, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.labelLarge, + ) + Text( + androidBugReportBlockMessage(update, versionCheck) + ?: "OpenNOW must verify the installed Play Store build before sending a report.", + color = TextMuted, + style = MaterialTheme.typography.bodySmall, + ) + when { + updateRequired -> Button(onClick = onOpenUpdate) { + Text(stringResource(R.string.bug_report_update_play)) + } + checking -> Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) + Text(stringResource(R.string.bug_report_checking_build), style = MaterialTheme.typography.bodySmall) + } + else -> OutlinedButton(onClick = onRetry) { + Text(stringResource(R.string.bug_report_retry_version)) + } + } + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun BugReportPreflightDeckView( + deck: BugReportPreflightDeck, + page: Int, + onPrevious: () -> Unit, + onNext: () -> Unit, + onRefresh: () -> Unit, + onCancel: () -> Unit, +) { + val card = deck.cards[page] + val accent = when (card.tone) { + BugReportPreflightTone.Healthy -> Green + BugReportPreflightTone.Notice -> MaterialTheme.colorScheme.primary + BugReportPreflightTone.Warning -> Color(0xffffc266) + } + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(stringResource(R.string.bug_report_preflight_title), fontWeight = FontWeight.Bold, style = MaterialTheme.typography.titleMedium) + Text( + stringResource(R.string.bug_report_preflight_subtitle), + color = TextMuted, + style = MaterialTheme.typography.bodySmall, + ) + } + Text( + "${page + 1} / ${deck.cards.size}", + color = accent, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + ) + } + + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + deck.cards.indices.forEach { index -> + Box( + Modifier + .height(4.dp) + .weight(1f) + .clip(RoundedCornerShape(999.dp)) + .background(if (index <= page) accent else Color.White.copy(alpha = 0.10f)), + ) + } + } + + AnimatedContent( + targetState = page, + transitionSpec = { fadeIn(tween(140)) togetherWith fadeOut(tween(100)) }, + label = "bug-report-preflight-card", + ) { targetPage -> + val targetCard = deck.cards[targetPage] + val targetAccent = when (targetCard.tone) { + BugReportPreflightTone.Healthy -> Green + BugReportPreflightTone.Notice -> MaterialTheme.colorScheme.primary + BugReportPreflightTone.Warning -> Color(0xffffc266) + } + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(18.dp), + color = targetAccent.copy(alpha = 0.08f), + border = BorderStroke(1.dp, targetAccent.copy(alpha = 0.34f)), + ) { + Column( + modifier = Modifier.padding(15.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + targetCard.label, + color = targetAccent, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + letterSpacing = 0.8.sp, + ) + Text( + targetCard.title, + color = TextPrimary, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + Text( + targetCard.summary, + color = TextMuted, + style = MaterialTheme.typography.bodySmall, + ) + if (targetCard.facts.isNotEmpty()) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + targetCard.facts.forEach { fact -> + Surface( + shape = RoundedCornerShape(999.dp), + color = PanelAlt, + border = if (LocalAbsoluteCinemaEffects.current) { + BorderStroke(1.dp, LocalActiveSelectionColor.current) + } else { + null + }, + ) { + Text( + fact, + modifier = Modifier.padding(horizontal = 9.dp, vertical = 5.dp), + color = TextPrimary, + style = MaterialTheme.typography.labelSmall, + ) + } + } + } + } + if (targetCard.recommendations.isNotEmpty()) { + Text( + stringResource(R.string.bug_report_matched_suggestions), + color = targetAccent, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + ) + targetCard.recommendations.forEach { finding -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(9.dp), + verticalAlignment = Alignment.Top, + ) { + Surface( + modifier = Modifier.size(7.dp).offset(y = 6.dp), + shape = CircleShape, + color = targetAccent, + ) {} + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + finding.title, + color = TextPrimary, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Bold, + ) + Text( + finding.detail, + color = TextMuted, + style = MaterialTheme.typography.labelSmall, + ) + } + } + } + } else { + Text( + stringResource(R.string.bug_report_no_suggestions), + color = targetAccent, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.SemiBold, + ) + } + } + } + } + + Text( + stringResource(R.string.bug_report_still_happening), + color = TextMuted, + style = MaterialTheme.typography.labelSmall, + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + TextButton(onClick = onRefresh) { + Text(stringResource(R.string.action_refresh)) + } + Spacer(Modifier.weight(1f)) + OutlinedButton(onClick = if (page == 0) onCancel else onPrevious) { + Text(if (page == 0) stringResource(R.string.action_cancel) else stringResource(R.string.action_back)) + } + Button(onClick = onNext) { + Text(if (page == deck.cards.lastIndex) stringResource(R.string.action_continue) else stringResource(R.string.action_next)) + } + } + } +} + +@Composable +internal fun BugReportFormInputs( + title: String, + description: String, + consentChecked: Boolean, + knownIssueBlock: BugReportKnownIssueBlock?, + acknowledgedKnownIssueKey: String?, + submission: BugReportSubmissionState, + onTitleChange: (String) -> Unit, + onDescriptionChange: (String) -> Unit, + onConsentChange: (Boolean) -> Unit, + onKnownIssueAcknowledgementChange: (String?) -> Unit, + onConfirm: () -> Unit, + modifier: Modifier = Modifier, +) { + val descriptionError = androidBugReportDescriptionError(description) + val titleError = androidBugReportTitleError(title) + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + OutlinedTextField( + value = title, + onValueChange = onTitleChange, + modifier = Modifier.fillMaxWidth(), + enabled = !submission.uploading, + singleLine = true, + label = { Text(stringResource(R.string.bug_report_title_label)) }, + placeholder = { Text(stringResource(R.string.bug_report_title_placeholder)) }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + ) + OutlinedTextField( + value = description, + onValueChange = onDescriptionChange, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 128.dp), + enabled = !submission.uploading, + minLines = 4, + maxLines = 7, + label = { Text(stringResource(R.string.bug_report_description_label)) }, + placeholder = { Text(stringResource(R.string.bug_report_description_placeholder)) }, + supportingText = { + Text( + descriptionError + ?: "${androidBugReportMeaningfulCharacterCount(description)} / $ANDROID_BUG_REPORT_MIN_MEANINGFUL_CHARS meaningful characters", + ) + }, + isError = description.isNotEmpty() && descriptionError != null, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Default), + ) + BugReportDescriptionFeedback( + description = description, + error = descriptionError, + ) + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .clickable(enabled = !submission.uploading) { + onConsentChange(!consentChecked) + } + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox( + checked = consentChecked, + onCheckedChange = onConsentChange, + enabled = !submission.uploading, + ) + Text( + stringResource(R.string.bug_report_consent_upload), + modifier = Modifier.weight(1f), + color = TextMuted, + style = MaterialTheme.typography.bodySmall, + ) + } + knownIssueBlock?.let { block -> + BugReportKnownIssueOverride( + block = block, + checked = acknowledgedKnownIssueKey == block.key, + enabled = !submission.uploading, + onCheckedChange = { checked -> + onKnownIssueAcknowledgementChange(block.key.takeIf { checked }) + }, + ) + } + submission.error?.let { error -> + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(10.dp), + color = MaterialTheme.colorScheme.error.copy(alpha = 0.12f), + contentColor = MaterialTheme.colorScheme.error, + ) { + Text( + error, + modifier = Modifier.padding(10.dp), + style = MaterialTheme.typography.bodySmall, + ) + } + } + Button( + onClick = onConfirm, + enabled = titleError == null && + descriptionError == null && + consentChecked && + bugReportKnownIssueAllowsSubmission(knownIssueBlock, acknowledgedKnownIssueKey) && + !submission.uploading, + modifier = Modifier.fillMaxWidth(), + ) { + if (submission.uploading) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.bug_report_uploading)) + } else { + Text(if (knownIssueBlock == null) "Send bug report" else "Send anyway") + } + } + } +} + +@Composable +internal fun BugReportDescriptionFeedback( + description: String, + error: String?, + modifier: Modifier = Modifier, +) { + if (description.isEmpty() || error == null) return + + val meaningfulCharacters = androidBugReportMeaningfulCharacterCount(description) + val missingCharacters = (ANDROID_BUG_REPORT_MIN_MEANINGFUL_CHARS - meaningfulCharacters).coerceAtLeast(0) + Surface( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(10.dp), + color = MaterialTheme.colorScheme.error.copy(alpha = 0.14f), + contentColor = TextPrimary, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.error.copy(alpha = 0.48f)), + ) { + Column( + modifier = Modifier.padding(10.dp), + verticalArrangement = Arrangement.spacedBy(3.dp), + ) { + Text( + if (missingCharacters > 0) "Add more detail" else "Description needs attention", + color = MaterialTheme.colorScheme.error, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.labelLarge, + ) + Text( + if (missingCharacters > 0) { + "$meaningfulCharacters / $ANDROID_BUG_REPORT_MIN_MEANINGFUL_CHARS meaningful characters. Add $missingCharacters more letters or numbers before sending." + } else { + error + }, + style = MaterialTheme.typography.bodyMedium, + ) + } + } +} + +@Composable +private fun StreamBugReporter( + submission: BugReportSubmissionState, + versionCheck: AndroidBugReportVersionCheckState, + update: AndroidUpdateState, + onSubmit: (String, String, String?) -> Unit, + onReset: () -> Unit, + onVersionCheck: () -> Unit, + onOpenUpdate: () -> Unit, + onButtonTone: () -> Unit, + preflightProvider: () -> BugReportPreflightDeck, + initiallyExpanded: Boolean = false, + onExpandedClose: () -> Unit = {}, +) { + val landscapeLayout = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE + val appLocale = currentAndroidAppLocale(LocalContext.current) + var expanded by rememberSaveable(initiallyExpanded) { mutableStateOf(initiallyExpanded) } + var title by rememberSaveable { mutableStateOf("") } + var description by rememberSaveable { mutableStateOf("") } + var consentChecked by rememberSaveable { mutableStateOf(false) } + var confirmationOpen by rememberSaveable { mutableStateOf(false) } + var preflightReviewed by rememberSaveable { mutableStateOf(false) } + var preflightPage by rememberSaveable { mutableStateOf(0) } + var preflightDeck by remember { mutableStateOf(null) } + var acknowledgedKnownIssueKey by rememberSaveable { mutableStateOf(null) } + val knownIssueBlock = preflightDeck?.let { deck -> + bugReportKnownIssueBlock(title, description, deck) + } + + LaunchedEffect(expanded, update.installSource.isGooglePlay) { + if (expanded && update.installSource.isGooglePlay) { + onVersionCheck() + } + if (expanded && !preflightReviewed && preflightDeck == null) { + preflightDeck = preflightProvider() + } + } + + ControlSection(stringResource(R.string.bug_report_section)) { + if (!expanded) { + ControlActionRow( + label = stringResource(R.string.bug_report_open_label), + actionLabel = stringResource(R.string.action_open), + onClick = { + onButtonTone() + preflightReviewed = false + preflightPage = 0 + preflightDeck = preflightProvider() + expanded = true + }, + value = stringResource(R.string.bug_report_open_summary), + ) + return@ControlSection + } + + DiscordCommunityLink( + summary = stringResource(R.string.discord_community_bug_report_summary), + ) + + if (submission.submitted) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + color = Green.copy(alpha = 0.12f), + contentColor = TextPrimary, + border = BorderStroke(1.dp, Green.copy(alpha = 0.45f)), + ) { + Column( + modifier = Modifier.padding(14.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon(Icons.Rounded.Check, contentDescription = null, tint = Green) + Text(stringResource(R.string.bug_report_sent), fontWeight = FontWeight.Bold) + } + submission.reference?.let { reportId -> + CopyableBugReportId(reportId) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton( + onClick = { + onButtonTone() + title = "" + description = "" + consentChecked = false + acknowledgedKnownIssueKey = null + confirmationOpen = false + preflightReviewed = false + preflightPage = 0 + preflightDeck = preflightProvider() + onReset() + }, + ) { + Text(stringResource(R.string.bug_report_send_another)) + } + TextButton( + onClick = { + onButtonTone() + preflightReviewed = false + preflightPage = 0 + preflightDeck = null + expanded = false + onExpandedClose() + }, + ) { + Text(stringResource(R.string.action_close)) + } + } + } + } + return@ControlSection + } + + if (!appLocale.bugReportsAllowed) { + BugReportLocaleGateCard() + return@ControlSection + } + + if (!androidBugReportsAllowed(update, versionCheck)) { + BugReportVersionGateCard( + update = update, + versionCheck = versionCheck, + onRetry = onVersionCheck, + onOpenUpdate = onOpenUpdate, + ) + return@ControlSection + } + + if (!preflightReviewed) { + val deck = preflightDeck + if (deck == null) { + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 20.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + CircularProgressIndicator(modifier = Modifier.size(22.dp), strokeWidth = 2.dp) + Spacer(Modifier.width(10.dp)) + Text(stringResource(R.string.bug_report_checking_session), color = TextMuted) + } + } else { + BugReportPreflightDeckView( + deck = deck, + page = preflightPage.coerceIn(deck.cards.indices), + onPrevious = { + onButtonTone() + preflightPage = (preflightPage - 1).coerceAtLeast(0) + }, + onNext = { + onButtonTone() + if (preflightPage < deck.cards.lastIndex) { + preflightPage += 1 + } else { + preflightReviewed = true + } + }, + onRefresh = { + onButtonTone() + preflightPage = 0 + preflightDeck = preflightProvider() + }, + onCancel = { + onButtonTone() + preflightReviewed = false + preflightPage = 0 + preflightDeck = null + expanded = false + onExpandedClose() + }, + ) + } + return@ControlSection + } + + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(stringResource(R.string.bug_report_open_label), fontWeight = FontWeight.Bold) + Text( + stringResource(R.string.bug_report_inline_subtitle), + color = TextMuted, + style = MaterialTheme.typography.bodySmall, + ) + } + TextButton( + enabled = !submission.uploading, + onClick = { + onButtonTone() + preflightReviewed = false + preflightPage = 0 + preflightDeck = preflightProvider() + }, + ) { + Text(stringResource(R.string.bug_report_checks_tab)) + } + TextButton( + enabled = !submission.uploading, + onClick = { + onButtonTone() + preflightReviewed = false + preflightPage = 0 + preflightDeck = null + expanded = false + onExpandedClose() + }, + ) { + Text(stringResource(R.string.action_cancel)) + } + } + + if (landscapeLayout) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(OpenNowSpacing.lg), + verticalAlignment = Alignment.Top, + ) { + Column( + modifier = Modifier.weight(0.9f), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + BugReportSubmissionRequirements() + BugReportDataDisclosure(includeTypedTextWarning = true) + } + BugReportFormInputs( + title = title, + description = description, + consentChecked = consentChecked, + knownIssueBlock = knownIssueBlock, + acknowledgedKnownIssueKey = acknowledgedKnownIssueKey, + submission = submission, + onTitleChange = { value -> + title = value + if (submission.error != null) onReset() + }, + onDescriptionChange = { value -> + description = value + if (submission.error != null) onReset() + }, + onConsentChange = { consentChecked = it }, + onKnownIssueAcknowledgementChange = { acknowledgedKnownIssueKey = it }, + onConfirm = { + onButtonTone() + confirmationOpen = true + }, + modifier = Modifier.weight(1.1f), + ) + } + } else { + BugReportSubmissionRequirements() + BugReportDataDisclosure(includeTypedTextWarning = true) + BugReportFormInputs( + title = title, + description = description, + consentChecked = consentChecked, + knownIssueBlock = knownIssueBlock, + acknowledgedKnownIssueKey = acknowledgedKnownIssueKey, + submission = submission, + onTitleChange = { value -> + title = value + if (submission.error != null) onReset() + }, + onDescriptionChange = { value -> + description = value + if (submission.error != null) onReset() + }, + onConsentChange = { consentChecked = it }, + onKnownIssueAcknowledgementChange = { acknowledgedKnownIssueKey = it }, + onConfirm = { + onButtonTone() + confirmationOpen = true + }, + ) + } + } + } + + if (confirmationOpen) { + AlertDialog( + onDismissRequest = { confirmationOpen = false }, + modifier = if (landscapeLayout) { + Modifier.widthIn(max = 760.dp).fillMaxWidth(0.82f) + } else { + Modifier + }, + properties = DialogProperties(usePlatformDefaultWidth = !landscapeLayout), + title = { Text(stringResource(R.string.bug_report_upload_confirm_title)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + knownIssueBlock?.let { block -> + Text(block.title, color = Color(0xffffc266), fontWeight = FontWeight.Bold) + Text(block.action, color = TextMuted, style = MaterialTheme.typography.bodySmall) + } + BugReportSubmissionRequirements() + BugReportDataDisclosure(includeTypedTextWarning = true) + } + }, + confirmButton = { + Button( + onClick = { + onButtonTone() + confirmationOpen = false + onSubmit( + title, + description, + knownIssueBlock?.key?.takeIf { it == acknowledgedKnownIssueKey }, + ) + }, + ) { + Text(stringResource(R.string.bug_report_upload_action)) + } + }, + dismissButton = { + TextButton( + onClick = { + onButtonTone() + confirmationOpen = false + }, + ) { + Text(stringResource(R.string.action_go_back)) + } + }, + ) + } +} + +private fun LazyListScope.mouseModePageItems( + settings: AppSettings, + controllerMouseEmulationEnabled: Boolean, + onControllerMouseEmulationToggle: () -> Unit, + onMouseSensitivityChange: (Float) -> Unit, + onMouseScrollSensitivityChange: (Int) -> Unit, + onNativeTouchScrollScaleChange: (Float) -> Unit, + onNativeTouchJitterThresholdChange: (Float) -> Unit, + onButtonTone: () -> Unit, +) { + item { + ControlSwitchRow( + label = "Enable Mouse Mode", + checked = controllerMouseEmulationEnabled, + onCheckedChange = { + onButtonTone() + onControllerMouseEmulationToggle() + }, + value = onOffLabel(controllerMouseEmulationEnabled), + ) + } + if (controllerMouseEmulationEnabled) { + item { + ControlSliderRow( + label = "Mouse sensitivity", + value = settings.stream.mouseSensitivity, + min = 0.25f, + max = 3f, + step = 0.05f, + onChange = onMouseSensitivityChange, + valueFormatter = { "%.2fx".format(it) } + ) + } + item { + val scrollHint = when { + settings.stream.mouseScrollSensitivity <= 20 -> "Fast" + settings.stream.mouseScrollSensitivity <= 40 -> "Normal" + settings.stream.mouseScrollSensitivity <= 60 -> "Precise" + else -> "Slow" + } + ControlSliderRow( + label = "Scroll sensitivity", + value = settings.stream.mouseScrollSensitivity.toFloat(), + min = 10f, + max = 100f, + step = 5f, + onChange = { onMouseScrollSensitivityChange(it.toInt()) }, + descriptionProvider = { "Speed: $scrollHint" } + ) + } + } + if (settings.androidTouch.effectiveNativeTouchMode() != NativeTouchMode.Off) { + item { + val scrollSpeedLabel = when { + settings.androidTouch.nativeTouchScrollScale <= 0.5f -> "Very slow" + settings.androidTouch.nativeTouchScrollScale <= 0.8f -> "Slow" + settings.androidTouch.nativeTouchScrollScale <= 1.2f -> "Normal" + settings.androidTouch.nativeTouchScrollScale <= 1.6f -> "Fast" + else -> "Very fast" + } + ControlSliderRow( + label = "Touch scroll speed", + value = settings.androidTouch.nativeTouchScrollScale, + min = 0.25f, + max = 2.0f, + step = 0.05f, + onChange = onNativeTouchScrollScaleChange, + descriptionProvider = { scrollSpeedLabel } + ) + } + item { + ControlSliderRow( + label = "Touch tap stability", + value = settings.androidTouch.nativeTouchJitterThresholdDp, + min = 0f, + max = 24f, + step = 1f, + onChange = onNativeTouchJitterThresholdChange, + valueFormatter = { "${it.toInt()}dp" } + ) + } + } +} + + +@OptIn(ExperimentalLayoutApi::class) +private fun LazyListScope.statusBarPageItems( + settings: AppSettings, + statsVisible: Boolean, + onStatsToggle: () -> Unit, + onStatsStyleCycle: () -> Unit, + onStatsPositionCycle: () -> Unit, + onStatsMetricsChange: (StreamStatsMetrics) -> Unit, + onKeyboardButtonToggle: () -> Unit, + onButtonTone: () -> Unit, +) { + item { + ControlSwitchRow( + label = stringResource(R.string.common_visible), + checked = statsVisible, + onCheckedChange = { + onButtonTone() + onStatsToggle() + }, + value = onOffLabel(statsVisible), + ) + } + item { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(OpenNowSpacing.sm)) { + ControlActionRow( + label = stringResource(R.string.stream_statusbar_appearance), + actionLabel = settings.streamStatsStyle.label, + onClick = { + onButtonTone() + onStatsStyleCycle() + }, + modifier = Modifier.weight(1f), + ) + ControlActionRow( + label = stringResource(R.string.stream_statusbar_position), + actionLabel = settings.streamStatsPosition.label, + onClick = { + onButtonTone() + onStatsPositionCycle() + }, + modifier = Modifier.weight(1f), + ) + } + } + item { + Text( + stringResource(R.string.stream_statusbar_items), + color = TextMuted, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + ) + } + item { + // Compact toggles side by side; the standard row height would waste the panel. + val statusBarMetricStyle = ControlRowStyle.stream().copy( + verticalPadding = 6.dp, + labelStyle = MaterialTheme.typography.labelMedium, + ) + BoxWithConstraints(Modifier.fillMaxWidth()) { + val columns = when { + maxWidth >= 800.dp -> 5 + maxWidth >= 620.dp -> 4 + maxWidth >= 460.dp -> 3 + else -> 2 + } + val gap = 8.dp + val itemWidth = (maxWidth - gap * (columns - 1)) / columns.toFloat() + FlowRow( + modifier = Modifier.fillMaxWidth(), + maxItemsInEachRow = columns, + horizontalArrangement = Arrangement.spacedBy(gap), + verticalArrangement = Arrangement.spacedBy(gap), + ) { + StreamStatusItem.entries.forEach { item -> + ControlSwitchRow( + label = stringResource(item.labelRes), + checked = item.enabledIn(settings), + onCheckedChange = { enabled -> + onButtonTone() + if (item == StreamStatusItem.Keyboard) { + onKeyboardButtonToggle() + } else { + onStatsMetricsChange(item.setEnabled(settings, enabled).streamStatsMetrics) + } + }, + modifier = Modifier.width(itemWidth), + style = statusBarMetricStyle, + ) + } + } + } + } +} + +/** + * The three bare key buttons in the Input section. Extracted so the manual focus-ring pattern the + * panel needs lives in one place instead of being repeated per button. + */ +@Composable +private fun StreamPanelKeyButton(label: String, modifier: Modifier = Modifier, onClick: () -> Unit) { + var focused by remember { mutableStateOf(false) } + OutlinedButton( + onClick = onClick, + modifier = modifier.onFocusChanged { focused = it.isFocused }, + border = BorderStroke( + width = if (focused) 2.dp else 1.dp, + color = if (focused) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline, + ), + ) { + Text(label, maxLines = 1) + } +} + +/** + * A touch-layout slider. Unlike the settings sliders these preview on every drag frame, because + * the overlay they are adjusting is on screen underneath the panel and watching it move is the + * point of the control. + */ +@Composable +private fun TouchLayoutSlider( + @StringRes labelRes: Int, + value: Float, + min: Float, + max: Float, + step: Float, + onChange: (Float) -> Unit, + unit: String? = null, +) { + ControlSliderRow( + label = stringResource(labelRes), + value = value, + min = min, + max = max, + step = step, + onChange = onChange, + onChangePreview = onChange, + unit = unit, + ) +} + +/** "On" / "Off", so the same boolean reads the same way everywhere. */ +@Composable +internal fun onOffLabel(enabled: Boolean): String = + stringResource(if (enabled) R.string.common_on else R.string.common_off) + +private const val SHARPENING_SLIDER_STEP = 0.05f +private const val TOUCH_SCALE_SLIDER_STEP = 0.05f +private const val TOUCH_DP_SLIDER_STEP = 2f +private const val JOYSTICK_DEAD_ZONE_STEP = 0.01f +private const val DP_UNIT = "dp" diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowStreamStatus.kt b/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowStreamStatus.kt new file mode 100644 index 000000000..0b20fa7f7 --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowStreamStatus.kt @@ -0,0 +1,1360 @@ +package com.opencloudgaming.opennow + +import android.content.Context +import androidx.annotation.StringRes +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.interaction.MutableInteractionSource +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.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Keyboard +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material.icons.automirrored.rounded.BatteryUnknown +import androidx.compose.material.icons.rounded.Battery0Bar +import androidx.compose.material.icons.rounded.Battery1Bar +import androidx.compose.material.icons.rounded.Battery2Bar +import androidx.compose.material.icons.rounded.Battery3Bar +import androidx.compose.material.icons.rounded.Battery4Bar +import androidx.compose.material.icons.rounded.Battery5Bar +import androidx.compose.material.icons.rounded.Battery6Bar +import androidx.compose.material.icons.rounded.BatteryFull +import androidx.compose.material.icons.rounded.Bolt +import androidx.compose.material.icons.rounded.SignalCellular0Bar +import androidx.compose.material.icons.rounded.SignalCellular4Bar +import androidx.compose.material.icons.rounded.SignalCellularAlt +import androidx.compose.material.icons.rounded.SignalCellularAlt1Bar +import androidx.compose.material.icons.rounded.SignalCellularAlt2Bar +import androidx.compose.material.icons.rounded.SignalWifi0Bar +import androidx.compose.material.icons.rounded.Wifi +import androidx.compose.material.icons.rounded.Wifi1Bar +import androidx.compose.material.icons.rounded.Wifi2Bar +import androidx.compose.material.icons.rounded.WifiOff +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.layout +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay +import java.util.Locale +import com.opencloudgaming.opennow.ui.theme.OpenNowPalette +import com.opencloudgaming.opennow.ui.theme.OpenNowRadius +import com.opencloudgaming.opennow.ui.theme.OpenNowSpacing +import com.opencloudgaming.opennow.ui.theme.numeric +import com.opencloudgaming.opennow.ui.theme.tint + +@Composable +internal fun StreamKeyboardBar( + value: TextFieldValue, + onValueChange: (TextFieldValue) -> Unit, + clearConfirmationEnabled: Boolean, + onClear: () -> Unit, + onDisableClearConfirmation: () -> Unit, + onEnter: () -> Unit, + onEsc: () -> Unit, + onDone: () -> Unit, + modifier: Modifier = Modifier, +) { + val inputFocusRequester = remember { FocusRequester() } + val keyboardController = LocalSoftwareKeyboardController.current + val focusManager = LocalFocusManager.current + var clearConfirmationOpen by remember { mutableStateOf(false) } + var neverAskAgain by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { + delay(80) + runCatching { inputFocusRequester.requestFocus() } + keyboardController?.show() + } + Surface( + modifier = modifier + // The stream runs edge-to-edge with the system bars hidden, so adjustResize does not + // push this bar up when the IME opens: without imePadding the Android keyboard would + // cover the text field and the action buttons below it. + .imePadding() + .fillMaxWidth() + // The keyboard bar registered no passthrough bounds at all, so on a phone every tap on + // it — including on the text field — was also forwarded into the game as touch input. + .streamTouchPassthrough(PASSTHROUGH_ID_KEYBOARD), + // imePadding on the parent keeps this single compact row directly above the system IME. + shape = RoundedCornerShape(topStart = OpenNowRadius.lg, topEnd = OpenNowRadius.lg), + color = OpenNowPalette.PanelOverVideo, + border = BorderStroke(1.dp, OpenNowPalette.PanelHairline), + tonalElevation = 8.dp, + ) { + Row( + modifier = Modifier.padding(horizontal = OpenNowSpacing.sm, vertical = 6.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + value = value, + onValueChange = onValueChange, + modifier = Modifier + .weight(1f) + .height(52.dp) + .focusRequester(inputFocusRequester), + singleLine = true, + textStyle = MaterialTheme.typography.bodyLarge.copy( + color = Color.White, + fontWeight = FontWeight.Medium, + ), + placeholder = { Text(stringResource(R.string.stream_text_placeholder), color = TextMuted) }, + colors = OutlinedTextFieldDefaults.colors( + focusedTextColor = Color.White, + unfocusedTextColor = Color.White, + cursorColor = MaterialTheme.colorScheme.primary, + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = Color.White.copy(alpha = 0.72f), + focusedContainerColor = Color.Black.copy(alpha = 0.52f), + unfocusedContainerColor = Color.Black.copy(alpha = 0.42f), + ), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text, imeAction = ImeAction.Send), + keyboardActions = KeyboardActions(onSend = { onEnter() }), + ) + TextButton( + onClick = { + if (clearConfirmationEnabled) { + neverAskAgain = false + clearConfirmationOpen = true + } else { + onClear() + } + }, + contentPadding = PaddingValues(horizontal = 10.dp), + ) { + Text(stringResource(R.string.stream_panel_clear)) + } + OutlinedButton(onClick = onEnter, contentPadding = PaddingValues(horizontal = 12.dp)) { Text(stringResource(R.string.stream_panel_key_enter)) } + TextButton(onClick = onEsc, contentPadding = PaddingValues(horizontal = 10.dp)) { Text(stringResource(R.string.stream_panel_key_esc)) } + TextButton( + onClick = { + keyboardController?.hide() + focusManager.clearFocus(force = true) + onDone() + }, + contentPadding = PaddingValues(horizontal = 10.dp), + ) { Text(stringResource(R.string.stream_panel_done)) } + } + } + if (clearConfirmationOpen) { + AlertDialog( + onDismissRequest = { clearConfirmationOpen = false }, + title = { Text(stringResource(R.string.stream_keyboard_clear_confirm_title)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text(stringResource(R.string.stream_keyboard_clear_confirm_body)) + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { neverAskAgain = !neverAskAgain }, + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox( + checked = neverAskAgain, + onCheckedChange = null, + ) + Text(stringResource(R.string.stream_keyboard_clear_never_ask_again)) + } + } + }, + confirmButton = { + TextButton( + onClick = { + clearConfirmationOpen = false + if (neverAskAgain) onDisableClearConfirmation() + onClear() + }, + ) { + Text(stringResource(R.string.stream_panel_clear)) + } + }, + dismissButton = { + TextButton(onClick = { clearConfirmationOpen = false }) { + Text(stringResource(R.string.action_cancel)) + } + }, + ) + } +} + +internal const val MAX_STREAM_KEYBOARD_TEXT_LENGTH = 4096 + +@OptIn(ExperimentalLayoutApi::class) +@Composable +internal fun StreamStatsPill( + streamStats: StreamRuntimeStats, + streamSettings: StreamSettings, + style: StreamStatsStyle, + metrics: StreamStatsMetrics, + serverLocation: String?, + keyboardButtonEnabled: Boolean, + onKeyboardOpen: () -> Unit, + modifier: Modifier = Modifier, +) { + if (metrics.enabledCount() == 0 && !keyboardButtonEnabled) return + val compact = style == StreamStatsStyle.Compact + val deviceStatus = rememberCompactStreamDeviceStatus() + Surface( + modifier = modifier + .padding(OpenNowSpacing.sm) + .widthIn(max = if (compact) 720.dp else 300.dp), + shape = RoundedCornerShape(if (compact) OpenNowRadius.full else OpenNowRadius.lg), + // This sits over gameplay, so keep the capsule clean and borderless. Top-level Cinema + // chrome must not leak into the in-stream status overlay. + color = Panel.copy(alpha = 0.52f), + tonalElevation = 0.dp, + ) { + if (compact) { + Row( + Modifier.padding(horizontal = OpenNowSpacing.md, vertical = 6.dp), + horizontalArrangement = Arrangement.spacedBy(OpenNowSpacing.md), + verticalAlignment = Alignment.CenterVertically, + ) { + StreamStatsMetricItems(streamStats, streamSettings, metrics, deviceStatus, serverLocation) + if (keyboardButtonEnabled) { + StreamStatusKeyboardButton(onClick = onKeyboardOpen) + } + } + } else { + FlowRow( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = OpenNowSpacing.md, vertical = OpenNowSpacing.sm), + maxItemsInEachRow = 2, + horizontalArrangement = Arrangement.spacedBy(OpenNowSpacing.md), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + StreamStatsMetricItems( + streamStats, + streamSettings, + metrics, + deviceStatus, + serverLocation, + // Two aligned columns instead of a ragged pair of runs. + itemModifier = Modifier.weight(1f), + ) + if (keyboardButtonEnabled) { + StreamStatusKeyboardButton(onClick = onKeyboardOpen) + } + } + } + } +} + +@Composable +private fun StreamStatusKeyboardButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .size(28.dp) + .streamTouchPassthrough(PASSTHROUGH_ID_STATUS_BAR_KEYBOARD, inflate = 8.dp) + .clickable(role = Role.Button, onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Rounded.Keyboard, + contentDescription = stringResource(R.string.stream_panel_cd_keyboard), + tint = TextPrimary, + modifier = Modifier.size(20.dp), + ) + } +} + +@Composable +internal fun StreamNetworkQualityNotice( + warning: StreamNetworkWarning, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier + .padding(horizontal = 8.dp) + .widthIn(max = 520.dp) + .semantics { contentDescription = warning.message }, + shape = RoundedCornerShape(OpenNowRadius.md), + color = Color(0xff4a2f0b).copy(alpha = 0.92f), + border = BorderStroke(1.dp, OpenNowPalette.StatusNotice.copy(alpha = 0.62f)), + tonalElevation = 0.dp, + ) { + Text( + text = warning.message, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + color = Color(0xffffd38a), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +internal fun ActiveStreamModePill( + status: ActiveStreamModeStatus, + recoveryReason: String?, + bugReportSubmission: BugReportSubmissionState, + bugReportVersionCheck: AndroidBugReportVersionCheckState, + update: AndroidUpdateState, + onBugReportSubmit: (String, String) -> Unit, + onBugReportReset: () -> Unit, + onBugReportVersionCheck: () -> Unit, + onOpenUpdate: () -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val appLocale = currentAndroidAppLocale(context) + // Only the changes that still raise a notice. Resolution and colour negotiation is recorded + // silently — see activeStreamModeNoticeChanges. + val changes = remember(status) { activeStreamModeNoticeChanges(status) } + if (changes.isEmpty()) return + val causeAssessment = remember(status, recoveryReason) { + activeStreamModeCauseAssessment(status, recoveryReason) + } + val developerReport = remember(status, recoveryReason) { + activeStreamModeDeveloperReport(status, recoveryReason) + } + val headline = changes.first().let { "${it.label} ${it.requestedValue} → ${it.actualValue}" } + val noticeKey = remember(changes, recoveryReason) { + changes.joinToString("|") { "${it.label}:${it.requestedValue}:${it.actualValue}" } + + "|${recoveryReason.orEmpty()}" + } + var noticeVisible by remember(noticeKey) { mutableStateOf(true) } + var detailsOpen by remember(noticeKey) { mutableStateOf(false) } + var reportConfirmationOpen by remember(noticeKey) { mutableStateOf(false) } + + LaunchedEffect(detailsOpen, update.installSource.isGooglePlay) { + if (detailsOpen && appLocale.bugReportsAllowed && update.installSource.isGooglePlay) { + onBugReportVersionCheck() + } + } + + LaunchedEffect(noticeKey) { + noticeVisible = true + delay(ACTIVE_STREAM_MODE_NOTICE_DURATION_MS) + noticeVisible = false + } + + AnimatedVisibility( + visible = noticeVisible, + modifier = modifier.padding(horizontal = 8.dp), + enter = fadeIn(), + exit = fadeOut(), + ) { + Surface( + modifier = Modifier + .semantics { contentDescription = "$headline. Tap for details." } + .clickable { + if (!bugReportSubmission.uploading) onBugReportReset() + detailsOpen = true + } + .focusable(), + shape = RoundedCornerShape(999.dp), + color = Color(0xff4a2f0b).copy(alpha = 0.88f), + tonalElevation = 0.dp, + ) { + Text( + text = headline, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp), + color = Color(0xffffd38a), + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + + if (detailsOpen) { + AlertDialog( + onDismissRequest = { + if (!bugReportSubmission.uploading) detailsOpen = false + }, + title = { Text(stringResource(R.string.stream_profile_changed_title)) }, + text = { + Column( + modifier = Modifier + .heightIn(max = 560.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + color = OpenNowPalette.StatusNotice.copy(alpha = 0.10f), + contentColor = TextPrimary, + border = BorderStroke(1.dp, OpenNowPalette.StatusNotice.copy(alpha = 0.32f)), + ) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = stringResource(R.string.stream_profile_changed_why), + color = OpenNowPalette.StatusNotice, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + ) + Text( + text = causeAssessment.summary, + style = MaterialTheme.typography.bodySmall, + ) + } + } + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + changes.forEach { change -> + Column { + Text( + text = change.label, + color = TextMuted, + style = MaterialTheme.typography.labelMedium, + ) + Text( + text = "${change.requestedValue} → ${change.actualValue}", + color = TextPrimary, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + ) + } + } + } + when { + bugReportSubmission.uploading -> Text( + text = stringResource(R.string.stream_profile_sending), + color = TextMuted, + style = MaterialTheme.typography.bodySmall, + ) + bugReportSubmission.submitted -> Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(10.dp), + color = Green.copy(alpha = 0.12f), + contentColor = Green, + ) { + Column( + modifier = Modifier.padding(10.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = "Sent to developer", + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Bold, + ) + bugReportSubmission.reference?.let { reportId -> + CopyableBugReportId(reportId) + } + } + } + bugReportSubmission.error != null -> Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(10.dp), + color = MaterialTheme.colorScheme.error.copy(alpha = 0.12f), + contentColor = MaterialTheme.colorScheme.error, + ) { + Text( + text = bugReportSubmission.error, + modifier = Modifier.padding(10.dp), + style = MaterialTheme.typography.bodySmall, + ) + } + } + Text( + text = stringResource(R.string.stream_profile_settings_unchanged), + color = TextMuted, + style = MaterialTheme.typography.bodySmall, + ) + if (!appLocale.bugReportsAllowed) { + BugReportLocaleGateCard() + } else if (!androidBugReportsAllowed(update, bugReportVersionCheck)) { + BugReportVersionGateCard( + update = update, + versionCheck = bugReportVersionCheck, + onRetry = onBugReportVersionCheck, + onOpenUpdate = onOpenUpdate, + ) + } + } + }, + confirmButton = { + when { + bugReportSubmission.uploading -> Button( + enabled = false, + onClick = {}, + ) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.bug_report_sending)) + } + bugReportSubmission.submitted -> TextButton(onClick = { detailsOpen = false }) { + Text(stringResource(R.string.stream_panel_done)) + } + !appLocale.bugReportsAllowed -> Button( + onClick = { setAndroidAppLanguage(context, ANDROID_APP_LANGUAGE_ENGLISH) }, + ) { + Text(stringResource(R.string.bug_report_use_english)) + } + !androidBugReportsAllowed(update, bugReportVersionCheck) -> when { + update.status == AndroidUpdateStatus.Available || + bugReportVersionCheck.status == AndroidBugReportVersionCheckStatus.UpdateRequired -> + Button(onClick = onOpenUpdate) { + Text(stringResource(R.string.bug_report_update_play)) + } + bugReportVersionCheck.status == AndroidBugReportVersionCheckStatus.Checking -> Button( + enabled = false, + onClick = {}, + ) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.bug_report_checking_play)) + } + else -> Button(onClick = onBugReportVersionCheck) { + Text(stringResource(R.string.bug_report_retry_version)) + } + } + else -> Button( + onClick = { + onBugReportReset() + detailsOpen = false + reportConfirmationOpen = true + }, + ) { + Text(if (bugReportSubmission.error == null) "Send to developer" else "Try again") + } + } + }, + dismissButton = { + if (!bugReportSubmission.uploading && !bugReportSubmission.submitted) { + TextButton(onClick = { detailsOpen = false }) { + Text(stringResource(R.string.action_close)) + } + } + }, + ) + } + + if (reportConfirmationOpen) { + AlertDialog( + onDismissRequest = { + reportConfirmationOpen = false + detailsOpen = true + }, + title = { Text(stringResource(R.string.stream_diag_confirm_title)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text( + stringResource(R.string.stream_diag_confirm_body), + ) + BugReportDataDisclosure( + includeTypedTextWarning = false, + ) + } + }, + confirmButton = { + Button( + onClick = { + reportConfirmationOpen = false + onBugReportSubmit(developerReport.title, developerReport.description) + detailsOpen = true + }, + ) { + Text(stringResource(R.string.stream_diag_send)) + } + }, + dismissButton = { + TextButton( + onClick = { + reportConfirmationOpen = false + detailsOpen = true + }, + ) { + Text(stringResource(R.string.action_cancel)) + } + }, + ) + } +} + +internal enum class ActiveStreamModeChangeKind { + Resolution, + Codec, + Fps, + Bitrate, + Hdr, + Color, + L4S, + Sharpening, +} + +internal data class ActiveStreamModeDisplayChange( + val label: String, + val requestedValue: String, + val actualValue: String, + val kind: ActiveStreamModeChangeKind, +) + +/** + * Changes the player is worth interrupting for. + * + * Resolution and colour-depth differences are routine session negotiation — the cloud allocates a + * mode, the decoder reports another — and every one of them was raising an in-stream notice whose + * only action is uploading a diagnostic report. They stay in the session report and the debug log, + * where they are useful, and no longer prompt. + */ +private val SILENT_ACTIVE_STREAM_MODE_CHANGES = setOf( + ActiveStreamModeChangeKind.Resolution, + ActiveStreamModeChangeKind.Color, +) + +internal fun activeStreamModeNoticeChanges(status: ActiveStreamModeStatus): List = + activeStreamModeDisplayChanges(status).filterNot { it.kind in SILENT_ACTIVE_STREAM_MODE_CHANGES } + +internal fun activeStreamModeDisplayChanges(status: ActiveStreamModeStatus): List { + val requested = status.requestedProfile + val actual = status.transportProfile + return buildList { + if (status.requestedResolution != status.displayedResolution) { + add( + ActiveStreamModeDisplayChange( + label = "Resolution", + requestedValue = formatRuntimeResolution(status.requestedResolution), + actualValue = formatRuntimeResolution(status.displayedResolution), + kind = ActiveStreamModeChangeKind.Resolution, + ), + ) + } + if (requested.codec != actual.codec) { + add( + ActiveStreamModeDisplayChange( + "Codec", + requested.codec.name, + actual.codec.name, + ActiveStreamModeChangeKind.Codec, + ), + ) + } + if (requested.fps != actual.fps) { + add( + ActiveStreamModeDisplayChange( + "FPS", + requested.fps.toString(), + actual.fps.toString(), + ActiveStreamModeChangeKind.Fps, + ), + ) + } + if (requested.maxBitrateMbps != actual.maxBitrateMbps) { + add( + ActiveStreamModeDisplayChange( + "Bitrate", + "${requested.maxBitrateMbps} Mbps", + "${actual.maxBitrateMbps} Mbps", + ActiveStreamModeChangeKind.Bitrate, + ), + ) + } + if (requested.hdrEnabled != actual.hdrEnabled) { + add( + ActiveStreamModeDisplayChange( + "HDR", + requested.hdrEnabled.onOffLabel(), + actual.hdrEnabled.onOffLabel(), + ActiveStreamModeChangeKind.Hdr, + ), + ) + } + if (requested.colorQuality != actual.colorQuality) { + add( + ActiveStreamModeDisplayChange( + "Color", + requested.colorQuality.label, + actual.colorQuality.label, + ActiveStreamModeChangeKind.Color, + ), + ) + } + if (requested.enableL4S != actual.enableL4S) { + add( + ActiveStreamModeDisplayChange( + "L4S", + requested.enableL4S.onOffLabel(), + actual.enableL4S.onOffLabel(), + ActiveStreamModeChangeKind.L4S, + ), + ) + } + if (requested.streamSharpeningEnabled != actual.streamSharpeningEnabled) { + add( + ActiveStreamModeDisplayChange( + "Sharpening", + requested.streamSharpeningEnabled.onOffLabel(), + actual.streamSharpeningEnabled.onOffLabel(), + ActiveStreamModeChangeKind.Sharpening, + ), + ) + } + } +} + +internal fun Boolean.onOffLabel(): String = if (this) "On" else "Off" + +internal data class ActiveStreamModeCauseAssessment( + val summary: String, +) + +internal fun activeStreamModeCauseAssessment( + status: ActiveStreamModeStatus, + recoveryReason: String?, +): ActiveStreamModeCauseAssessment { + val requestedCodec = status.requestedProfile.codec.name + val actualCodec = status.transportProfile.codec.name + val primaryChange = activeStreamModeDisplayChanges(status).firstOrNull() + val saferProfileSummary = primaryChange?.let { + "a safer profile (${it.label} ${it.requestedValue} to ${it.actualValue})" + } ?: "a safer live profile" + val recordedReason = recoveryReason?.trim()?.takeIf(String::isNotEmpty) + val lowerReason = recordedReason?.lowercase(Locale.US).orEmpty() + val summary = when { + "did not negotiate" in lowerReason -> + "WebRTC could not negotiate the requested $requestedCodec codec for this connection, so OpenNOW retried the local video transport with $actualCodec." + "video offer" in lowerReason -> + "The session did not provide a video offer before the startup timeout, so OpenNOW retried the local video transport with $actualCodec." + "no frame rendered" in lowerReason || "first video frame" in lowerReason -> + "Video data arrived, but the device did not render a frame before the recovery timeout. OpenNOW applied $saferProfileSummary to restore video." + "decoder stalled" in lowerReason || "media stall" in lowerReason -> + "The device decoder stopped producing video frames during startup. OpenNOW applied $saferProfileSummary while keeping the same cloud session." + "decoded at" in lowerReason -> + "The decoder produced an unexpected output size for the requested stream mode, so OpenNOW tried the $actualCodec transport profile. Recorded detail: $recordedReason" + status.resolutionSource == StreamResolutionChangeSource.ServerNegotiatedFallback -> + "The cloud server selected ${status.displayedResolution} instead of the requested ${status.requestedResolution}. This was a server/session negotiation decision, not a change to your saved setting." + status.resolutionSource == StreamResolutionChangeSource.ProviderOrGameModeChange -> + "The decoded stream changed to ${status.displayedResolution} after startup without matching the server's initial mode. This points to a game or cloud-provider output-mode change." + recordedReason != null -> + "OpenNOW recorded this recovery reason: $recordedReason" + status.safeVideoRecoveryActive -> + "The original video transport stopped progressing, so OpenNOW adjusted the local profile to keep video playing without ending the cloud session." + else -> + "The live stream profile no longer matched the requested profile." + } + return ActiveStreamModeCauseAssessment(summary) +} + +internal data class ActiveStreamModeDeveloperReport( + val title: String, + val description: String, +) + +internal fun activeStreamModeDeveloperReport( + status: ActiveStreamModeStatus, + recoveryReason: String?, +): ActiveStreamModeDeveloperReport { + val changes = activeStreamModeDisplayChanges(status) + val primary = activeStreamModeNoticeChanges(status).firstOrNull() ?: changes.first() + val cause = activeStreamModeCauseAssessment(status, recoveryReason) + return ActiveStreamModeDeveloperReport( + title = "Automatic stream change: ${primary.label} ${primary.requestedValue} to ${primary.actualValue}", + description = buildString { + appendLine("OpenNOW detected an automatic stream profile change while the session was active.") + appendLine() + appendLine("Cause assessment:") + appendLine(cause.summary) + appendLine() + appendLine("Requested to actual changes:") + changes.forEach { change -> + appendLine("- ${change.label}: ${change.requestedValue} -> ${change.actualValue}") + } + recoveryReason?.trim()?.takeIf(String::isNotEmpty)?.let { reason -> + appendLine() + appendLine("Recorded recovery event:") + appendLine(reason) + } + appendLine() + append("Sent from the in-stream profile-change notice. The user's saved stream settings were not changed.") + }, + ) +} + +@Composable +private fun StreamStatsMetricItems( + streamStats: StreamRuntimeStats, + streamSettings: StreamSettings, + metrics: StreamStatsMetrics, + deviceStatus: CompactStreamDeviceStatus, + serverLocation: String?, + /** Applied to every item; the expanded layout passes a weight so its two columns line up. */ + itemModifier: Modifier = Modifier, +) { + // The target is what the user asked for; streamStats.fps is what is actually arriving. + val targetFps = streamSettings.fps + if (metrics.fps) { + val fps = streamStats.fps + StreamStatsText( + value = "FPS ${fps ?: targetFps}", + modifier = itemModifier, + quality = fps?.let { StreamQuality.frameRate(it.toDouble(), targetFps) }, + contentDescription = stringResource(R.string.stream_stats_cd_fps, fps ?: targetFps), + ) + } + if (metrics.ping) { + val ping = streamStats.pingMs + StreamStatsText( + value = stringResource(R.string.stream_stats_ping, ping?.let { "${it}ms" } ?: NO_STAT_VALUE), + modifier = itemModifier, + quality = ping?.let(StreamQuality::latency), + contentDescription = ping?.let { stringResource(R.string.stream_stats_cd_ping, it) }, + ) + } + if (metrics.latency) { + streamStats.decodeMs?.let { decode -> + StreamStatsText( + value = stringResource(R.string.stream_stats_decode, "%.1f".format(Locale.US, decode)), + modifier = itemModifier, + quality = StreamQuality.decode(decode, targetFps, streamStats.fps?.toDouble()), + contentDescription = stringResource(R.string.stream_stats_cd_decode, "%.1f".format(Locale.US, decode)), + ) + } + streamStats.jitterMs?.let { jitter -> + StreamStatsText( + value = stringResource(R.string.stream_stats_jitter, "%.1f".format(Locale.US, jitter)), + modifier = itemModifier, + quality = StreamQuality.jitter(jitter), + contentDescription = stringResource(R.string.stream_stats_cd_jitter, "%.1f".format(Locale.US, jitter)), + ) + } + } + if (metrics.packetLoss) { + streamStats.packetLossPct?.let { loss -> + // %.2f, matching the session report — %.1f hid the 0.5% boundary the ladder cares about. + val formatted = "%.2f".format(Locale.US, loss) + StreamStatsText( + value = stringResource(R.string.stream_stats_loss, formatted), + modifier = itemModifier, + quality = StreamQuality.packetLoss(loss), + contentDescription = stringResource(R.string.stream_stats_cd_loss, formatted), + ) + } + } + if (metrics.bitrate) { + StreamStatsText( + formatRuntimeBitrateStatus( + actualBitrateKbps = streamStats.bitrateKbps, + requestedMaxBitrateMbps = streamSettings.maxBitrateMbps, + ), + modifier = itemModifier, + ) + } + if (metrics.battery) { + StreamBatteryIndicator(deviceStatus, itemModifier) + } + if (metrics.connection) { + StreamNetworkIndicator(deviceStatus, itemModifier) + } + if (metrics.resolution) { + StreamStatsText( + streamStats.resolution?.let(::formatRuntimeResolution) + ?: formatRuntimeResolution(normalizeStreamResolutionForAspect(streamSettings.resolution, streamSettings.aspectRatio)), + modifier = itemModifier, + ) + } + if (metrics.codec) { + StreamStatsText(streamStats.codec?.takeIf { it.isNotBlank() } ?: streamSettings.codec.name, modifier = itemModifier) + } + if (metrics.location && !serverLocation.isNullOrBlank()) { + val displayName = serverLocation.removePrefix("NPA-").removePrefix("NP-").uppercase() + StreamStatsText(displayName, modifier = itemModifier) + } +} + +/** Shown in place of a metric that has not been measured yet. */ +private const val NO_STAT_VALUE = "--" + +@Composable +private fun StreamStatsText( + value: String, + modifier: Modifier = Modifier, + quality: StreamQualityLevel? = null, + contentDescription: String? = null, +) { + // Colour alone used to carry the warning, which says nothing to a colour-blind user or to + // TalkBack. The quality level is spelled out in the description instead. + val qualityLabel = quality?.let { stringResource(it.labelRes()) } + val describedAs = contentDescription?.let { base -> + if (qualityLabel != null) "$base, $qualityLabel" else base + } + Text( + value, + modifier = if (describedAs != null) { + modifier.semantics { this.contentDescription = describedAs } + } else { + modifier + }, + color = quality?.tint() ?: TextPrimary, + // Tabular figures: without these every value is a different width each tick, so the whole + // row reflows roughly once a second. + style = MaterialTheme.typography.labelSmall.numeric(), + fontWeight = FontWeight.SemiBold, + maxLines = 1, + ) +} + +@StringRes +internal fun StreamQualityLevel.labelRes(): Int = when (this) { + StreamQualityLevel.Good -> R.string.stream_quality_good + StreamQualityLevel.Fair -> R.string.stream_quality_fair + StreamQualityLevel.Poor -> R.string.stream_quality_poor +} + +private data class CompactStreamDeviceStatus( + val batteryPercent: Int? = null, + val batteryCharging: Boolean = false, + val networkKind: AndroidNetworkKind = AndroidNetworkKind.Unknown, + val networkBars: Int? = null, + val cellularGeneration: String? = null, +) + +@Composable +private fun rememberCompactStreamDeviceStatus(): CompactStreamDeviceStatus { + val context = LocalContext.current + val appContext = remember(context) { context.applicationContext } + var status by remember(appContext) { mutableStateOf(readCompactStreamDeviceStatus(appContext)) } + LaunchedEffect(appContext) { + while (true) { + status = readCompactStreamDeviceStatus(appContext) + delay(COMPACT_STREAM_DEVICE_STATUS_REFRESH_MS) + } + } + return status +} + +private fun readCompactStreamDeviceStatus(context: Context): CompactStreamDeviceStatus { + val diagnostics = AndroidRuntimeDiagnostics.snapshot(context) + return CompactStreamDeviceStatus( + batteryPercent = diagnostics.batteryPercent, + batteryCharging = diagnostics.batteryCharging, + networkKind = diagnostics.networkKind, + networkBars = diagnostics.networkSignalBars, + cellularGeneration = diagnostics.cellularGeneration, + ) +} + +@Composable +private fun StreamBatteryIndicator(status: CompactStreamDeviceStatus, modifier: Modifier = Modifier) { + val description = status.batteryPercent?.let { percent -> + "Battery $percent percent${if (status.batteryCharging) ", charging" else ""}" + } ?: "Battery unknown" + val level = streamBatteryLevel(status.batteryPercent) + val batteryIcon = when (level) { + StreamBatteryLevel.Unknown -> Icons.AutoMirrored.Rounded.BatteryUnknown + StreamBatteryLevel.Empty -> Icons.Rounded.Battery0Bar + StreamBatteryLevel.One -> Icons.Rounded.Battery1Bar + StreamBatteryLevel.Two -> Icons.Rounded.Battery2Bar + StreamBatteryLevel.Three -> Icons.Rounded.Battery3Bar + StreamBatteryLevel.Four -> Icons.Rounded.Battery4Bar + StreamBatteryLevel.Five -> Icons.Rounded.Battery5Bar + StreamBatteryLevel.Six -> Icons.Rounded.Battery6Bar + StreamBatteryLevel.Full -> Icons.Rounded.BatteryFull + } + val batteryTint = when { + status.batteryCharging -> Green + status.batteryPercent != null && status.batteryPercent <= 20 -> MaterialTheme.colorScheme.error + else -> TextPrimary + } + Row( + modifier = modifier.semantics { contentDescription = description }, + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(Modifier.size(18.dp)) { + Icon( + imageVector = batteryIcon, + contentDescription = null, + tint = batteryTint, + modifier = Modifier.matchParentSize().graphicsLayer { rotationZ = 90f }, + ) + if (status.batteryCharging) { + Icon( + imageVector = Icons.Rounded.Bolt, + contentDescription = null, + tint = batteryTint, + modifier = Modifier.align(Alignment.Center).size(10.dp), + ) + } + } + Text( + status.batteryPercent?.let { "$it%" } ?: "--%", + color = batteryTint, + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + ) + } +} + +@Composable +private fun StreamNetworkIndicator(status: CompactStreamDeviceStatus, modifier: Modifier = Modifier) { + val bars = status.networkBars?.coerceIn(0, 4) + val label = when (status.networkKind) { + AndroidNetworkKind.Cellular -> status.cellularGeneration ?: status.networkKind.label + AndroidNetworkKind.Ethernet, + AndroidNetworkKind.Other, + AndroidNetworkKind.None, + AndroidNetworkKind.Unknown, + -> status.networkKind.label + AndroidNetworkKind.Wifi -> null + } + val description = "${label ?: status.networkKind.label} signal ${bars?.toString() ?: "unknown"} bars" + Row( + modifier = modifier.semantics { contentDescription = description }, + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (label != null) { + Text( + label, + color = TextPrimary, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + ) + } + if (status.networkKind == AndroidNetworkKind.Wifi) { + Icon( + imageVector = when (bars) { + 4 -> Icons.Rounded.Wifi + 3 -> Icons.Rounded.Wifi + 2 -> Icons.Rounded.Wifi2Bar + 1 -> Icons.Rounded.Wifi1Bar + 0 -> Icons.Rounded.SignalWifi0Bar + else -> Icons.Rounded.WifiOff + }, + contentDescription = null, + tint = TextPrimary, + modifier = Modifier.size(20.dp), + ) + } else if (status.networkKind == AndroidNetworkKind.Cellular || status.networkKind == AndroidNetworkKind.Other || status.networkKind == AndroidNetworkKind.Unknown) { + Icon( + imageVector = when (bars) { + 4 -> Icons.Rounded.SignalCellular4Bar + 3 -> Icons.Rounded.SignalCellularAlt + 2 -> Icons.Rounded.SignalCellularAlt2Bar + 1 -> Icons.Rounded.SignalCellularAlt1Bar + else -> Icons.Rounded.SignalCellular0Bar + }, + contentDescription = null, + tint = TextPrimary, + modifier = Modifier.size(20.dp), + ) + } + } +} + +internal fun formatRuntimeResolution(resolution: String): String { + val parts = resolution.lowercase(Locale.US).split("x", limit = 2) + return if (parts.size == 2 && parts.all { it.trim().isNotBlank() }) { + "${parts[0].trim()}x${parts[1].trim()}" + } else { + resolution + } +} + +internal fun formatRuntimeBitrate(bitrateKbps: Int?): String { + val kbps = bitrateKbps ?: return "--" + return if (kbps >= 1000) { + "${(kbps / 1000.0).let { kotlin.math.round(it * 10.0) / 10.0 }} Mbps" + } else { + "$kbps Kbps" + } +} + +internal fun formatRuntimeBitrateStatus( + actualBitrateKbps: Int?, + requestedMaxBitrateMbps: Int, +): String = "${formatRuntimeBitrate(actualBitrateKbps)} / ${requestedMaxBitrateMbps.coerceAtLeast(1)} Mbps max" + +internal fun shouldHideStreamStatusText(status: String): Boolean = + status.trim().replace('_', ' ').let { + it.equals("Streaming", ignoreCase = true) || + it.equals("ICE CONNECTED", ignoreCase = true) || + it.equals("ICE COMPLETED", ignoreCase = true) + } + +internal data class InitialStreamConnectionStatus( + val phase: String, + val title: String, + val detail: String, +) + +internal fun initialStreamConnectionStatus(nativeState: String): InitialStreamConnectionStatus { + val normalized = nativeState.trim().replace('_', ' ') + return when { + normalized.equals("Preparing", ignoreCase = true) -> InitialStreamConnectionStatus( + phase = "Preparing", + title = "Preparing your stream", + detail = "Getting the secure video connection ready.", + ) + normalized.startsWith("Connecting signaling", ignoreCase = true) -> InitialStreamConnectionStatus( + phase = "Connecting", + title = "Connecting to your game", + detail = "Opening a secure connection to the streaming server.", + ) + normalized.startsWith("Waiting for offer", ignoreCase = true) -> InitialStreamConnectionStatus( + phase = "Waiting for video", + title = "Starting the video stream", + detail = "The server is preparing the first video frame.", + ) + normalized.equals("ICE CHECKING", ignoreCase = true) || + normalized.equals("ICE NEW", ignoreCase = true) -> InitialStreamConnectionStatus( + phase = "Securing connection", + title = "Almost ready", + detail = "Checking the best route for the live video stream.", + ) + normalized.equals("ICE DISCONNECTED", ignoreCase = true) || + normalized.equals("ICE FAILED", ignoreCase = true) -> InitialStreamConnectionStatus( + phase = "Retrying", + title = "Connection interrupted", + detail = "OpenNOW is retrying the initial stream connection.", + ) + normalized.startsWith("Recovering video", ignoreCase = true) -> InitialStreamConnectionStatus( + phase = "Recovering video", + title = "Waiting for a clear frame", + detail = "Requesting a fresh video frame before showing the stream.", + ) + normalized.contains("safe H264 profile", ignoreCase = true) -> InitialStreamConnectionStatus( + phase = "Optimizing video", + title = "Trying a compatible video mode", + detail = "Restarting the initial video connection with safer settings.", + ) + normalized.startsWith("Reconnecting", ignoreCase = true) -> InitialStreamConnectionStatus( + phase = "Retrying connection", + title = "Connecting again", + detail = "The initial connection did not finish, so OpenNOW is retrying it.", + ) + normalized.startsWith("Recovering cloud session", ignoreCase = true) -> InitialStreamConnectionStatus( + phase = "Checking session", + title = "Restoring your game session", + detail = "Checking the existing cloud session before continuing.", + ) + normalized.equals("Streaming", ignoreCase = true) -> InitialStreamConnectionStatus( + phase = "Starting video", + title = "Connection established", + detail = "Waiting for the first video frame to appear.", + ) + else -> InitialStreamConnectionStatus( + phase = "Starting stream", + title = "Preparing your game", + detail = "OpenNOW is waiting for the live video to begin.", + ) + } +} + +@Composable +internal fun InitialStreamConnectionOverlay( + gameTitle: String?, + status: InitialStreamConnectionStatus, + modifier: Modifier = Modifier, +) { + BoxWithConstraints( + modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.18f)) + .padding(24.dp), + contentAlignment = Alignment.Center, + ) { + val cardWidthFraction = if (maxWidth > maxHeight) 0.54f else 0.9f + Surface( + modifier = Modifier + .fillMaxWidth(cardWidthFraction) + .widthIn(max = 560.dp), + shape = RoundedCornerShape(22.dp), + color = Panel.copy(alpha = 0.96f), + contentColor = TextPrimary, + tonalElevation = 10.dp, + ) { + Row( + Modifier.padding(horizontal = 22.dp, vertical = 20.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(18.dp), + ) { + CircularProgressIndicator( + modifier = Modifier + .size(38.dp) + .semantics { contentDescription = status.phase }, + strokeWidth = 3.dp, + color = MaterialTheme.colorScheme.primary, + ) + Column( + Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(5.dp), + ) { + Text( + gameTitle?.takeIf { it.isNotBlank() } ?: "OpenNOW stream", + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + status.title, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + Text( + status.detail, + color = TextMuted, + style = MaterialTheme.typography.bodyMedium, + ) + Text( + status.phase, + color = TextMuted.copy(alpha = 0.78f), + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.SemiBold, + ) + } + } + } + } +} + +@Composable +internal fun StreamExitConfirmation( + gameTitle: String, + onKeepPlaying: () -> Unit, + onExit: () -> Unit, + modifier: Modifier = Modifier, +) { + val keepPlayingFocusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { + delay(80) + runCatching { keepPlayingFocusRequester.requestFocus() } + } + val scrimInteraction = remember { MutableInteractionSource() } + Box( + Modifier + .fillMaxSize() + // The scrim covers everything, so it reports the full screen — otherwise a mis-tap on + // "Exit Stream" also lands in the game underneath. + .streamTouchPassthrough(PASSTHROUGH_ID_EXIT, inflate = 0.dp) + .background(OpenNowPalette.StreamScrim) + // indication = null: a full-screen ripple is wrong, and without its own interaction + // source the scrim competes with the two buttons for D-pad focus. + .clickable( + interactionSource = scrimInteraction, + indication = null, + onClick = onKeepPlaying, + ), + contentAlignment = Alignment.Center, + ) { + Surface( + modifier = modifier + .padding(OpenNowSpacing.xl) + .fillMaxWidth() + // Unbounded fillMaxWidth made this enormous on a tablet or TV. + .widthIn(max = 440.dp), + // Same radius as the controls panel, so the two overlays read as one family. + shape = RoundedCornerShape(OpenNowRadius.lg + 2.dp), + color = OpenNowPalette.PanelOverVideo, + contentColor = TextPrimary, + border = BorderStroke(1.dp, OpenNowPalette.PanelHairline), + tonalElevation = 8.dp, + ) { + Column( + Modifier.padding(OpenNowSpacing.lg + 2.dp), + verticalArrangement = Arrangement.spacedBy(OpenNowSpacing.md), + ) { + Text( + stringResource(R.string.stream_exit_eyebrow), + color = TextMuted, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + ) + Text(stringResource(R.string.stream_exit_title), style = MaterialTheme.typography.titleLarge) + Text(stringResource(R.string.stream_exit_body, gameTitle), color = TextMuted) + Text( + stringResource(R.string.stream_exit_caveat), + color = TextMuted, + style = MaterialTheme.typography.bodySmall, + ) + Row( + horizontalArrangement = Arrangement.spacedBy(OpenNowSpacing.md), + modifier = Modifier.fillMaxWidth(), + ) { + OutlinedButton( + onClick = onKeepPlaying, + modifier = Modifier + .weight(1f) + .focusRequester(keepPlayingFocusRequester), + ) { Text(stringResource(R.string.stream_exit_keep_playing), maxLines = 1) } + Button(onClick = onExit, modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.stream_exit_confirm), maxLines = 1) + } + } + } + } + } +} diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowStreamSurface.kt b/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowStreamSurface.kt new file mode 100644 index 000000000..0be9e00dc --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowStreamSurface.kt @@ -0,0 +1,1840 @@ +package com.opencloudgaming.opennow + +import android.Manifest +import android.app.Activity +import android.content.pm.PackageManager +import android.os.Build +import android.view.KeyEvent +import android.view.MotionEvent +import android.view.PointerIcon +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.core.tween +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +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.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.ime +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.key +import androidx.compose.runtime.setValue +import androidx.compose.runtime.DisposableEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.pointerInteropFilter +import androidx.compose.ui.input.key.key +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.content.ContextCompat +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.util.Locale +import com.opencloudgaming.opennow.ui.theme.OpenNowPalette +import kotlin.math.sqrt + +@Composable +internal fun StreamScreen( + state: OpenNowUiState, + viewModel: OpenNowViewModel, + onMicrophoneCaptureActiveChange: (Boolean) -> Unit, +) { + val context = LocalContext.current + val activity = context as? Activity + val openNowHaptics = LocalOpenNowHaptics.current + val keyboardController = LocalSoftwareKeyboardController.current + val focusManager = LocalFocusManager.current + val density = LocalDensity.current + val audioController = remember(context) { AndroidNerdAudioController(context.applicationContext) } + val gyroscopeAvailable = remember(context) { hasMobileGyroscope(context) } + val session = state.streamSession + val game = state.streamGame + var streamState by remember { mutableStateOf("Preparing") } + var initialVideoFrameRendered by remember(session?.sessionId) { mutableStateOf(false) } + val markInitialVideoFrameRendered by rememberUpdatedState<() -> Unit> { + initialVideoFrameRendered = true + } + var controlsOpen by remember { mutableStateOf(false) } + var exitConfirmOpen by remember { mutableStateOf(false) } + var keyboardOpen by remember { mutableStateOf(false) } + var keyboardValue by remember(session?.sessionId) { mutableStateOf(TextFieldValue()) } + var keyboardSyncedText by remember(session?.sessionId) { mutableStateOf(null) } + var audioMuted by remember { mutableStateOf(false) } + var touchLayoutEditing by remember { mutableStateOf(false) } + var streamGuideOpen by remember(session?.sessionId) { mutableStateOf(false) } + var streamGuideStep by remember(session?.sessionId) { mutableStateOf(StreamGuideStep.OpenControls) } + var statsVisible by remember(state.settings.showStatsOnLaunch) { mutableStateOf(state.settings.showStatsOnLaunch) } + // Bitrate ceiling (kbps) the live session is currently capped at; mirrors client.liveBitrateLimitKbps. + var liveBitrateLimitKbps by remember(session?.sessionId) { mutableStateOf(null) } + var streamStats by remember { mutableStateOf(StreamRuntimeStats()) } + var networkNotice by remember(session?.sessionId) { mutableStateOf(null) } + var networkNoticeSequence by remember(session?.sessionId) { mutableIntStateOf(0) } + val networkWarningGate = remember(session?.sessionId) { StreamNetworkWarningGate() } + var videoTransportFallbackReason by remember { mutableStateOf(null) } + var controllerMouseAssistEnabled by remember(session?.sessionId) { mutableStateOf(false) } + var controllerMouseEmulationEnabled by remember(session?.sessionId) { mutableStateOf(state.settings.controllerMouseEmulation) } + val streamReady = state.isNativeStreamReady() + val tvProfile = state.androidTvProfile + LaunchedEffect(session?.sessionId) { + videoTransportFallbackReason = null + } + val physicalControllerConnected = rememberPhysicalControllerConnected(enabled = streamReady) + val physicalKeyboardMouse = rememberPhysicalKeyboardMouseConnection(enabled = streamReady) + val physicalMouseConnected = physicalKeyboardMouse.mouseConnected + val physicalKeyboardMouseConnected = physicalKeyboardMouse.connected + var showTouchControlsWithPhysicalController by remember(session?.sessionId) { mutableStateOf(false) } + var showTouchControlsWithPhysicalMouse by remember(session?.sessionId) { mutableStateOf(false) } + var preferVirtualController by remember(session?.sessionId) { mutableStateOf(false) } + var physicalControllerPromptOpen by remember(session?.sessionId) { mutableStateOf(false) } + var physicalControllerPromptHandled by remember(session?.sessionId) { mutableStateOf(false) } + var physicalControllerPromptDoNotShowAgain by remember(session?.sessionId) { mutableStateOf(false) } + val touchInputEnabled = !state.androidPictureInPictureActive + val touchControlsSuppressedByPhysicalController = + physicalControllerConnected && + state.settings.androidTouch.enabled && + !showTouchControlsWithPhysicalController + val builtInGameTouchSupported = !tvProfile && game?.let(::catalogClaimsTouchSupport) == true + val nativeTouchAvailable = !tvProfile && shouldUseNativeTouch( + state.settings.androidTouch.effectiveNativeTouchMode(), + game, + state.activeStreamSettings ?: state.settings.stream, + ) + val launchInputMode = state.streamInputModeAtLaunch ?: streamInputModeAtStart( + nativeTouchAvailable = nativeTouchAvailable, + keyboardMouseConnected = physicalKeyboardMouseConnected, + ) + var streamInputMode by remember(session?.sessionId) { mutableStateOf(launchInputMode) } + val nativeTouchProvisionedForSession = launchInputMode == StreamInputMode.NativeTouch + var keyboardMouseBaselineCaptured by remember(session?.sessionId) { mutableStateOf(false) } + var previousKeyboardMouseConnected by remember(session?.sessionId) { + mutableStateOf(physicalKeyboardMouseConnected) + } + var pendingInputModePrompt by remember(session?.sessionId) { + mutableStateOf(null) + } + var inputModePromptOpen by remember(session?.sessionId) { + mutableStateOf(null) + } + // Native game touch and the virtual controller need exclusive ownership of the same fingers. + // Catalog touch remains the default, while a player's in-session controller choice wins. + val nativeTouchActive = !tvProfile && shouldUseNativeTouchForStream( + state.settings.androidTouch.effectiveNativeTouchMode(), + game, + state.activeStreamSettings ?: state.settings.stream, + preferVirtualController = preferVirtualController, + preferKeyboardMouse = streamInputMode == StreamInputMode.KeyboardMouse, + ) + val touchControlsVisible = shouldShowAndroidTouchControls( + tvProfile = tvProfile, + touchInputEnabled = touchInputEnabled, + touchControlsEnabled = state.settings.androidTouch.enabled, + suppressedByPhysicalController = touchControlsSuppressedByPhysicalController, + physicalMouseConnected = physicalMouseConnected, + allowWithPhysicalMouse = showTouchControlsWithPhysicalMouse, + ) && !nativeTouchActive + val touchMouseActive = + streamReady && touchInputEnabled && state.settings.androidTouch.mousePad && !nativeTouchActive + val fallbackSessionStartedAtMs = remember(session?.sessionId) { System.currentTimeMillis() } + val sessionStartedAtMs = session?.timerStartedAtMs ?: fallbackSessionStartedAtMs + var timerNowMs by remember(session?.sessionId) { mutableStateOf(System.currentTimeMillis()) } + val smartSessionLimit = smartSessionLimitFor(state.subscriptionInfo, state.authSession?.user?.membershipTier) + val buttonToneEnabled = state.settings.controllerUiSounds + val stretchToFit = state.settings.stretchStreamToFit + val playButtonTone = { + audioController.playButtonTone(buttonToneEnabled) + } + val launchStreamSettings = state.activeStreamSettings ?: state.settings.stream + // activeStreamSettings tracks the transport profile and can deliberately + // change during safe-codec recovery. Keep the original launch profile so + // requested, server-selected, decoded, and recovery modes remain distinct. + val requestedStreamSettings = remember(session?.sessionId) { + state.settings.stream.eligibleForAndroidLaunch( + subscriptionInfo = state.subscriptionInfo, + fallbackMembershipTier = state.authSession?.user?.membershipTier, + androidTvProfile = tvProfile, + ) + } + val microphoneRequested = launchStreamSettings.microphoneMode != MicrophoneMode.Disabled + val initialMicrophonePermissionGranted = remember(session?.sessionId, microphoneRequested) { + !microphoneRequested || + ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == + PackageManager.PERMISSION_GRANTED + } + var microphonePermissionGranted by remember(session?.sessionId, microphoneRequested) { + mutableStateOf(initialMicrophonePermissionGranted) + } + var microphonePermissionResolved by remember(session?.sessionId, microphoneRequested) { + mutableStateOf(!microphoneRequested || initialMicrophonePermissionGranted) + } + var microphoneEnabled by remember(session?.sessionId) { mutableStateOf(false) } + val microphonePermissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { granted -> + microphonePermissionGranted = granted + microphonePermissionResolved = true + if (!granted) { + Toast.makeText( + context, + context.getString(R.string.settings_microphone_permission_denied), + Toast.LENGTH_LONG, + ).show() + } + } + val streamSettings = launchStreamSettings.copy( + mouseSensitivity = state.settings.stream.mouseSensitivity, + mouseAcceleration = state.settings.stream.mouseAcceleration, + streamSharpeningEnabled = launchStreamSettings.streamSharpeningEnabled && state.settings.stream.streamSharpeningEnabled, + streamSharpeningAmount = state.settings.stream.streamSharpeningAmount, + mouseScrollSensitivity = state.settings.stream.mouseScrollSensitivity, + ) + val streamTransportIdentity = session?.nativeStreamTransportIdentity() + val statsAlignment = when (state.settings.streamStatsPosition) { + StreamStatsPosition.Left -> Alignment.TopStart + StreamStatsPosition.Center -> Alignment.TopCenter + StreamStatsPosition.Right -> Alignment.TopEnd + } + val openStreamKeyboard = { + NativeStreamInputRouter.setStreamUiActive(true) + controlsOpen = false + exitConfirmOpen = false + physicalControllerPromptOpen = false + inputModePromptOpen = null + keyboardOpen = true + } + val dismissStreamGuide = { + streamGuideOpen = false + if (!state.settings.androidStreamGuideDismissed) { + viewModel.updateSettings(state.settings.copy(androidStreamGuideDismissed = true)) + } + } + val openControlsForGuide = { + // Claim UI routing before Compose replaces the launcher with the panel. Waiting for the + // keyed effect below leaves a short window where native touch can forward the activating + // gesture into the game or retarget its trailing event into the newly opened menu. + NativeStreamInputRouter.setStreamUiActive(true) + keyboardOpen = false + exitConfirmOpen = false + physicalControllerPromptOpen = false + inputModePromptOpen = null + if (streamGuideOpen && streamGuideStep == StreamGuideStep.OpenControls) { + streamGuideStep = StreamGuideStep.PressDone + } + controlsOpen = true + } + LaunchedEffect(state.remoteStreamMenuRequestToken) { + if (state.remoteStreamMenuRequestToken > 0 && streamReady) { + openControlsForGuide() + } + } + LaunchedEffect(state.remoteStatsToggleRequestToken) { + if (state.remoteStatsToggleRequestToken > 0 && streamReady) { + statsVisible = !statsVisible + } + } + val streamOverlayOpen = controlsOpen || exitConfirmOpen || keyboardOpen || streamGuideOpen || + physicalControllerPromptOpen || inputModePromptOpen != null || touchLayoutEditing + val streamKeyboardImeVisible = keyboardOpen && WindowInsets.ime.getBottom(density) > 0 + val externalMousePointerCaptureActive = shouldEnableExternalMousePointerCapture( + streamReady = streamReady, + streamOverlayOpen = streamOverlayOpen, + pointerLockEnabled = state.settings.externalMousePointerLock, + ) + val handleStreamBack = { + when { + streamGuideOpen && streamGuideStep == StreamGuideStep.OpenControls -> openControlsForGuide() + streamGuideOpen && streamGuideStep == StreamGuideStep.PressDone && controlsOpen -> { + controlsOpen = false + dismissStreamGuide() + } + streamGuideOpen -> dismissStreamGuide() + exitConfirmOpen -> exitConfirmOpen = false + keyboardOpen -> keyboardOpen = false + inputModePromptOpen != null -> inputModePromptOpen = null + physicalControllerPromptOpen -> physicalControllerPromptOpen = false + controlsOpen -> controlsOpen = false + else -> { + NativeStreamInputRouter.setStreamUiActive(true) + controlsOpen = true + } + } + } + BackHandler(enabled = streamReady) { + handleStreamBack() + } + val client = remember { + NativeStreamClient( + context = context.applicationContext, + onState = { + streamState = it + viewModel.recordNativeStreamState(it) + if (it == "Streaming") viewModel.markStreamConnected() + }, + onError = { + streamState = it + viewModel.markStreamError(it) + }, + onSessionRecoveryRequired = { + streamState = it + viewModel.recoverStreamSession(it) + }, + onFirstVideoFrameRendered = { + markInitialVideoFrameRendered() + }, + onStats = { + streamStats = it + viewModel.updateStreamRuntimeStats(it) + }, + onControllerMouseAssistChanged = { + controllerMouseAssistEnabled = it + }, + ) + } + + DisposableEffect(Unit) { + val decor = activity?.window?.decorView + NativeStreamInputRouter.attach(client) + NativeStreamInputRouter.setAndroidTvProfile(tvProfile) + onDispose { + if (Build.VERSION.SDK_INT >= 26) { + decor?.releasePointerCapture() + } + NativeStreamInputRouter.clearUiTouchPassthroughBounds() + NativeStreamInputRouter.clearStreamPanelTouchPassthroughBounds() + NativeStreamInputRouter.setSystemMenuHandler(null) + NativeStreamInputRouter.setSystemBackHandler(null) + NativeStreamInputRouter.setAndroidTvProfile(false) + NativeStreamInputRouter.setStreamUiActive(false) + NativeStreamInputRouter.setTouchControllerVisible(false) + client.setVirtualControllerVisible(false) + client.setTouchMouseEnabled(false) + NativeStreamInputRouter.detach(client) + client.release() + } + } + DisposableEffect(audioController) { + onDispose { + audioController.release() + } + } + + LaunchedEffect(streamReady, streamOverlayOpen, streamGuideOpen, streamGuideStep, touchLayoutEditing) { + NativeStreamInputRouter.setStreamUiActive(streamReady && streamOverlayOpen) + NativeStreamInputRouter.setSystemMenuHandler { + openControlsForGuide() + } + NativeStreamInputRouter.setSystemBackHandler { + handleStreamBack() + } + } + + LaunchedEffect(client, tvProfile) { + client.updateAndroidTvProfile(tvProfile) + client.updateControllerMouseAssistAutoArm(tvProfile) + } + + // StreamScreen owns the effective controller/mouse modes even when TouchOverlay is absent. + // Re-sync on every session so closeTransport(clearInputState=false) cannot carry stale virtual + // controller presence into a Finger Mouse-only session. + LaunchedEffect(client, session?.sessionId, touchControlsVisible) { + client.setVirtualControllerVisible(touchControlsVisible) + NativeStreamInputRouter.setTouchControllerVisible(touchControlsVisible) + } + + LaunchedEffect(streamReady, session?.sessionId, controlsOpen) { + while (streamReady && controlsOpen) { + liveBitrateLimitKbps = client.liveBitrateLimitKbps + delay(1000L) + } + } + + LaunchedEffect(streamReady, state.settings.androidStreamGuideDismissed, session?.sessionId) { + val shouldOpenGuide = streamReady && !state.settings.androidStreamGuideDismissed + streamGuideOpen = shouldOpenGuide + if (shouldOpenGuide) { + streamGuideStep = StreamGuideStep.OpenControls + } + } + + LaunchedEffect(controlsOpen, streamGuideOpen, streamGuideStep) { + if (controlsOpen && streamGuideOpen && streamGuideStep == StreamGuideStep.OpenControls) { + streamGuideStep = StreamGuideStep.PressDone + } + } + + LaunchedEffect( + physicalControllerConnected, + touchControlsSuppressedByPhysicalController, + streamGuideOpen, + controlsOpen, + exitConfirmOpen, + keyboardOpen, + inputModePromptOpen, + pendingInputModePrompt, + ) { + if (!physicalControllerConnected) { + showTouchControlsWithPhysicalController = false + physicalControllerPromptOpen = false + return@LaunchedEffect + } + if ( + !tvProfile && + touchControlsSuppressedByPhysicalController && + !state.settings.androidPhysicalControllerPromptDismissed && + !physicalControllerPromptHandled && + !streamGuideOpen && + !controlsOpen && + !exitConfirmOpen && + !keyboardOpen && + inputModePromptOpen == null && + pendingInputModePrompt == null + ) { + physicalControllerPromptOpen = true + } + } + + LaunchedEffect(streamReady, physicalKeyboardMouseConnected, session?.sessionId) { + if (!streamReady) return@LaunchedEffect + if (!keyboardMouseBaselineCaptured) { + keyboardMouseBaselineCaptured = true + previousKeyboardMouseConnected = physicalKeyboardMouseConnected + if (physicalKeyboardMouseConnected) { + streamInputMode = StreamInputMode.KeyboardMouse + } + return@LaunchedEffect + } + if (physicalKeyboardMouseConnected == previousKeyboardMouseConnected) { + return@LaunchedEffect + } + previousKeyboardMouseConnected = physicalKeyboardMouseConnected + inputModePromptOpen = null + pendingInputModePrompt = streamInputModePromptForConnectionChange( + currentMode = streamInputMode, + keyboardMouseConnected = physicalKeyboardMouseConnected, + nativeTouchProvisionedForSession = nativeTouchProvisionedForSession, + ) + } + + LaunchedEffect(physicalMouseConnected) { + if (!physicalMouseConnected) { + showTouchControlsWithPhysicalMouse = false + } + } + + LaunchedEffect( + pendingInputModePrompt, + streamGuideOpen, + controlsOpen, + exitConfirmOpen, + keyboardOpen, + physicalControllerPromptOpen, + touchLayoutEditing, + ) { + val prompt = pendingInputModePrompt ?: return@LaunchedEffect + if ( + !streamGuideOpen && + !controlsOpen && + !exitConfirmOpen && + !keyboardOpen && + !physicalControllerPromptOpen && + !touchLayoutEditing + ) { + inputModePromptOpen = prompt + pendingInputModePrompt = null + } + } + + LaunchedEffect(streamReady, state.settings.sessionCounterEnabled, session?.sessionId, sessionStartedAtMs, smartSessionLimit) { + var previousRemainingSeconds: Int? = null + val sentSessionWarnings = mutableSetOf() + while (streamReady && state.settings.sessionCounterEnabled) { + val nowMs = System.currentTimeMillis() + timerNowMs = nowMs + val remainingSeconds = sessionRemainingSeconds(smartSessionLimit, sessionStartedAtMs, nowMs) + sessionWarningThresholdCrossed(previousRemainingSeconds, remainingSeconds)?.let { thresholdSeconds -> + if (sentSessionWarnings.add(thresholdSeconds)) { + Toast.makeText( + context, + "${formatSessionWarningThreshold(thresholdSeconds)} left in this session", + Toast.LENGTH_SHORT, + ).show() + } + } + previousRemainingSeconds = remainingSeconds + delay(1000L) + } + } + + // Also gated on nativeTouchActive: dispatchTouch would take the native branch first anyway, but + // leaving two input modes both flagged "enabled" is how they end up fighting later. + LaunchedEffect(streamReady, touchInputEnabled, state.settings.androidTouch.mousePad, nativeTouchActive) { + NativeStreamInputRouter.setTouchMouseEnabled(touchMouseActive) + client.setTouchMouseEnabled(touchMouseActive) + } + // Gated on touchInputEnabled as well as the setting: finger touches already stop at + // setTouchMouseEnabled during PiP, but external mouse and touchpad events reach direct click + // through their own path and would otherwise be mapped against the tiny PiP window. + LaunchedEffect(state.settings.androidTouch.mouseDirectClick, touchInputEnabled) { + NativeStreamInputRouter.setMouseDirectClick( + state.settings.androidTouch.mouseDirectClick && touchInputEnabled, + ) + } + LaunchedEffect( + streamReady, + touchInputEnabled, + nativeTouchActive, + physicalMouseConnected, + state.streamGame?.id, + ) { + val activeGame = state.streamGame + val enabled = streamReady && touchInputEnabled && nativeTouchActive + NativeStreamInputRouter.setNativeTouchEnabled(enabled) + // Records what the catalog says about this game even when we leave touch off, so the fixed + // list in NativeTouchGames.kt can be filled in — and eventually retired — from real data. + if (activeGame != null && streamReady) { + NativeInputDiagnostics.add( + nativeTouchDiagnostics( + game = activeGame, + enabled = enabled, + physicalMouseConnected = physicalMouseConnected, + ), + ) + } + } + + LaunchedEffect(streamReady, touchInputEnabled, state.settings.androidTouch.mousePad, nativeTouchActive, controlsOpen, exitConfirmOpen, keyboardOpen, streamGuideOpen, touchControlsVisible) { + NativeStreamInputRouter.setCaptureAllTouch( + streamReady && + touchInputEnabled && + (state.settings.androidTouch.mousePad || nativeTouchActive) && + !controlsOpen && + !exitConfirmOpen && + !keyboardOpen && + !streamGuideOpen, + ) + } + DisposableEffect(Unit) { + onDispose { + NativeStreamInputRouter.setCaptureAllTouch(false) + } + } + + LaunchedEffect(streamReady, microphoneRequested, microphonePermissionResolved, session?.sessionId) { + if (streamReady && microphoneRequested && !microphonePermissionResolved) { + microphonePermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) + } + } + LaunchedEffect( + streamTransportIdentity, + streamReady, + microphonePermissionGranted, + microphonePermissionResolved, + ) { + if (session != null && streamReady && microphonePermissionResolved) { + val captureMicrophone = shouldCaptureMicrophone( + mode = launchStreamSettings.microphoneMode, + permissionGranted = microphonePermissionGranted, + ) + // Promote the already-running stream service while the activity is visible and before + // WebRTC opens AudioRecord. Android 14+ rejects that promotion from the background. + onMicrophoneCaptureActiveChange(captureMicrophone) + microphoneEnabled = captureMicrophone + client.setMicrophoneEnabled(captureMicrophone) + client.setVirtualControllerVisible(touchControlsVisible) + client.setTouchMouseEnabled(touchMouseActive) + client.start( + session, + launchStreamSettings.copy( + microphoneMode = if (captureMicrophone) { + launchStreamSettings.microphoneMode + } else { + MicrophoneMode.Disabled + }, + ), + ) + } + } + LaunchedEffect(client, controllerMouseEmulationEnabled, streamReady) { + if (streamReady) { + client.setControllerMouseEmulationActive(controllerMouseEmulationEnabled) + } + } + val activeStreamMode = activeStreamModeStatus( + requestedSettings = requestedStreamSettings, + transportSettings = launchStreamSettings, + decodedResolution = streamStats.resolution, + serverNegotiatedResolution = session?.monitorSnapshot?.returnedResolution + ?: session?.negotiatedStreamProfile?.resolution, + serverFinalSelectedResolution = session?.monitorSnapshot?.finalSelectedResolution, + ) + LaunchedEffect( + session?.sessionId, + streamReady, + activeStreamMode, + ) { + if (streamReady && activeStreamMode != null) { + viewModel.recordActiveStreamMode(activeStreamMode) + } + } + LaunchedEffect(streamReady, streamStats) { + val candidate = if (streamReady) { + streamNetworkWarning(streamStats) + } else { + null + } + networkWarningGate.update(candidate)?.let { warning -> + networkNotice = warning + networkNoticeSequence += 1 + } + } + LaunchedEffect(networkNoticeSequence) { + if (networkNoticeSequence <= 0) return@LaunchedEffect + val displayedSequence = networkNoticeSequence + delay(STREAM_NETWORK_NOTICE_DURATION_MS) + if (networkNoticeSequence == displayedSequence) networkNotice = null + } + + Box(Modifier.fillMaxSize().background(Color.Black)) { + if (state.activeSessionDecision != null) { + ActiveSessionDecisionScreen( + state = state, + onResumeSession = viewModel::resumeActiveSession, + onReplaceSession = viewModel::terminateActiveSessionAndStartNew, + onCancel = viewModel::dismissActiveSessionDecision, + ) + } else if (session == null && state.streamStatus != "idle") { + QueueLoadingScreen(state, viewModel) + } else if (session == null) { + NoActiveStreamScreen( + canResumeSession = state.activeSession != null, + canEndSession = state.authSession != null, + onBack = { viewModel.setPage(AppPage.Home) }, + onResumeSession = viewModel::resumeActiveSession, + onEndSession = viewModel::stopStream, + ) + } else if (!streamReady) { + QueueLoadingScreen(state, viewModel) + } else { + StreamVideoSurface( + client = client, + settings = streamSettings, + viewportSettings = requestedStreamSettings, + decodedResolution = streamStats.resolution, + serverNegotiatedResolution = session.monitorSnapshot?.returnedResolution + ?: session.negotiatedStreamProfile?.resolution, + serverFinalSelectedResolution = session.monitorSnapshot?.finalSelectedResolution, + androidTouch = state.settings.androidTouch, + hideExternalMousePointer = externalMousePointerCaptureActive, + touchMouseEnabled = + touchMouseActive, + pinchZoomEnabled = streamPinchZoomEnabled( + touchMouseEnabled = + touchInputEnabled && state.settings.androidTouch.mousePad && !nativeTouchActive, + touchControllerVisible = touchControlsVisible, + ), + externalMouseRoot = activity?.window?.decorView, + onMouseCaptureInput = { (activity as? MainActivity)?.enforceStreamSystemUiFromInput() }, + stretchToFit = stretchToFit, + vibrationEnabled = state.settings.vibrationEnabled, + hapticsOutput = state.settings.hapticsOutput, + ) + if (statsVisible) { + StreamStatsPill( + streamStats = streamStats, + streamSettings = requestedStreamSettings, + style = state.settings.streamStatsStyle, + metrics = state.settings.streamStatsMetrics, + serverLocation = session.reportedServerZone(), + keyboardButtonEnabled = !state.settings.hideStreamButtons, + onKeyboardOpen = openStreamKeyboard, + modifier = Modifier.align(statsAlignment), + ) + } + MobileGyroscopeAim( + client = client, + settings = state.settings.androidTouch, + active = streamReady && touchControlsVisible && !streamOverlayOpen, + ) + if (networkNotice != null || activeStreamMode != null) { + Column( + modifier = Modifier + .align(Alignment.TopCenter) + .padding(top = if (statsVisible && statsAlignment == Alignment.TopCenter) 48.dp else 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + networkNotice?.let { StreamNetworkQualityNotice(it) } + activeStreamMode?.let { status -> + ActiveStreamModePill( + status = status, + recoveryReason = videoTransportFallbackReason, + bugReportSubmission = state.bugReportSubmission, + bugReportVersionCheck = state.bugReportVersionCheck, + update = state.androidUpdate, + onBugReportSubmit = viewModel::submitBugReport, + onBugReportReset = viewModel::resetBugReportSubmission, + onBugReportVersionCheck = viewModel::verifyBugReportVersion, + onOpenUpdate = viewModel::performAndroidUpdatePrimaryAction, + ) + } + } + } + if (touchControlsVisible) { + TouchOverlay( + client = client, + touch = state.settings.androidTouch.copy(enabled = true), + // FLAG_IGNORE_GLOBAL_SETTING stopped working in Android 13, so the on-screen + // buttons went silent on any device with system touch feedback off. Drive the + // vibrator directly instead — see OpenNowHaptics. + onButtonTone = { openNowHaptics?.play(HapticCue.Activate) }, + layoutEditing = touchLayoutEditing, + onSaveAllOffsets = { allOffsets -> + var touch = state.settings.androidTouch + allOffsets.forEach { (key, offset) -> + touch = touch.withOffset(key, offset.x, offset.y) + } + viewModel.updateSettings(state.settings.copy(androidTouch = touch)) + }, + modifier = Modifier.align(Alignment.BottomCenter), + ) + } + AnimatedVisibility( + visible = !initialVideoFrameRendered, + enter = fadeIn(animationSpec = tween(180)) + scaleIn(initialScale = 0.96f), + exit = fadeOut(animationSpec = tween(180)) + scaleOut(targetScale = 0.98f), + modifier = Modifier.align(Alignment.Center), + ) { + InitialStreamConnectionOverlay( + gameTitle = game?.title, + status = initialStreamConnectionStatus(streamState), + ) + } + if (touchLayoutEditing) { + val doneButtonTone = playButtonTone + Box( + Modifier + .align(Alignment.Center), + contentAlignment = Alignment.Center, + ) { + Button( + onClick = { + doneButtonTone() + touchLayoutEditing = false + }, + shape = RoundedCornerShape(999.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + contentPadding = PaddingValues(horizontal = 28.dp, vertical = 14.dp), + elevation = ButtonDefaults.buttonElevation(defaultElevation = 8.dp), + modifier = Modifier.pointerInteropFilter { event -> + if (event.action == MotionEvent.ACTION_UP || + event.action == MotionEvent.ACTION_DOWN + ) { + false // let Button's click handling still work + } else { + false + } + }, + ) { + Icon( + Icons.Rounded.Check, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(8.dp)) + Text( + stringResource(R.string.stream_panel_done), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + ) + } + } + } + if (streamGuideOpen) { + AnimatedLaunchOverlay(Modifier.align(Alignment.Center)) { + StreamFirstLaunchGuide( + step = streamGuideStep, + controlsOpen = controlsOpen, + touchControlsEnabled = touchControlsVisible, + onOpenControls = { + playButtonTone() + openControlsForGuide() + }, + onSkip = { + playButtonTone() + controlsOpen = false + dismissStreamGuide() + }, + ) + } + } + if (physicalControllerPromptOpen) { + PhysicalControllerTouchControlsDialog( + doNotShowAgain = physicalControllerPromptDoNotShowAgain, + onDoNotShowAgainChange = { physicalControllerPromptDoNotShowAgain = it }, + onOk = { + physicalControllerPromptHandled = true + physicalControllerPromptOpen = false + showTouchControlsWithPhysicalController = false + if (physicalControllerPromptDoNotShowAgain) { + viewModel.updateSettings( + state.settings.copy(androidPhysicalControllerPromptDismissed = true), + ) + } + }, + onUndo = { + physicalControllerPromptHandled = true + physicalControllerPromptOpen = false + showTouchControlsWithPhysicalController = true + if (physicalMouseConnected) { + showTouchControlsWithPhysicalMouse = true + } + if (physicalControllerPromptDoNotShowAgain) { + viewModel.updateSettings( + state.settings.copy(androidPhysicalControllerPromptDismissed = true), + ) + } + }, + ) + } + inputModePromptOpen?.let { prompt -> + StreamInputModeSwitchDialog( + prompt = prompt, + onStay = { inputModePromptOpen = null }, + onSwitch = { + streamInputMode = when (prompt) { + StreamInputModePrompt.SwitchToKeyboardMouse -> StreamInputMode.KeyboardMouse + StreamInputModePrompt.SwitchToNativeTouch -> StreamInputMode.NativeTouch + } + inputModePromptOpen = null + }, + ) + } + // Keep the decoded frame untouched when the Quick Menu is open. A full-screen + // translucent wash over SurfaceViewRenderer looked like a stuck grey compositor + // layer on physical devices; the panel has its own opaque fill and border. + AnimatedVisibility( + visible = controlsOpen, + enter = fadeIn() + slideInVertically(initialOffsetY = { it / 4 }) + scaleIn(initialScale = 0.96f), + exit = fadeOut() + slideOutVertically(targetOffsetY = { it / 4 }) + scaleOut(targetScale = 0.96f), + modifier = Modifier.align(Alignment.BottomEnd), + ) { + StreamControlsPanel( + gameTitle = game?.title ?: stringResource(R.string.settings_section_stream), + status = (state.queuePosition?.let { "Queue $it" } ?: streamState).takeUnless(::shouldHideStreamStatusText), + settings = state.settings, + tvProfile = tvProfile, + touchControlsVisible = touchControlsVisible, + builtInGameTouchSupported = builtInGameTouchSupported, + nativeTouchActive = nativeTouchActive, + gyroscopeAvailable = gyroscopeAvailable, + controllerMouseAssistEnabled = controllerMouseAssistEnabled, + controllerMouseEmulationEnabled = controllerMouseEmulationEnabled, + showSessionTimer = state.settings.sessionCounterEnabled, + sessionTimerLimit = smartSessionLimit, + sessionStartedAtMs = sessionStartedAtMs, + sessionNowMs = timerNowMs, + audioMuted = audioMuted, + microphoneRequested = microphoneRequested, + microphonePermissionGranted = microphonePermissionGranted, + microphoneEnabled = microphoneEnabled, + statsVisible = statsVisible, + liveBitrateLimitKbps = liveBitrateLimitKbps, + touchLayoutEditing = touchLayoutEditing, + bugReportSubmission = state.bugReportSubmission, + bugReportVersionCheck = state.bugReportVersionCheck, + update = state.androidUpdate, + bugReportPreflightProvider = { + buildBugReportPreflightDeck( + BugReportPreflightEvidence( + requestedSettings = requestedStreamSettings, + recommendedSettings = state.recommendedStreamSettings, + nativeLowLatencyDecoderEnabled = state.settings.nativeLowLatencyDecoder, + runtimeStats = streamStats, + runtimeDiagnostics = AndroidRuntimeDiagnostics.snapshot(context), + deliveredResolution = activeStreamMode?.displayedResolution + ?: session.monitorSnapshot?.returnedResolution + ?: streamStats.resolution, + deliveredCodec = activeStreamMode?.transportCodec?.name + ?: streamStats.codec, + codecReport = state.codecReport, + androidTvProfile = tvProfile, + serverZone = session.reportedServerZone(), + manuallySelectedServer = state.manuallySelectedServerForReport, + inputDiagnostics = NativeInputDiagnostics.snapshot(), + ), + ) + }, + onAudioToggle = { + audioMuted = !audioMuted + client.setAudioMuted(audioMuted) + }, + onMicrophoneToggle = { + if (!microphonePermissionGranted) { + microphonePermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) + } else { + microphoneEnabled = !microphoneEnabled + client.setMicrophoneEnabled(microphoneEnabled) + } + }, + onStatsToggle = { + statsVisible = !statsVisible + viewModel.updateSettings(state.settings.copy(showStatsOnLaunch = statsVisible)) + }, + onStatsStyleCycle = { + viewModel.updateSettings(state.settings.copy(streamStatsStyle = state.settings.streamStatsStyle.next())) + }, + onStatsPositionCycle = { + viewModel.updateSettings(state.settings.copy(streamStatsPosition = state.settings.streamStatsPosition.next())) + }, + onStatsMetricsChange = { metrics -> + viewModel.updateSettings(state.settings.copy(streamStatsMetrics = metrics)) + }, + onKeyboardButtonToggle = { + viewModel.updateSettings( + state.settings.copy(hideStreamButtons = !state.settings.hideStreamButtons), + ) + }, + onVibrationToggle = { + viewModel.updateSettings(state.settings.copy(vibrationEnabled = !state.settings.vibrationEnabled)) + }, + onTouchLayoutEditingToggle = { + touchLayoutEditing = !touchLayoutEditing + }, + onKeyboardOpen = openStreamKeyboard, + onEsc = { client.sendKeyCode(KeyEvent.KEYCODE_ESCAPE) }, + onEnter = { client.sendKeyCode(KeyEvent.KEYCODE_ENTER) }, + onBackspace = { client.sendKeyCode(KeyEvent.KEYCODE_DEL) }, + onSteamMenuOpen = { + controlsOpen = false + client.openSteamMenu() + }, + onControllerMouseAssistToggle = { + client.setControllerMouseAssistEnabled(!controllerMouseAssistEnabled) + }, + onControllerMouseEmulationToggle = { + val newState = !controllerMouseEmulationEnabled + controllerMouseEmulationEnabled = newState + client.setControllerMouseEmulationActive(newState) + }, + onExit = { + controlsOpen = false + exitConfirmOpen = true + }, + onTouchControlsToggle = { + when { + nativeTouchActive -> { + preferVirtualController = true + if (physicalControllerConnected) { + showTouchControlsWithPhysicalController = true + } + if (physicalMouseConnected) { + showTouchControlsWithPhysicalMouse = true + } + if (!state.settings.androidTouch.enabled) { + viewModel.updateSettings( + state.settings.copy( + androidTouch = state.settings.androidTouch.copy(enabled = true), + ), + ) + } + } + preferVirtualController && nativeTouchAvailable && touchControlsVisible -> { + // Turning the overlay back off restores the game's built-in touch + // without changing the player's persisted controller preference. + preferVirtualController = false + } + physicalControllerConnected && !touchControlsVisible -> { + showTouchControlsWithPhysicalController = true + if (physicalMouseConnected) { + showTouchControlsWithPhysicalMouse = true + } + if (!state.settings.androidTouch.enabled) { + viewModel.updateSettings( + state.settings.copy( + androidTouch = state.settings.androidTouch.copy(enabled = true), + ), + ) + } + } + physicalMouseConnected && !touchControlsVisible -> { + showTouchControlsWithPhysicalMouse = true + if (!state.settings.androidTouch.enabled) { + viewModel.updateSettings( + state.settings.copy( + androidTouch = state.settings.androidTouch.copy(enabled = true), + ), + ) + } + } + else -> { + viewModel.updateSettings( + state.settings.copy( + androidTouch = state.settings.androidTouch.copy( + enabled = !state.settings.androidTouch.enabled, + ), + ), + ) + } + } + }, + onMousePadToggle = { + viewModel.updateSettings( + state.settings.copy( + androidTouch = state.settings.androidTouch.copy(mousePad = !state.settings.androidTouch.mousePad), + ), + ) + }, + onMouseDirectClickToggle = { + viewModel.updateSettings( + state.settings.copy( + androidTouch = state.settings.androidTouch.copy(mouseDirectClick = !state.settings.androidTouch.mouseDirectClick), + ), + ) + }, + onToggleTouchControllerStyle = { + viewModel.updateSettings( + state.settings.copy( + androidTouch = state.settings.androidTouch.copy( + touchControllerStyle = nextTouchControllerStyle( + state.settings.androidTouch.touchControllerStyle, + ), + ), + ), + ) + }, + onTouchButtonLabelsToggle = { + viewModel.updateSettings( + state.settings.copy( + androidTouch = state.settings.androidTouch.copy( + touchButtonLabels = !state.settings.androidTouch.touchButtonLabels, + ), + ), + ) + }, + onJoystickModeToggle = { + val nextMode = if (state.settings.androidTouch.joystickMode == TouchJoystickMode.Fixed) { + TouchJoystickMode.Dynamic + } else { + TouchJoystickMode.Fixed + } + viewModel.updateSettings( + state.settings.copy( + androidTouch = state.settings.androidTouch.copy(joystickMode = nextMode), + ), + ) + }, + onTouchAimModeToggle = { + val nextMode = if (state.settings.androidTouch.aimMode == TouchAimMode.LockJoystick) { + TouchAimMode.LockZone + } else { + TouchAimMode.LockJoystick + } + viewModel.updateSettings( + state.settings.copy( + androidTouch = state.settings.androidTouch.copy(aimMode = nextMode), + ), + ) + }, + onJoystickDeadZoneChange = { value -> + viewModel.updateSettings( + state.settings.copy( + androidTouch = state.settings.androidTouch.copy(joystickDeadZone = value), + ), + ) + }, + onSharpeningToggle = { + viewModel.updateStreamSettings { settings -> + settings.copy(streamSharpeningEnabled = !settings.streamSharpeningEnabled) + } + }, + onSharpeningAmountChange = { value -> + viewModel.updateStreamSettings { settings -> + settings.copy(streamSharpeningAmount = value) + } + }, + onStretchToFitToggle = { + val next = !state.settings.stretchStreamToFit + viewModel.updateSettings( + state.settings.copy( + legacyCropStreamToFill = false, + stretchStreamToFit = next, + ), + ) + }, + onMaxBitrateChange = { value -> + viewModel.updateStreamSettings { s -> s.copy(maxBitrateMbps = value) } + // Preserve the active WSS/ICE transport. The new b=AS ceiling is queued for + // the next legitimate offer because replacing a healthy transport here can + // strand the allocated cloud session on a stale signaling endpoint. + client.updateBitrateLimit(value * 1000) + // Optimistic indicator for the requested next-offer ceiling. + liveBitrateLimitKbps = value * 1000 + }, + onTouchScaleChange = { value -> + viewModel.updateSettings(state.settings.copy(androidTouch = state.settings.androidTouch.copy(scale = value))) + }, + onButtonScaleChange = { value -> + viewModel.updateSettings(state.settings.copy(androidTouch = state.settings.androidTouch.copy(buttonScale = value))) + }, + onStickScaleChange = { value -> + viewModel.updateSettings(state.settings.copy(androidTouch = state.settings.androidTouch.copy(stickScale = value))) + }, + onOpacityChange = { value -> + viewModel.updateSettings(state.settings.copy(androidTouch = state.settings.androidTouch.copy(opacity = value))) + }, + onMouseSensitivityChange = { value -> + viewModel.updateStreamSettings { s -> s.copy(mouseSensitivity = value) } + }, + onMouseScrollSensitivityChange = { value -> + viewModel.updateStreamSettings { s -> s.copy(mouseScrollSensitivity = value) } + }, + onNativeTouchScrollScaleChange = { value -> + viewModel.updateSettings(state.settings.copy(androidTouch = state.settings.androidTouch.copy(nativeTouchScrollScale = value))) + }, + onNativeTouchJitterThresholdChange = { value -> + viewModel.updateSettings(state.settings.copy(androidTouch = state.settings.androidTouch.copy(nativeTouchJitterThresholdDp = value))) + }, + onTouchEdgePaddingChange = { value -> + viewModel.updateSettings(state.settings.copy(androidTouch = state.settings.androidTouch.copy(edgePaddingDp = value))) + }, + onTouchBottomPaddingChange = { value -> + viewModel.updateSettings(state.settings.copy(androidTouch = state.settings.androidTouch.copy(bottomPaddingDp = value))) + }, + onTouchLeftOffsetChange = { value -> + viewModel.updateSettings(state.settings.copy(androidTouch = state.settings.androidTouch.copy(leftOffsetYDp = value))) + }, + onTouchRightOffsetChange = { value -> + viewModel.updateSettings(state.settings.copy(androidTouch = state.settings.androidTouch.copy(rightOffsetYDp = value))) + }, + onTouchLayoutReset = { + viewModel.updateSettings( + state.settings.copy( + androidTouch = state.settings.androidTouch.withResetOffsets() + ) + ) + }, + onTouchSettingsChange = { touch -> + viewModel.updateSettings(state.settings.copy(androidTouch = touch)) + }, + onBugReportSubmit = { title, description, knownIssueOverrideKey -> + viewModel.submitBugReport(title, description, knownIssueOverrideKey) + }, + onBugReportReset = viewModel::resetBugReportSubmission, + onBugReportVersionCheck = viewModel::verifyBugReportVersion, + onOpenUpdate = viewModel::performAndroidUpdatePrimaryAction, + onButtonTone = playButtonTone, + highlightDone = streamGuideOpen && streamGuideStep == StreamGuideStep.PressDone, + onClose = { + controlsOpen = false + if (streamGuideOpen && streamGuideStep == StreamGuideStep.PressDone) { + dismissStreamGuide() + } + }, + ) + } + if (keyboardOpen) { + if (streamKeyboardImeVisible) { + Box( + Modifier + .matchParentSize() + .pointerInput(Unit) { + detectTapGestures { + keyboardController?.hide() + focusManager.clearFocus(force = true) + } + }, + ) + } + AnimatedLaunchOverlay( + modifier = Modifier + .align(Alignment.BottomCenter) + .imePadding(), + ) { + StreamKeyboardBar( + value = keyboardValue, + clearConfirmationEnabled = !state.settings.streamKeyboardClearConfirmationDisabled, + onValueChange = { next -> + if (next.text.length <= MAX_STREAM_KEYBOARD_TEXT_LENGTH) { + client.syncText(keyboardSyncedText, next.text) + keyboardValue = next + keyboardSyncedText = next.text + } + }, + onClear = { + client.clearText() + keyboardValue = TextFieldValue() + keyboardSyncedText = "" + }, + onDisableClearConfirmation = { + viewModel.updateSettings( + state.settings.copy(streamKeyboardClearConfirmationDisabled = true), + ) + }, + onEnter = { + client.sendTextControlKey(KeyEvent.KEYCODE_ENTER) + if (keyboardSyncedText != null) { + keyboardValue = TextFieldValue() + keyboardSyncedText = null + } + }, + onEsc = { client.sendKeyCode(KeyEvent.KEYCODE_ESCAPE) }, + onDone = { keyboardOpen = false }, + ) + } + } + if (exitConfirmOpen) { + AnimatedLaunchOverlay(Modifier.align(Alignment.Center)) { + StreamExitConfirmation( + gameTitle = game?.title ?: "this game", + onKeepPlaying = { exitConfirmOpen = false }, + onExit = { + exitConfirmOpen = false + viewModel.stopStream() + }, + ) + } + } + } + } +} + +internal fun shouldShowAndroidTouchControls( + tvProfile: Boolean, + touchInputEnabled: Boolean, + touchControlsEnabled: Boolean, + suppressedByPhysicalController: Boolean, + physicalMouseConnected: Boolean = false, + allowWithPhysicalMouse: Boolean = false, +): Boolean = + !tvProfile && + touchInputEnabled && + touchControlsEnabled && + !suppressedByPhysicalController && + (!physicalMouseConnected || allowWithPhysicalMouse) + +private data class SessionTimerDisplay( + val label: String, + val value: String, + val detail: String, + val progress: Float, + val warning: Boolean, +) + +internal enum class StreamGuideStep { + OpenControls, + PressDone, +} + +private fun sessionTimerDisplay(limit: SmartSessionLimit, startedAtMs: Long, nowMs: Long): SessionTimerDisplay { + val elapsedSeconds = sessionElapsedSeconds(startedAtMs, nowMs) + val limitSeconds = limit.limitHours * 60 * 60 + val remainingSeconds = sessionRemainingSeconds(limit, startedAtMs, nowMs) + val warning = remainingSeconds <= 10 * 60 + val progress = if (limitSeconds > 0) (elapsedSeconds.toFloat() / limitSeconds).coerceIn(0f, 1f) else 0f + return when (limit.mode) { + SessionTimerMode.Countdown -> SessionTimerDisplay( + label = "${limit.tierLabel} countdown", + value = formatSessionTimerDuration(remainingSeconds), + detail = "${limit.limitHours}h session limit", + progress = progress, + warning = warning, + ) + SessionTimerMode.Stopwatch -> SessionTimerDisplay( + label = "${limit.tierLabel} session", + value = "${formatSessionTimerDuration(elapsedSeconds)} / ${limit.limitHours}h", + detail = "Session stopwatch", + progress = progress, + warning = warning, + ) + } +} + +@Composable +internal fun StreamSessionTimerMenuRow( + limit: SmartSessionLimit, + startedAtMs: Long, + nowMs: Long, + modifier: Modifier = Modifier, +) { + val display = sessionTimerDisplay(limit, startedAtMs, nowMs) + val progressColor = when { + display.warning -> OpenNowPalette.StatusNotice + else -> MaterialTheme.colorScheme.primary + } + Column( + modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(Color.White.copy(alpha = 0.06f)) + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(7.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Column(Modifier.weight(1f)) { + Text(stringResource(R.string.session_timer), fontWeight = FontWeight.SemiBold) + Text(display.label, color = TextMuted, style = MaterialTheme.typography.labelSmall) + } + Text( + display.value, + color = if (display.warning) OpenNowPalette.StatusNotice else TextPrimary, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Box( + Modifier + .fillMaxWidth() + .height(4.dp) + .clip(RoundedCornerShape(999.dp)) + .background(Color.White.copy(alpha = 0.12f)), + ) { + Box( + Modifier + .fillMaxWidth(display.progress) + .height(4.dp) + .background(progressColor), + ) + } + Text(display.detail, color = TextMuted, style = MaterialTheme.typography.labelSmall) + } +} + +internal fun formatSessionTimerDuration(totalSeconds: Int): String { + val seconds = totalSeconds.coerceAtLeast(0) + val hours = seconds / 3600 + val minutes = (seconds % 3600) / 60 + val remainingSeconds = seconds % 60 + return if (hours > 0) { + "%d:%02d:%02d".format(Locale.US, hours, minutes, remainingSeconds) + } else { + "%d:%02d".format(Locale.US, minutes, remainingSeconds) + } +} + +private fun formatSessionWarningThreshold(thresholdSeconds: Int): String { + val minutes = thresholdSeconds / 60 + return if (minutes == 1) "1 minute" else "$minutes minutes" +} + +@Composable +private fun StreamVideoSurface( + client: NativeStreamClient, + settings: StreamSettings, + viewportSettings: StreamSettings, + decodedResolution: String?, + serverNegotiatedResolution: String?, + serverFinalSelectedResolution: String?, + androidTouch: AndroidTouchSettings, + hideExternalMousePointer: Boolean, + touchMouseEnabled: Boolean, + pinchZoomEnabled: Boolean, + externalMouseRoot: android.view.View?, + onMouseCaptureInput: () -> Unit, + stretchToFit: Boolean, + vibrationEnabled: Boolean, + hapticsOutput: HapticsOutputPreference, + modifier: Modifier = Modifier, +) { + val rootView = LocalView.current + val configuration = LocalConfiguration.current + val pointerRootView = externalMouseRoot ?: rootView + val currentOnMouseCaptureInput by rememberUpdatedState(onMouseCaptureInput) + var zoomScale by remember { mutableFloatStateOf(1f) } + var zoomOffset by remember { mutableStateOf(Offset.Zero) } + var viewportSize by remember { mutableStateOf(IntSize.Zero) } + val streamAspectRatio = remember( + viewportSettings.resolution, + viewportSettings.aspectRatio, + decodedResolution, + serverNegotiatedResolution, + serverFinalSelectedResolution, + ) { + streamRendererAspectRatio( + settings = viewportSettings, + decodedResolution = decodedResolution, + serverNegotiatedResolution = serverNegotiatedResolution, + serverFinalSelectedResolution = serverFinalSelectedResolution, + ) + } + val stretchContentAspectRatio = remember(decodedResolution, streamAspectRatio) { + streamStretchContentAspectRatio( + selectedAspectRatio = streamAspectRatio, + decodedResolution = decodedResolution, + ) + } + val viewportAspectRatio = remember(viewportSize) { + if (viewportSize.width > 0 && viewportSize.height > 0) { + viewportSize.width.toFloat() / viewportSize.height.toFloat() + } else { + 0f + } + } + val rendererModifier = if (viewportAspectRatio <= 0f) { + Modifier.fillMaxSize() + } else if (viewportAspectRatio > streamAspectRatio) { + // Screen is wider than stream (e.g. 2400×1080 screen, 1920×1080 stream). + // Fit by height so the renderer has no black bars internally; horizontal + // stretch (if enabled) is applied later via View.scaleX. + Modifier + .fillMaxHeight() + .aspectRatio(streamAspectRatio) + } else { + // Screen is taller than stream — fit by width; vertical stretch via scaleY. + Modifier + .fillMaxWidth() + .aspectRatio(streamAspectRatio) + } + + // SCALE_ASPECT_FIT preserves every decoded pixel. Stretching the View on only + // the mismatching axis removes the bars without cropping HUD or edge content. + val stretchScale = remember(stretchToFit, viewportAspectRatio, stretchContentAspectRatio) { + streamStretchScale( + enabled = stretchToFit, + viewportAspectRatio = viewportAspectRatio, + streamAspectRatio = stretchContentAspectRatio, + ) + } + LaunchedEffect( + viewportSettings.resolution, + viewportSettings.aspectRatio, + settings.streamSharpeningEnabled, + touchMouseEnabled, + pinchZoomEnabled, + stretchToFit, + streamAspectRatio, + configuration.orientation, + configuration.screenWidthDp, + configuration.screenHeightDp, + ) { + zoomScale = 1f + zoomOffset = Offset.Zero + } + LaunchedEffect(stretchToFit) { + NativeStreamInputRouter.setStretchToFit(stretchToFit) + } + LaunchedEffect(zoomScale, zoomOffset) { + NativeStreamInputRouter.setPresentationTransform( + zoomScale = zoomScale, + translationX = zoomOffset.x, + translationY = zoomOffset.y, + ) + } + LaunchedEffect( + settings.mouseSensitivity, + settings.mouseScrollSensitivity, + settings.mouseAcceleration, + settings.streamSharpeningEnabled, + settings.streamSharpeningAmount, + stretchToFit, + vibrationEnabled, + hapticsOutput, + ) { + client.applyLiveSettings(settings, vibrationEnabled, hapticsOutput, stretchToFit) + } + LaunchedEffect(streamAspectRatio) { + NativeStreamInputRouter.setRenderingAspectRatio(streamAspectRatio) + } + LaunchedEffect( + androidTouch.nativeTouchScrollScale, + androidTouch.nativeTouchJitterThresholdDp, + ) { + NativeStreamInputRouter.setNativeTouchSettings( + scrollScale = androidTouch.nativeTouchScrollScale, + jitterThresholdDp = androidTouch.nativeTouchJitterThresholdDp, + ) + } + DisposableEffect(client, rootView, pointerRootView, hideExternalMousePointer) { + NativeStreamInputRouter.setExternalMousePointerCaptureEnabled(hideExternalMousePointer) + pointerRootView.configureAndroidMousePointerCapture(hideExternalMousePointer, { currentOnMouseCaptureInput() }) { event -> + client.dispatchMotion(event) + } + if (hideExternalMousePointer) { + pointerRootView.hideAndroidPointerTree() + } else { + pointerRootView.showAndroidPointerTree() + } + onDispose { + NativeStreamInputRouter.setPresentationTransform(1f, 0f, 0f) + NativeStreamInputRouter.setExternalMousePointerCaptureEnabled(false) + pointerRootView.clearAndroidMousePointerCapture() + pointerRootView.showAndroidPointerTree() + } + } + Box( + modifier + .fillMaxSize() + .background(Color.Black) + .onSizeChanged { + if (viewportSize != it) { + viewportSize = it + zoomScale = 1f + zoomOffset = Offset.Zero + } else { + zoomOffset = clampStreamZoomOffset(zoomOffset, zoomScale, it) + } + } + .clipToBounds(), + contentAlignment = Alignment.Center, + ) { + Box( + Modifier + .matchParentSize() + .graphicsLayer { + scaleX = zoomScale + scaleY = zoomScale + translationX = zoomOffset.x + translationY = zoomOffset.y + }, + contentAlignment = Alignment.Center, + ) { + // The sharpness drawer is always attached (Streaming.kt createRenderer), so toggling + // sharpening mid-session is handled entirely by the update lambda below via + // applyLiveSettings → drawer.amount. Re-keying this AndroidView on that flag used to + // tear down and recreate the SurfaceViewRenderer on every toggle, causing a visible + // restart/flicker of the video surface. + AndroidView( + modifier = rendererModifier, + factory = { ctx -> + client.createRenderer(ctx, settings).apply { + isFocusable = false + isFocusableInTouchMode = false + hideAndroidPointerTree() + scaleX = stretchScale.first + scaleY = stretchScale.second + } + }, + update = { renderer -> + client.applyLiveSettings(settings, vibrationEnabled, hapticsOutput, stretchToFit) + renderer.scaleX = stretchScale.first + renderer.scaleY = stretchScale.second + renderer.isFocusable = false + renderer.isFocusableInTouchMode = false + pointerRootView.configureAndroidMousePointerCapture(hideExternalMousePointer, { currentOnMouseCaptureInput() }) { event -> + client.dispatchMotion(event) + } + if (hideExternalMousePointer) { + pointerRootView.hideAndroidPointerTree() + renderer.hideAndroidPointerTree() + } else { + pointerRootView.showAndroidPointerTree() + renderer.showAndroidPointerTree() + } + renderer.setOnKeyListener(null) + renderer.setOnGenericMotionListener { _, event -> + if (hideExternalMousePointer) pointerRootView.hideAndroidPointerTree() + client.dispatchMotion(event) + } + renderer.setOnTouchListener { view, event -> + NativeStreamInputRouter.dispatchTouch(event, view.width, view.height) + } + }, + onRelease = client::releaseRenderer, + ) + } + FingerMouseInputLayer( + enabled = touchMouseEnabled, + pinchZoomEnabled = pinchZoomEnabled, + onZoomGesture = { scaleChange, pan, centroid -> + val previousScale = zoomScale + val nextScale = (zoomScale * scaleChange).coerceIn(1f, 3f) + val appliedScaleChange = nextScale / previousScale + val viewportCenter = Offset(viewportSize.width / 2f, viewportSize.height / 2f) + zoomScale = nextScale + zoomOffset = if (nextScale <= 1.001f) { + Offset.Zero + } else { + // Keep the content under the pinch centroid anchored while scaling, then + // apply the fingers' pan. Scaling around the viewport centre without this + // correction makes an off-centre zoom appear to slide away from the user. + val focalCorrection = + (centroid - viewportCenter) * (1f - appliedScaleChange) + clampStreamZoomOffset( + zoomOffset * appliedScaleChange + focalCorrection + pan, + nextScale, + viewportSize, + ) + } + }, + modifier = Modifier.matchParentSize(), + ) + } +} + +internal fun streamRendererAspectRatio( + settings: StreamSettings, + decodedResolution: String? = null, + serverNegotiatedResolution: String? = null, + serverFinalSelectedResolution: String? = null, +): Float { + val selectedAspectRatio = streamAspectRatioForPixels(streamResolutionPixels(settings)) + val decodedPixels = parseResolutionPixelsOrNull(decodedResolution) ?: return selectedAspectRatio + val authoritativeServerPixels = listOf(serverFinalSelectedResolution, serverNegotiatedResolution) + .mapNotNull(::parseResolutionPixelsOrNull) + if (decodedPixels !in authoritativeServerPixels) return selectedAspectRatio + return streamAspectRatioForPixels(decodedPixels) +} + +internal fun streamStretchContentAspectRatio( + selectedAspectRatio: Float, + decodedResolution: String?, +): Float { + val decodedPixels = parseResolutionPixelsOrNull(decodedResolution) ?: return selectedAspectRatio + val decodedAspectRatio = decodedPixels.first.toFloat() / decodedPixels.second.toFloat() + return decodedAspectRatio.takeIf { it.isFinite() && it > 0f } ?: selectedAspectRatio +} + +internal fun streamStretchScale( + enabled: Boolean, + viewportAspectRatio: Float, + streamAspectRatio: Float, +): Pair { + if (!enabled || viewportAspectRatio <= 0f || streamAspectRatio <= 0f) return 1f to 1f + return when { + viewportAspectRatio > streamAspectRatio -> + (viewportAspectRatio / streamAspectRatio).coerceIn(1f, 3f) to 1f + viewportAspectRatio < streamAspectRatio -> + 1f to (streamAspectRatio / viewportAspectRatio).coerceIn(1f, 3f) + else -> 1f to 1f + } +} + +internal fun streamPinchZoomEnabled( + touchMouseEnabled: Boolean, + touchControllerVisible: Boolean, +): Boolean = touchMouseEnabled && !touchControllerVisible + +internal fun shouldEnableExternalMousePointerCapture( + streamReady: Boolean, + streamOverlayOpen: Boolean, + pointerLockEnabled: Boolean, +): Boolean = streamReady && !streamOverlayOpen && pointerLockEnabled + +private fun streamAspectRatioForPixels(pixels: Pair): Float { + val (width, height) = pixels + if (width <= 0 || height <= 0) return 16f / 9f + return width.toFloat() / height.toFloat() +} + +private fun clampStreamZoomOffset(offset: Offset, zoomScale: Float, viewportSize: IntSize): Offset { + if (zoomScale <= 1.001f || viewportSize.width <= 0 || viewportSize.height <= 0) return Offset.Zero + val maxX = viewportSize.width * (zoomScale - 1f) / 2f + val maxY = viewportSize.height * (zoomScale - 1f) / 2f + return Offset( + x = offset.x.coerceIn(-maxX, maxX), + y = offset.y.coerceIn(-maxY, maxY), + ) +} + +private fun androidNullPointerIcon(view: android.view.View): PointerIcon? = + if (Build.VERSION.SDK_INT >= 24) { + runCatching { PointerIcon.getSystemIcon(view.context, PointerIcon.TYPE_NULL) } + .onFailure { error -> NativeInputDiagnostics.add("pointer icon unavailable error=${error.javaClass.simpleName}") } + .getOrNull() + } else { + null + } + +private fun View.configureAndroidMousePointerCapture(enabled: Boolean, onCaptureInput: () -> Unit = {}, onMotion: (MotionEvent) -> Boolean) { + if (Build.VERSION.SDK_INT < 26) return + if (!enabled) { + clearAndroidMousePointerCapture() + return + } + setOnCapturedPointerListener { _, event -> + onCaptureInput() + onMotion(event) + } + post { + if (isAttachedToWindow && hasWindowFocus() && !hasPointerCapture()) { + isFocusable = true + isFocusableInTouchMode = true + requestFocus() + onCaptureInput() + runCatching { requestPointerCapture() } + .onFailure { error -> NativeInputDiagnostics.add("pointer capture request failed error=${error.javaClass.simpleName}") } + } + } +} + +private fun View.clearAndroidMousePointerCapture() { + if (Build.VERSION.SDK_INT < 26) return + setOnCapturedPointerListener(null) + runCatching { releasePointerCapture() } + .onFailure { error -> NativeInputDiagnostics.add("pointer capture release failed error=${error.javaClass.simpleName}") } +} + +private fun android.view.View.hideAndroidPointerTree() { + if (Build.VERSION.SDK_INT < 24) return + val icon = androidNullPointerIcon(this) + applyAndroidPointerIconTree(icon) +} + +private fun android.view.View.showAndroidPointerTree() { + if (Build.VERSION.SDK_INT < 24) return + applyAndroidPointerIconTree(null) +} + +private fun android.view.View.applyAndroidPointerIconTree(icon: PointerIcon?) { + if (Build.VERSION.SDK_INT < 24) return + runCatching { pointerIcon = icon } + .onFailure { error -> NativeInputDiagnostics.add("pointer icon apply failed error=${error.javaClass.simpleName}") } + if (this is ViewGroup) { + for (index in 0 until childCount) { + getChildAt(index).applyAndroidPointerIconTree(icon) + } + } +} + +@Composable +private fun FingerMouseInputLayer( + enabled: Boolean, + pinchZoomEnabled: Boolean, + onZoomGesture: (scaleChange: Float, pan: Offset, centroid: Offset) -> Unit, + modifier: Modifier = Modifier, +) { + if (!enabled) return + var width by remember { mutableStateOf(0) } + var height by remember { mutableStateOf(0) } + var pinchActive by remember { mutableStateOf(false) } + var lastPinchDistance by remember { mutableFloatStateOf(0f) } + var lastPinchCentroid by remember { mutableStateOf(Offset.Zero) } + Box( + modifier + .onSizeChanged { + width = it.width + height = it.height + } + .pointerInteropFilter { event -> + if (NativeStreamInputRouter.isNativeUiTouchGestureActive()) { + pinchActive = false + lastPinchDistance = 0f + lastPinchCentroid = Offset.Zero + return@pointerInteropFilter NativeStreamInputRouter.dispatchTouch(event, width, height) + } + if (event.pointerCount >= 2) { + // 3-finger touch is reserved for the Direct Click toggle gesture + // (handled in NativeStreamInputRouter.dispatchTouch). Do not + // interpret it as a pinch-zoom — reset pinch state and let it through. + if (event.pointerCount >= 3) { + pinchActive = false + lastPinchDistance = 0f + lastPinchCentroid = Offset.Zero + NativeStreamInputRouter.dispatchTouch(event, width, height) + return@pointerInteropFilter true + } + NativeStreamInputRouter.cancelTouchMouse() + if (!pinchZoomEnabled) { + // Multiple fingers while the touch controller is visible are + // controller input, not a request to crop the video surface. + pinchActive = true + lastPinchDistance = 0f + lastPinchCentroid = Offset.Zero + return@pointerInteropFilter true + } + val distance = event.firstTwoPointerDistance() + val centroid = event.firstTwoPointerCentroid() + if (pinchActive && lastPinchDistance > 0f && distance > 0f) { + onZoomGesture( + (distance / lastPinchDistance).coerceIn(0.82f, 1.22f), + centroid - lastPinchCentroid, + centroid, + ) + } + pinchActive = true + lastPinchDistance = distance + lastPinchCentroid = centroid + return@pointerInteropFilter true + } + if (pinchActive) { + if (event.actionMasked == MotionEvent.ACTION_UP || event.actionMasked == MotionEvent.ACTION_CANCEL) { + pinchActive = false + lastPinchDistance = 0f + lastPinchCentroid = Offset.Zero + } + return@pointerInteropFilter true + } + if (event.actionMasked == MotionEvent.ACTION_DOWN) { + NativeInputDiagnostics.retainTouchRoute("compose.finger-layer") { + "compose finger layer down size=${width}x$height" + } + } + NativeStreamInputRouter.dispatchTouch(event, width, height) + }, + ) +} + +private fun MotionEvent.firstTwoPointerDistance(): Float { + if (pointerCount < 2) return 0f + val dx = getX(1) - getX(0) + val dy = getY(1) - getY(0) + return sqrt(dx * dx + dy * dy) +} + +private fun MotionEvent.firstTwoPointerCentroid(): Offset = + if (pointerCount >= 2) { + Offset((getX(0) + getX(1)) / 2f, (getY(0) + getY(1)) / 2f) + } else { + Offset.Zero + } diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowTouchControls.kt b/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowTouchControls.kt new file mode 100644 index 000000000..e47b9146a --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowTouchControls.kt @@ -0,0 +1,1456 @@ +package com.opencloudgaming.opennow + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.key +import androidx.compose.runtime.setValue +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.key.key +import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.Dp +import kotlin.math.min +import kotlin.math.roundToInt +import kotlin.math.sqrt + +@Composable +internal fun TouchOverlay( + client: NativeStreamClient, + touch: AndroidTouchSettings, + onButtonTone: () -> Unit, + layoutEditing: Boolean, + onSaveAllOffsets: (Map) -> Unit, + modifier: Modifier = Modifier, +) { + val opacity = touch.opacity + val layoutScale = touch.scale + val buttonScale = touch.buttonScale + val stickScale = touch.stickScale + val defaultOffsets = remember { AndroidTouchSettings().offsets } + + val localOffsets = remember(touch.offsets) { + androidx.compose.runtime.mutableStateMapOf().apply { + putAll(touch.offsets) + } + } + + fun getLocalOffset(key: String): TouchOffset { + val saved = localOffsets[key] + if (saved != null) return saved + defaultOffsets[key]?.let { return it } + val baseKey = key.substringBeforeLast("_") + return when (baseKey) { + "lt", "lb", "lstick", "dpad", "l3" -> TouchOffset(touch.leftOffsetXDp, touch.leftOffsetYDp) + "rt", "rb", "rstick", "face", "r3" -> TouchOffset(touch.rightOffsetXDp, touch.rightOffsetYDp) + else -> TouchOffset() + } + } + + val onLocalOffsetChange = { key: String, x: Float, y: Float -> + localOffsets[key] = TouchOffset(x, y) + } + + val currentLocalOffsets by rememberUpdatedState(localOffsets.toMap()) + val currentOnSaveAllOffsets by rememberUpdatedState(onSaveAllOffsets) + DisposableEffect(layoutEditing) { + onDispose { + if (layoutEditing) { + currentOnSaveAllOffsets(currentLocalOffsets) + } + } + } + + DisposableEffect(client) { + onDispose { + NativeStreamInputRouter.clearTouchControllerPassthroughBounds() + } + } + + val skin = remember(touch.touchControllerStyle, touch.opacity, touch.touchSkinTint) { + touchSkinColors(touch.touchControllerStyle, touch.opacity, touchSkinAccent(touch)) + } + val skinForm = remember(touch.touchControllerStyle) { touchSkinForm(touch.touchControllerStyle) } + CompositionLocalProvider( + LocalTouchControllerStyle provides touch.touchControllerStyle, + LocalTouchSkin provides skin, + LocalTouchSkinForm provides skinForm, + LocalTouchButtonLabels provides touch.touchButtonLabels, + LocalTouchStickKnobScale provides touch.stickKnobScale, + ) { + BoxWithConstraints( + modifier + .fillMaxSize() + .padding( + start = touch.edgePaddingDp.dp, + top = 10.dp, + end = touch.edgePaddingDp.dp, + bottom = touch.bottomPaddingDp.dp, + ), + ) { + if (touch.enabled) { + val landscape = maxWidth > maxHeight + val suffix = if (landscape) "_landscape" else "_portrait" + val getOrientationLocalOffset = { key: String -> getLocalOffset(key + suffix) } + val onOrientationLocalOffsetChange = { key: String, x: Float, y: Float -> + onLocalOffsetChange(key + suffix, x, y) + } + + if (landscape) { + LandscapeTouchControls( + client = client, + opacity = opacity, + layoutScale = layoutScale, + buttonScale = buttonScale, + stickScale = stickScale, + faceButtonScale = touch.faceButtonScale, + dpadScale = touch.dpadScale, + shoulderButtonScale = touch.shoulderButtonScale, + centerButtonScale = touch.centerButtonScale, + leftStickScale = touch.leftStickScale, + rightStickScale = touch.rightStickScale, + visibleControlGroups = touch.visibleControlGroups, + extraButtonActions = List(TOUCH_EXTRA_BUTTON_COUNT, touch::extraButtonAction), + extraButtonScale = touch.extraButtonScale, + joystickMode = touch.joystickMode, + aimMode = touch.aimMode, + aimZoneScale = touch.aimZoneScale, + aimZoneSensitivity = touch.aimZoneSensitivity, + joystickDeadZone = touch.joystickDeadZone, + viewportHeight = maxHeight, + layoutEditing = layoutEditing, + getLocalOffset = getOrientationLocalOffset, + onLocalOffsetChange = onOrientationLocalOffsetChange, + onButtonTone = onButtonTone, + ) + } else { + PortraitTouchControls( + client = client, + opacity = opacity, + layoutScale = layoutScale, + buttonScale = buttonScale, + stickScale = stickScale, + faceButtonScale = touch.faceButtonScale, + dpadScale = touch.dpadScale, + shoulderButtonScale = touch.shoulderButtonScale, + centerButtonScale = touch.centerButtonScale, + leftStickScale = touch.leftStickScale, + rightStickScale = touch.rightStickScale, + visibleControlGroups = touch.visibleControlGroups, + extraButtonActions = List(TOUCH_EXTRA_BUTTON_COUNT, touch::extraButtonAction), + extraButtonScale = touch.extraButtonScale, + joystickMode = touch.joystickMode, + aimMode = touch.aimMode, + aimZoneScale = touch.aimZoneScale, + aimZoneSensitivity = touch.aimZoneSensitivity, + joystickDeadZone = touch.joystickDeadZone, + layoutEditing = layoutEditing, + getLocalOffset = getOrientationLocalOffset, + onLocalOffsetChange = onOrientationLocalOffsetChange, + onButtonTone = onButtonTone, + ) + } + } + } + } +} + +@Composable +private fun PortraitTouchControls( + client: NativeStreamClient, + opacity: Float, + layoutScale: Float, + buttonScale: Float, + stickScale: Float, + faceButtonScale: Float, + dpadScale: Float, + shoulderButtonScale: Float, + centerButtonScale: Float, + leftStickScale: Float, + rightStickScale: Float, + visibleControlGroups: Set, + extraButtonActions: List, + extraButtonScale: Float, + joystickMode: TouchJoystickMode, + aimMode: TouchAimMode, + aimZoneScale: Float, + aimZoneSensitivity: Float, + joystickDeadZone: Float, + layoutEditing: Boolean, + getLocalOffset: (String) -> TouchOffset, + onLocalOffsetChange: (String, Float, Float) -> Unit, + onButtonTone: () -> Unit, +) { + val leftStickDiameter = 116.dp * stickScale * leftStickScale * layoutScale + val rightStickDiameter = 104.dp * stickScale * rightStickScale * layoutScale + val centerScale = buttonScale * centerButtonScale * layoutScale + val buttonSize48 = 48.dp * centerScale + val buttonSize44 = 44.dp * centerScale + val faceWidth = buttonSize48 * 2.44f + + Box( + Modifier.fillMaxSize().padding(horizontal = 32.dp, vertical = 24.dp) + ) { + if (aimMode == TouchAimMode.LockZone && TouchControlGroup.RightStick in visibleControlGroups) { + LockZoneAimSurface( + id = "portrait-aim-zone", + client = client, + opacity = opacity, + deadZone = joystickDeadZone, + sensitivity = aimZoneSensitivity, + enabled = !layoutEditing, + modifier = Modifier + .align(Alignment.BottomEnd) + .fillMaxWidth(scaledAimZoneFraction(0.54f, aimZoneScale)) + .fillMaxHeight(scaledAimZoneFraction(0.48f, aimZoneScale)), + ) + } + val shoulderScale = buttonScale * shoulderButtonScale * layoutScale + val triggerWidth = 64.dp * shoulderScale + val bumperHeight = 32.dp * shoulderScale + val triggerTouchHeight = if (bumperHeight < 48.dp) 48.dp else bumperHeight + + ExtraTouchButtons( + orientation = "portrait", + actions = extraButtonActions, + scale = buttonScale * extraButtonScale * layoutScale, + client = client, + layoutEditing = layoutEditing, + getLocalOffset = getLocalOffset, + onLocalOffsetChange = onLocalOffsetChange, + onButtonTone = onButtonTone, + modifier = Modifier.align(Alignment.TopCenter).padding(top = 4.dp), + ) + + if (TouchControlGroup.ShoulderButtons in visibleControlGroups) TouchControlGroup( + id = "portrait-lt", + layoutEditing = layoutEditing, + offsetX = getLocalOffset("lt").x.dp, + offsetY = getLocalOffset("lt").y.dp, + onOffsetChange = { x, y -> onLocalOffsetChange("lt", x, y) }, + modifier = Modifier.align(Alignment.TopStart), + ) { + GamepadTriggerButton( + label = "LT", + left = true, + client = client, + width = triggerWidth, + height = bumperHeight, + onPressTone = onButtonTone, + ) + } + + if (TouchControlGroup.ShoulderButtons in visibleControlGroups) TouchControlGroup( + id = "portrait-lb", + layoutEditing = layoutEditing, + offsetX = getLocalOffset("lb").x.dp, + offsetY = getLocalOffset("lb").y.dp, + onOffsetChange = { x, y -> onLocalOffsetChange("lb", x, y) }, + modifier = Modifier.align(Alignment.TopStart).padding(top = triggerTouchHeight + 6.dp), + ) { + GamepadBumperButton( + label = "LB", + mask = 0x0100, + client = client, + width = triggerWidth, + height = bumperHeight, + onPressTone = onButtonTone, + ) + } + + if (TouchControlGroup.LeftStick in visibleControlGroups) TouchControlGroup( + id = "portrait-lstick", + layoutEditing = layoutEditing, + offsetX = getLocalOffset("lstick").x.dp, + offsetY = getLocalOffset("lstick").y.dp, + onOffsetChange = { x, y -> onLocalOffsetChange("lstick", x, y) }, + modifier = Modifier.align(Alignment.BottomStart), + ) { + VirtualStick( + label = "L", + client = client, + diameter = leftStickDiameter, + mode = joystickMode, + deadZone = joystickDeadZone, + onChange = client::setVirtualLeftStick, + ) + } + + if (TouchControlGroup.ThumbButtons in visibleControlGroups) TouchControlGroup( + id = "portrait-l3", + layoutEditing = layoutEditing, + offsetX = getLocalOffset("l3").x.dp, + offsetY = getLocalOffset("l3").y.dp, + onOffsetChange = { x, y -> onLocalOffsetChange("l3", x, y) }, + modifier = Modifier.align(Alignment.BottomStart).padding( + start = (leftStickDiameter - buttonSize48) / 2, + bottom = leftStickDiameter + 6.dp + ), + ) { + GamepadButton("LS", GamepadButtonMapping.LEFT_THUMB, client, buttonSize48, onButtonTone) + } + + if (TouchControlGroup.Dpad in visibleControlGroups) TouchControlGroup( + id = "portrait-dpad", + layoutEditing = layoutEditing, + offsetX = getLocalOffset("dpad").x.dp, + offsetY = getLocalOffset("dpad").y.dp, + onOffsetChange = { x, y -> onLocalOffsetChange("dpad", x, y) }, + modifier = Modifier.align(Alignment.BottomStart).padding(start = leftStickDiameter + 12.dp), + ) { + DpadCluster(client, buttonScale * dpadScale * layoutScale, onButtonTone) + } + + if (TouchControlGroup.ShoulderButtons in visibleControlGroups) TouchControlGroup( + id = "portrait-rt", + layoutEditing = layoutEditing, + offsetX = getLocalOffset("rt").x.dp, + offsetY = getLocalOffset("rt").y.dp, + onOffsetChange = { x, y -> onLocalOffsetChange("rt", x, y) }, + modifier = Modifier.align(Alignment.TopEnd), + ) { + GamepadTriggerButton( + label = "RT", + left = false, + client = client, + width = triggerWidth, + height = bumperHeight, + onPressTone = onButtonTone, + ) + } + + if (TouchControlGroup.ShoulderButtons in visibleControlGroups) TouchControlGroup( + id = "portrait-rb", + layoutEditing = layoutEditing, + offsetX = getLocalOffset("rb").x.dp, + offsetY = getLocalOffset("rb").y.dp, + onOffsetChange = { x, y -> onLocalOffsetChange("rb", x, y) }, + modifier = Modifier.align(Alignment.TopEnd).padding(top = triggerTouchHeight + 6.dp), + ) { + GamepadBumperButton( + label = "RB", + mask = 0x0200, + client = client, + width = triggerWidth, + height = bumperHeight, + onPressTone = onButtonTone, + ) + } + + if (TouchControlGroup.MenuButtons in visibleControlGroups) TouchControlGroup( + id = "portrait-select", + layoutEditing = layoutEditing, + offsetX = getLocalOffset("select").x.dp, + offsetY = getLocalOffset("select").y.dp, + onOffsetChange = { x, y -> onLocalOffsetChange("select", x, y) }, + modifier = Modifier.align(Alignment.TopEnd).padding(top = buttonSize48 + 8.dp, end = buttonSize44 + 8.dp), + ) { + GamepadButton("◀", 0x0020, client, buttonSize44, onButtonTone) + } + + if (TouchControlGroup.MenuButtons in visibleControlGroups) TouchControlGroup( + id = "portrait-start", + layoutEditing = layoutEditing, + offsetX = getLocalOffset("start").x.dp, + offsetY = getLocalOffset("start").y.dp, + onOffsetChange = { x, y -> onLocalOffsetChange("start", x, y) }, + modifier = Modifier.align(Alignment.TopEnd).padding(top = buttonSize48 + 8.dp), + ) { + GamepadButton("▶", 0x0010, client, buttonSize44, onButtonTone) + } + + if (aimMode == TouchAimMode.LockJoystick && TouchControlGroup.RightStick in visibleControlGroups) { + TouchControlGroup( + id = "portrait-rstick", + layoutEditing = layoutEditing, + offsetX = getLocalOffset("rstick").x.dp, + offsetY = getLocalOffset("rstick").y.dp, + onOffsetChange = { x, y -> onLocalOffsetChange("rstick", x, y) }, + modifier = Modifier.align(Alignment.BottomEnd).padding(end = faceWidth + 12.dp), + ) { + VirtualStick( + label = "R", + client = client, + diameter = rightStickDiameter, + mode = joystickMode, + deadZone = joystickDeadZone, + onChange = client::setVirtualRightStick, + ) + } + } + + if (TouchControlGroup.ThumbButtons in visibleControlGroups) TouchControlGroup( + id = "portrait-r3", + layoutEditing = layoutEditing, + offsetX = getLocalOffset("r3").x.dp, + offsetY = getLocalOffset("r3").y.dp, + onOffsetChange = { x, y -> onLocalOffsetChange("r3", x, y) }, + modifier = Modifier.align(Alignment.BottomEnd).padding( + end = faceWidth + 12.dp + (rightStickDiameter - buttonSize48) / 2, + bottom = rightStickDiameter + 6.dp + ), + ) { + GamepadButton("RS", GamepadButtonMapping.RIGHT_THUMB, client, buttonSize48, onButtonTone) + } + + if (TouchControlGroup.FaceButtons in visibleControlGroups) TouchControlGroup( + id = "portrait-face", + layoutEditing = layoutEditing, + offsetX = getLocalOffset("face").x.dp, + offsetY = getLocalOffset("face").y.dp, + onOffsetChange = { x, y -> onLocalOffsetChange("face", x, y) }, + modifier = Modifier.align(Alignment.BottomEnd), + ) { + FaceButtonCluster(client, buttonScale * faceButtonScale * layoutScale, onButtonTone) + } + } +} + +@Composable +private fun BoxScope.LandscapeTouchControls( + client: NativeStreamClient, + opacity: Float, + layoutScale: Float, + buttonScale: Float, + stickScale: Float, + faceButtonScale: Float, + dpadScale: Float, + shoulderButtonScale: Float, + centerButtonScale: Float, + leftStickScale: Float, + rightStickScale: Float, + visibleControlGroups: Set, + extraButtonActions: List, + extraButtonScale: Float, + joystickMode: TouchJoystickMode, + aimMode: TouchAimMode, + aimZoneScale: Float, + aimZoneSensitivity: Float, + joystickDeadZone: Float, + viewportHeight: Dp, + layoutEditing: Boolean, + getLocalOffset: (String) -> TouchOffset, + onLocalOffsetChange: (String, Float, Float) -> Unit, + onButtonTone: () -> Unit, +) { + val controlScale = buttonScale * layoutScale + val shoulderScale = controlScale * shoulderButtonScale + val centerScale = controlScale * centerButtonScale + val topControlClearance = landscapeTouchTopControlClearanceDp(viewportHeight.value, shoulderScale).dp + Box(Modifier.fillMaxSize().padding(horizontal = 24.dp, vertical = 24.dp)) { + if (aimMode == TouchAimMode.LockZone && TouchControlGroup.RightStick in visibleControlGroups) { + LockZoneAimSurface( + id = "landscape-aim-zone", + client = client, + opacity = opacity, + deadZone = joystickDeadZone, + sensitivity = aimZoneSensitivity, + enabled = !layoutEditing, + modifier = Modifier + .align(Alignment.CenterEnd) + .fillMaxWidth(scaledAimZoneFraction(0.48f, aimZoneScale)) + .fillMaxHeight(scaledAimZoneFraction(0.72f, aimZoneScale)), + ) + } + val triggerWidth = 76.dp * shoulderScale + val bumperHeight = 36.dp * shoulderScale + val triggerTouchHeight = if (bumperHeight < 48.dp) 48.dp else bumperHeight + + ExtraTouchButtons( + orientation = "landscape", + actions = extraButtonActions, + scale = controlScale * extraButtonScale, + client = client, + layoutEditing = layoutEditing, + getLocalOffset = getLocalOffset, + onLocalOffsetChange = onLocalOffsetChange, + onButtonTone = onButtonTone, + modifier = Modifier.align(Alignment.TopCenter).padding(top = topControlClearance), + ) + + if (TouchControlGroup.ShoulderButtons in visibleControlGroups) TouchControlGroup( + id = "landscape-lt", + layoutEditing = layoutEditing, + offsetX = getLocalOffset("lt").x.dp, + offsetY = getLocalOffset("lt").y.dp, + onOffsetChange = { x, y -> onLocalOffsetChange("lt", x, y) }, + modifier = Modifier.align(Alignment.TopStart).padding(top = topControlClearance), + ) { + GamepadTriggerButton( + label = "LT", + left = true, + client = client, + width = triggerWidth, + height = bumperHeight, + onPressTone = onButtonTone, + ) + } + + if (TouchControlGroup.ShoulderButtons in visibleControlGroups) TouchControlGroup( + id = "landscape-lb", + layoutEditing = layoutEditing, + offsetX = getLocalOffset("lb").x.dp, + offsetY = getLocalOffset("lb").y.dp, + onOffsetChange = { x, y -> onLocalOffsetChange("lb", x, y) }, + modifier = Modifier.align(Alignment.TopStart).padding(top = topControlClearance + triggerTouchHeight + 6.dp), + ) { + GamepadBumperButton( + label = "LB", + mask = 0x0100, + client = client, + width = triggerWidth, + height = bumperHeight, + onPressTone = onButtonTone, + ) + } + + val selectSize = 42.dp * centerScale + if (TouchControlGroup.MenuButtons in visibleControlGroups) TouchControlGroup( + id = "landscape-select", + layoutEditing = layoutEditing, + offsetX = getLocalOffset("select").x.dp, + offsetY = getLocalOffset("select").y.dp, + onOffsetChange = { x, y -> onLocalOffsetChange("select", x, y) }, + modifier = Modifier.align(Alignment.BottomCenter).padding(end = selectSize / 2 + 27.dp), + ) { + GamepadButton("◀", 0x0020, client, selectSize, onButtonTone) + } + + if (TouchControlGroup.MenuButtons in visibleControlGroups) TouchControlGroup( + id = "landscape-start", + layoutEditing = layoutEditing, + offsetX = getLocalOffset("start").x.dp, + offsetY = getLocalOffset("start").y.dp, + onOffsetChange = { x, y -> onLocalOffsetChange("start", x, y) }, + modifier = Modifier.align(Alignment.BottomCenter).padding(start = selectSize / 2 + 27.dp), + ) { + GamepadButton("▶", 0x0010, client, selectSize, onButtonTone) + } + + if (TouchControlGroup.ShoulderButtons in visibleControlGroups) TouchControlGroup( + id = "landscape-rb", + layoutEditing = layoutEditing, + offsetX = getLocalOffset("rb").x.dp, + offsetY = getLocalOffset("rb").y.dp, + onOffsetChange = { x, y -> onLocalOffsetChange("rb", x, y) }, + modifier = Modifier.align(Alignment.TopEnd).padding(top = topControlClearance + triggerTouchHeight + 6.dp), + ) { + GamepadBumperButton( + label = "RB", + mask = 0x0200, + client = client, + width = triggerWidth, + height = bumperHeight, + onPressTone = onButtonTone, + ) + } + + if (TouchControlGroup.ShoulderButtons in visibleControlGroups) TouchControlGroup( + id = "landscape-rt", + layoutEditing = layoutEditing, + offsetX = getLocalOffset("rt").x.dp, + offsetY = getLocalOffset("rt").y.dp, + onOffsetChange = { x, y -> onLocalOffsetChange("rt", x, y) }, + modifier = Modifier.align(Alignment.TopEnd).padding(top = topControlClearance), + ) { + GamepadTriggerButton( + label = "RT", + left = false, + client = client, + width = triggerWidth, + height = bumperHeight, + onPressTone = onButtonTone, + ) + } + + val effectiveDpadScale = controlScale * dpadScale * 0.88f + val dpadButtonSize = 54.dp * effectiveDpadScale + // Keep the next control outside the full skin-aware d-pad canvas. The d-pad painter grew + // beyond the old four-button cluster width when the shaped skins were introduced. + val dpadWidth = if (TouchControlGroup.Dpad in visibleControlGroups) touchDpadBoxSize(dpadButtonSize) else 0.dp + if (TouchControlGroup.Dpad in visibleControlGroups) TouchControlGroup( + id = "landscape-dpad", + layoutEditing = layoutEditing, + offsetX = getLocalOffset("dpad").x.dp, + offsetY = getLocalOffset("dpad").y.dp, + onOffsetChange = { x, y -> onLocalOffsetChange("dpad", x, y) }, + modifier = Modifier.align(Alignment.BottomStart), + ) { + DpadCluster(client, effectiveDpadScale, onButtonTone) + } + + val leftStickDiameter = 112.dp * stickScale * leftStickScale * layoutScale + if (TouchControlGroup.LeftStick in visibleControlGroups) TouchControlGroup( + id = "landscape-lstick", + layoutEditing = layoutEditing, + offsetX = getLocalOffset("lstick").x.dp, + offsetY = getLocalOffset("lstick").y.dp, + onOffsetChange = { x, y -> onLocalOffsetChange("lstick", x, y) }, + modifier = Modifier.align(Alignment.BottomStart).padding(start = dpadWidth + 14.dp), + ) { + VirtualStick( + label = "L", + client = client, + diameter = leftStickDiameter, + mode = joystickMode, + deadZone = joystickDeadZone, + onChange = client::setVirtualLeftStick, + ) + } + + val l3Size = 54.dp * centerScale + if (TouchControlGroup.ThumbButtons in visibleControlGroups) TouchControlGroup( + id = "landscape-l3", + layoutEditing = layoutEditing, + offsetX = getLocalOffset("l3").x.dp, + offsetY = getLocalOffset("l3").y.dp, + onOffsetChange = { x, y -> onLocalOffsetChange("l3", x, y) }, + modifier = Modifier.align(Alignment.BottomStart).padding( + start = dpadWidth + 14.dp + (leftStickDiameter - l3Size) / 2, + bottom = leftStickDiameter + 6.dp + ), + ) { + GamepadButton("LS", GamepadButtonMapping.LEFT_THUMB, client, l3Size, onButtonTone) + } + + val faceScale = controlScale * faceButtonScale * 0.9f + val faceButtonSize = 54.dp * faceScale + val faceWidth = faceButtonSize * 2.44f + val rightStickDiameter = 112.dp * stickScale * rightStickScale * layoutScale + if (aimMode == TouchAimMode.LockJoystick && TouchControlGroup.RightStick in visibleControlGroups) { + TouchControlGroup( + id = "landscape-rstick", + layoutEditing = layoutEditing, + offsetX = getLocalOffset("rstick").x.dp, + offsetY = getLocalOffset("rstick").y.dp, + onOffsetChange = { x, y -> onLocalOffsetChange("rstick", x, y) }, + modifier = Modifier.align(Alignment.BottomEnd).padding(end = faceWidth + 14.dp), + ) { + VirtualStick( + label = "R", + client = client, + diameter = rightStickDiameter, + mode = joystickMode, + deadZone = joystickDeadZone, + onChange = client::setVirtualRightStick, + ) + } + } + + val r3Size = 54.dp * centerScale + if (TouchControlGroup.ThumbButtons in visibleControlGroups) TouchControlGroup( + id = "landscape-r3", + layoutEditing = layoutEditing, + offsetX = getLocalOffset("r3").x.dp, + offsetY = getLocalOffset("r3").y.dp, + onOffsetChange = { x, y -> onLocalOffsetChange("r3", x, y) }, + modifier = Modifier.align(Alignment.BottomEnd).padding( + end = faceWidth + 14.dp + (rightStickDiameter - r3Size) / 2, + bottom = rightStickDiameter + 6.dp + ), + ) { + GamepadButton("RS", GamepadButtonMapping.RIGHT_THUMB, client, r3Size, onButtonTone) + } + + if (TouchControlGroup.FaceButtons in visibleControlGroups) TouchControlGroup( + id = "landscape-face", + layoutEditing = layoutEditing, + offsetX = getLocalOffset("face").x.dp, + offsetY = getLocalOffset("face").y.dp, + onOffsetChange = { x, y -> onLocalOffsetChange("face", x, y) }, + modifier = Modifier.align(Alignment.BottomEnd), + ) { + FaceButtonCluster(client, faceScale, onButtonTone) + } + } +} + +internal fun landscapeTouchTopControlClearanceDp(viewportHeightDp: Float, controlScale: Float): Float { + val viewportBand = (viewportHeightDp * 0.11f).coerceIn(34f, 58f) + val scaledBand = viewportBand * controlScale.coerceIn(0.75f, 1.35f) + return scaledBand.coerceIn(30f, 76f) +} + +internal fun touchExtraButtonActionLabel(action: TouchExtraButtonAction): String = when (action) { + TouchExtraButtonAction.None -> "Off" + TouchExtraButtonAction.Guide -> "Guide / Home" + TouchExtraButtonAction.A -> "A" + TouchExtraButtonAction.B -> "B" + TouchExtraButtonAction.X -> "X" + TouchExtraButtonAction.Y -> "Y" + TouchExtraButtonAction.DpadUp -> "D-pad Up" + TouchExtraButtonAction.DpadDown -> "D-pad Down" + TouchExtraButtonAction.DpadLeft -> "D-pad Left" + TouchExtraButtonAction.DpadRight -> "D-pad Right" + TouchExtraButtonAction.LeftBumper -> "LB" + TouchExtraButtonAction.RightBumper -> "RB" + TouchExtraButtonAction.LeftTrigger -> "LT" + TouchExtraButtonAction.RightTrigger -> "RT" + TouchExtraButtonAction.LeftStickClick -> "L3 / LS" + TouchExtraButtonAction.RightStickClick -> "R3 / RS" + TouchExtraButtonAction.Start -> "Start" + TouchExtraButtonAction.Select -> "Select" +} + +internal fun touchControlGroupLabelRes(group: TouchControlGroup): Int = when (group) { + TouchControlGroup.FaceButtons -> R.string.settings_touch_control_face + TouchControlGroup.Dpad -> R.string.settings_touch_control_dpad + TouchControlGroup.LeftStick -> R.string.settings_touch_control_left_stick + TouchControlGroup.RightStick -> R.string.settings_touch_control_right_stick + TouchControlGroup.ShoulderButtons -> R.string.settings_touch_control_shoulders + TouchControlGroup.ThumbButtons -> R.string.settings_touch_control_thumb + TouchControlGroup.MenuButtons -> R.string.settings_touch_control_menu +} + +internal fun nextTouchExtraButtonAction(action: TouchExtraButtonAction): TouchExtraButtonAction { + val actions = TouchExtraButtonAction.entries + return actions[(actions.indexOf(action) + 1) % actions.size] +} + +private fun touchExtraButtonCapLabel(action: TouchExtraButtonAction): String = when (action) { + TouchExtraButtonAction.None -> "" + TouchExtraButtonAction.Guide -> "G" + TouchExtraButtonAction.DpadUp -> "↑" + TouchExtraButtonAction.DpadDown -> "↓" + TouchExtraButtonAction.DpadLeft -> "←" + TouchExtraButtonAction.DpadRight -> "→" + TouchExtraButtonAction.LeftBumper -> "LB" + TouchExtraButtonAction.RightBumper -> "RB" + TouchExtraButtonAction.LeftTrigger -> "LT" + TouchExtraButtonAction.RightTrigger -> "RT" + TouchExtraButtonAction.LeftStickClick -> "LS" + TouchExtraButtonAction.RightStickClick -> "RS" + TouchExtraButtonAction.Start -> "▶" + TouchExtraButtonAction.Select -> "◀" + else -> action.name +} + +private fun touchExtraButtonMask(action: TouchExtraButtonAction): Int? = when (action) { + TouchExtraButtonAction.Guide -> GamepadButtonMapping.GUIDE + TouchExtraButtonAction.A -> GamepadButtonMapping.A + TouchExtraButtonAction.B -> GamepadButtonMapping.B + TouchExtraButtonAction.X -> GamepadButtonMapping.X + TouchExtraButtonAction.Y -> GamepadButtonMapping.Y + TouchExtraButtonAction.DpadUp -> GamepadButtonMapping.DPAD_UP + TouchExtraButtonAction.DpadDown -> GamepadButtonMapping.DPAD_DOWN + TouchExtraButtonAction.DpadLeft -> GamepadButtonMapping.DPAD_LEFT + TouchExtraButtonAction.DpadRight -> GamepadButtonMapping.DPAD_RIGHT + TouchExtraButtonAction.LeftBumper -> GamepadButtonMapping.LEFT_SHOULDER + TouchExtraButtonAction.RightBumper -> GamepadButtonMapping.RIGHT_SHOULDER + TouchExtraButtonAction.LeftStickClick -> GamepadButtonMapping.LEFT_THUMB + TouchExtraButtonAction.RightStickClick -> GamepadButtonMapping.RIGHT_THUMB + TouchExtraButtonAction.Start -> GamepadButtonMapping.START + TouchExtraButtonAction.Select -> GamepadButtonMapping.BACK + TouchExtraButtonAction.None, + TouchExtraButtonAction.LeftTrigger, + TouchExtraButtonAction.RightTrigger, + -> null +} + +@Composable +private fun BoxScope.ExtraTouchButtons( + orientation: String, + actions: List, + scale: Float, + client: NativeStreamClient, + layoutEditing: Boolean, + getLocalOffset: (String) -> TouchOffset, + onLocalOffsetChange: (String, Float, Float) -> Unit, + onButtonTone: () -> Unit, + modifier: Modifier, +) { + actions.take(TOUCH_EXTRA_BUTTON_COUNT).forEachIndexed { index, action -> + if (action == TouchExtraButtonAction.None) return@forEachIndexed + val controlKey = "extra${index + 1}" + key("$orientation-$controlKey", action) { + TouchControlGroup( + id = "$orientation-$controlKey", + layoutEditing = layoutEditing, + offsetX = getLocalOffset(controlKey).x.dp, + offsetY = getLocalOffset(controlKey).y.dp, + onOffsetChange = { x, y -> onLocalOffsetChange(controlKey, x, y) }, + modifier = modifier, + ) { + GamepadActionButton( + action = action, + sourceId = "touch-$orientation-$controlKey", + client = client, + size = 44.dp * scale, + onPressTone = onButtonTone, + ) + } + } + } +} + +@Composable +private fun TouchControlGroup( + id: String, + layoutEditing: Boolean, + offsetX: Dp, + offsetY: Dp, + onOffsetChange: (Float, Float) -> Unit, + modifier: Modifier = Modifier, + content: @Composable BoxScope.() -> Unit, +) { + val density = LocalDensity.current + val currentOffsetX by rememberUpdatedState(offsetX) + val currentOffsetY by rememberUpdatedState(offsetY) + val currentOnOffsetChange by rememberUpdatedState(onOffsetChange) + Box( + modifier + .offset(x = offsetX, y = offsetY) + .onGloballyPositioned { coordinates -> + val bounds = coordinates.boundsInRoot() + NativeStreamInputRouter.setTouchControllerPassthroughBound( + id, + bounds.left.roundToInt(), + bounds.top.roundToInt(), + bounds.right.roundToInt(), + bounds.bottom.roundToInt(), + ) + }, + contentAlignment = Alignment.Center, + ) { + content() + if (layoutEditing) { + Box( + Modifier + .matchParentSize() + .clip(RoundedCornerShape(18.dp)) + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.16f)) + .border(1.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.72f), RoundedCornerShape(18.dp)) + .pointerInput(Unit) { + detectDragGestures { change, dragAmount -> + change.consume() + val deltaXDp = with(density) { dragAmount.x.toDp().value } + val deltaYDp = with(density) { dragAmount.y.toDp().value } + currentOnOffsetChange( + (currentOffsetX.value + deltaXDp).coerceIn(-280f, 280f), + (currentOffsetY.value + deltaYDp).coerceIn(-280f, 280f), + ) + } + }, + contentAlignment = Alignment.TopCenter, + ) { + Surface( + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.9f), + shape = RoundedCornerShape(999.dp), + modifier = Modifier.padding(top = 4.dp), + ) { + Text( + stringResource(R.string.touch_drag_label), + color = MaterialTheme.colorScheme.onPrimary, + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp), + ) + } + } + } + } + DisposableEffect(id) { + onDispose { + NativeStreamInputRouter.clearTouchControllerPassthroughBound(id) + } + } +} + +private fun clampStickOffset(offset: Offset, maxRadius: Float): Offset { + val distance = sqrt(offset.x * offset.x + offset.y * offset.y) + if (distance <= maxRadius || distance == 0f) return offset + val scale = maxRadius / distance + return Offset(offset.x * scale, offset.y * scale) +} + +internal fun touchStickValue( + deltaX: Float, + deltaY: Float, + maxTravel: Float, + deadZone: Float, + sensitivity: Float = 1f, +): Offset { + if (!deltaX.isFinite() || !deltaY.isFinite() || !maxTravel.isFinite() || maxTravel <= 0f) { + return Offset.Zero + } + val responsiveTravel = touchAimMaxTravel(maxTravel, sensitivity) + val clamped = clampStickOffset(Offset(deltaX, deltaY), responsiveTravel) + val rawX = (clamped.x / responsiveTravel).coerceIn(-1f, 1f) + val rawY = (clamped.y / responsiveTravel).coerceIn(-1f, 1f) + val magnitude = sqrt(rawX * rawX + rawY * rawY).coerceIn(0f, 1f) + val adjustedMagnitude = applyTouchJoystickDeadZone(magnitude, deadZone) + val adjustment = if (magnitude > 0f) adjustedMagnitude / magnitude else 0f + return Offset(rawX * adjustment, rawY * adjustment) +} + +internal fun scaledAimZoneFraction(baseFraction: Float, scale: Float): Float { + if (!baseFraction.isFinite() || baseFraction <= 0f) return 0f + val safeScale = if (scale.isFinite()) scale.coerceIn(0.5f, 1.5f) else 1f + return (baseFraction * safeScale).coerceIn(0f, 1f) +} + +internal fun touchAimMaxTravel(maxTravel: Float, sensitivity: Float): Float { + val safeSensitivity = if (sensitivity.isFinite()) sensitivity.coerceIn(0.25f, 3f) else 1f + return maxTravel / safeSensitivity +} + +internal fun applyTouchJoystickDeadZone(value: Float, deadZone: Float): Float { + val clampedValue = value.coerceIn(-1f, 1f) + val clampedDeadZone = deadZone.coerceIn(0f, 0.95f) + val magnitude = kotlin.math.abs(clampedValue) + if (magnitude <= clampedDeadZone) return 0f + val adjusted = (magnitude - clampedDeadZone) / (1f - clampedDeadZone) + return if (clampedValue < 0f) -adjusted else adjusted +} + +@Composable +private fun LockZoneAimSurface( + id: String, + client: NativeStreamClient, + opacity: Float, + deadZone: Float, + sensitivity: Float, + enabled: Boolean, + modifier: Modifier = Modifier, +) { + val density = LocalDensity.current + val currentOnChange by rememberUpdatedState(client::setVirtualRightStick) + var aimAnchor by remember { mutableStateOf(null) } + var aimOffset by remember { mutableStateOf(Offset.Zero) } + val maxTravelPx = with(density) { LOCK_ZONE_MAX_TRAVEL_DP.dp.toPx() } + val responsiveTravelPx = touchAimMaxTravel(maxTravelPx, sensitivity) + + DisposableEffect(client, id) { + onDispose { + client.setVirtualRightStick(0f, 0f) + NativeStreamInputRouter.clearTouchControllerPassthroughBound(id) + } + } + + Box( + modifier + .onGloballyPositioned { coordinates -> + val bounds = coordinates.boundsInRoot() + NativeStreamInputRouter.setTouchControllerPassthroughBound( + id, + bounds.left.roundToInt(), + bounds.top.roundToInt(), + bounds.right.roundToInt(), + bounds.bottom.roundToInt(), + ) + } + .pointerInput(client, deadZone, sensitivity, enabled, maxTravelPx) { + if (!enabled) return@pointerInput + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial) + val anchor = down.position + aimAnchor = anchor + aimOffset = Offset.Zero + + fun updateAim(position: Offset) { + val delta = position - anchor + val value = touchStickValue(delta.x, delta.y, maxTravelPx, deadZone, sensitivity) + currentOnChange(value.x, value.y) + aimOffset = clampStickOffset(delta, responsiveTravelPx) + } + + try { + updateAim(down.position) + down.consume() + while (true) { + val event = awaitPointerEvent(PointerEventPass.Initial) + val change = event.changes.firstOrNull { it.id == down.id } ?: break + if (!change.pressed) { + change.consume() + break + } + updateAim(change.position) + change.consume() + } + } finally { + currentOnChange(0f, 0f) + aimAnchor = null + aimOffset = Offset.Zero + } + } + }, + contentAlignment = Alignment.TopCenter, + ) { + val zoneColor = Color.White.copy(alpha = opacity * 0.22f) + Canvas(Modifier.matchParentSize()) { + drawRoundRect( + color = zoneColor, + cornerRadius = CornerRadius(22.dp.toPx()), + style = Stroke(width = 1.dp.toPx()), + ) + aimAnchor?.let { anchor -> + drawCircle( + color = Color.White.copy(alpha = opacity * 0.32f), + radius = 13.dp.toPx(), + center = anchor + aimOffset, + style = Stroke(width = 1.5.dp.toPx()), + ) + } + } + Text( + text = stringResource(R.string.stream_joysticks_aim_zone_label), + color = Color.White.copy(alpha = opacity * 0.46f), + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(top = 8.dp), + ) + } +} + +@Composable +private fun StickWithThumbButton( + stickLabel: String, + thumbLabel: String, + thumbMask: Int, + client: NativeStreamClient, + diameter: Dp, + buttonScale: Float, + mode: TouchJoystickMode = TouchJoystickMode.Fixed, + deadZone: Float = 0f, + onButtonTone: () -> Unit, + onChange: (Float, Float) -> Unit, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + GamepadPillButton( + label = thumbLabel, + mask = thumbMask, + client = client, + width = 56.dp * buttonScale, + height = 34.dp * buttonScale, + onPressTone = onButtonTone, + ) + VirtualStick( + label = stickLabel, + client = client, + diameter = diameter, + mode = mode, + deadZone = deadZone, + onChange = onChange, + ) + } +} + +@Composable +private fun VirtualStick( + label: String, + client: NativeStreamClient, + diameter: androidx.compose.ui.unit.Dp, + mode: TouchJoystickMode, + deadZone: Float, + onChange: (Float, Float) -> Unit, +) { + val currentOnChange by rememberUpdatedState(onChange) + var knobOffset by remember { mutableStateOf(Offset.Zero) } + var baseOffset by remember { mutableStateOf(Offset.Zero) } + + DisposableEffect(client) { + onDispose { + currentOnChange(0f, 0f) + } + } + + Box( + Modifier + .size(diameter) + .pointerInput(client, mode, deadZone) { + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial) + val fixedCenter = Offset(size.width / 2f, size.height / 2f) + val gestureCenter = if (mode == TouchJoystickMode.Dynamic) down.position else fixedCenter + val maxRadius = min(size.width, size.height) * 0.34f + baseOffset = gestureCenter - fixedCenter + + fun updateStick(position: Offset) { + val clamped = clampStickOffset(position - gestureCenter, maxRadius) + val value = touchStickValue(clamped.x, clamped.y, maxRadius, deadZone) + currentOnChange(value.x, value.y) + knobOffset = clamped + } + + try { + updateStick(down.position) + down.consume() + while (true) { + val event = awaitPointerEvent(PointerEventPass.Initial) + val change = event.changes.firstOrNull { it.id == down.id } ?: break + if (!change.pressed) { + change.consume() + break + } + updateStick(change.position) + change.consume() + } + } finally { + currentOnChange(0f, 0f) + knobOffset = Offset.Zero + baseOffset = Offset.Zero + } + } + }, + contentAlignment = Alignment.Center, + ) { + TouchStickFace(diameter = diameter, base = { baseOffset }, knob = { knobOffset }) + } +} + +private const val LOCK_ZONE_MAX_TRAVEL_DP = 72f + +@Composable +private fun FaceButtonCluster(client: NativeStreamClient, scale: Float, onButtonTone: () -> Unit) { + val buttonSize = 54.dp * scale + val distance = buttonSize * 1.05f + val boxSize = distance * 2 + buttonSize + Box(Modifier.size(boxSize)) { + Box(Modifier.align(Alignment.Center).offset(y = -distance)) { + GamepadButton("Y", 0x8000, client, buttonSize, onButtonTone) + } + Box(Modifier.align(Alignment.Center).offset(y = distance)) { + GamepadButton("A", 0x1000, client, buttonSize, onButtonTone) + } + Box(Modifier.align(Alignment.Center).offset(x = -distance)) { + GamepadButton("X", 0x4000, client, buttonSize, onButtonTone) + } + Box(Modifier.align(Alignment.Center).offset(x = distance)) { + GamepadButton("B", 0x2000, client, buttonSize, onButtonTone) + } + } +} + +@Composable +private fun DpadCluster(client: NativeStreamClient, scale: Float, onButtonTone: () -> Unit) { + val currentOnButtonTone by rememberUpdatedState(onButtonTone) + val buttonSize = 54.dp * scale + val boxSize = touchDpadBoxSize(buttonSize) + + var upPressed by remember { mutableStateOf(false) } + var downPressed by remember { mutableStateOf(false) } + var leftPressed by remember { mutableStateOf(false) } + var rightPressed by remember { mutableStateOf(false) } + + DisposableEffect(client) { + onDispose { + client.setVirtualButton(0x0001, false) + client.setVirtualButton(0x0002, false) + client.setVirtualButton(0x0004, false) + client.setVirtualButton(0x0008, false) + } + } + + Box( + Modifier + .size(boxSize) + .pointerInput(client) { + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial) + + fun updateDirection(position: Offset) { + val w = size.width + val h = size.height + val cx = w / 2f + val cy = h / 2f + val px = position.x + val py = position.y + val dx = px - cx + val dy = py - cy + val touchDist = Math.sqrt((dx * dx + dy * dy).toDouble()).toFloat() + val deadzone = 12.dp.toPx() + var newUp = false + var newDown = false + var newLeft = false + var newRight = false + if (touchDist > deadzone) { + val absDx = Math.abs(dx) + val absDy = Math.abs(dy) + if (dy < 0 && absDy > absDx * 0.414f) newUp = true + if (dy > 0 && absDy > absDx * 0.414f) newDown = true + if (dx < 0 && absDx > absDy * 0.414f) newLeft = true + if (dx > 0 && absDx > absDy * 0.414f) newRight = true + } + + val playTone = (!upPressed && newUp) || (!downPressed && newDown) || + (!leftPressed && newLeft) || (!rightPressed && newRight) + if (upPressed != newUp) { client.setVirtualButton(0x0001, newUp); upPressed = newUp } + if (downPressed != newDown) { client.setVirtualButton(0x0002, newDown); downPressed = newDown } + if (leftPressed != newLeft) { client.setVirtualButton(0x0004, newLeft); leftPressed = newLeft } + if (rightPressed != newRight) { client.setVirtualButton(0x0008, newRight); rightPressed = newRight } + if (playTone) currentOnButtonTone() + } + + try { + updateDirection(down.position) + down.consume() + while (true) { + val event = awaitPointerEvent(PointerEventPass.Initial) + val change = event.changes.firstOrNull { it.id == down.id } ?: break + if (!change.pressed) { + change.consume() + break + } + updateDirection(change.position) + change.consume() + } + } finally { + if (upPressed) { client.setVirtualButton(0x0001, false); upPressed = false } + if (downPressed) { client.setVirtualButton(0x0002, false); downPressed = false } + if (leftPressed) { client.setVirtualButton(0x0004, false); leftPressed = false } + if (rightPressed) { client.setVirtualButton(0x0008, false); rightPressed = false } + } + } + } + ) { + TouchDpadFace( + arm = buttonSize, + up = upPressed, + down = downPressed, + left = leftPressed, + right = rightPressed, + ) + } +} + +private fun Modifier.virtualPressInput( + client: NativeStreamClient, + controlKey: Any, + onPressedChange: State<(Boolean) -> Unit>, +): Modifier = pointerInput(client, controlKey) { + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial) + onPressedChange.value(true) + try { + down.consume() + while (true) { + val event = awaitPointerEvent(PointerEventPass.Initial) + val change = event.changes.firstOrNull { it.id == down.id } ?: break + if (!change.pressed) { + change.consume() + break + } + change.consume() + } + } finally { + onPressedChange.value(false) + } + } +} + +@Composable +private fun GamepadTriggerButton( + label: String, + left: Boolean, + client: NativeStreamClient, + width: androidx.compose.ui.unit.Dp, + height: androidx.compose.ui.unit.Dp, + onPressTone: () -> Unit = {}, +) { + var pressed by remember { mutableStateOf(false) } + val currentOnPressedChange = rememberUpdatedState<(Boolean) -> Unit> { down -> + if (down != pressed) { + client.setVirtualTrigger(left, down) + pressed = down + if (down) onPressTone() + } + } + Box( + Modifier + .width(width) + .heightIn(min = 48.dp) + .virtualPressInput(client, left, currentOnPressedChange), + contentAlignment = Alignment.TopCenter, + ) { + TouchShoulderFace(label = label, pressed = pressed, width = width, height = height) + } + DisposableEffect(client, left) { + onDispose { + client.setVirtualTrigger(left, false) + } + } +} + +@Composable +private fun GamepadBumperButton( + label: String, + mask: Int, + client: NativeStreamClient, + width: androidx.compose.ui.unit.Dp, + height: androidx.compose.ui.unit.Dp, + onPressTone: () -> Unit = {}, +) { + var pressed by remember { mutableStateOf(false) } + val currentOnPressedChange = rememberUpdatedState<(Boolean) -> Unit> { down -> + if (down != pressed) { + client.setVirtualButton(mask, down) + pressed = down + if (down) onPressTone() + } + } + Box( + Modifier + .width(width) + .height(height) + .virtualPressInput(client, mask, currentOnPressedChange), + contentAlignment = Alignment.Center, + ) { + TouchShoulderFace(label = label, pressed = pressed, width = width, height = height) + } + DisposableEffect(client, mask) { + onDispose { + client.setVirtualButton(mask, false) + } + } +} + +@Composable +private fun GamepadActionButton( + action: TouchExtraButtonAction, + sourceId: String, + client: NativeStreamClient, + size: Dp, + onPressTone: () -> Unit, +) { + val currentOnPressTone by rememberUpdatedState(onPressTone) + var pressed by remember(action, sourceId) { mutableStateOf(false) } + + fun dispatch(down: Boolean) { + when (action) { + TouchExtraButtonAction.LeftTrigger -> client.setVirtualTriggerFromSource(true, sourceId, down) + TouchExtraButtonAction.RightTrigger -> client.setVirtualTriggerFromSource(false, sourceId, down) + else -> touchExtraButtonMask(action)?.let { mask -> + client.setVirtualButtonFromSource(mask, sourceId, down) + } + } + } + + val currentOnPressedChange = rememberUpdatedState<(Boolean) -> Unit> { down -> + if (down != pressed) { + dispatch(down) + pressed = down + if (down) currentOnPressTone() + } + } + Box( + Modifier + .sizeIn(minWidth = 48.dp, minHeight = 48.dp) + .virtualPressInput(client, "$sourceId-${action.name}", currentOnPressedChange), + contentAlignment = Alignment.Center, + ) { + TouchCapFace(label = touchExtraButtonCapLabel(action), pressed = pressed, diameter = size) + } + DisposableEffect(client, action, sourceId) { + onDispose { dispatch(false) } + } +} + +@Composable +private fun GamepadButton( + label: String, + mask: Int, + client: NativeStreamClient, + size: androidx.compose.ui.unit.Dp, + onPressTone: () -> Unit = {}, +) { + val currentOnPressTone by rememberUpdatedState(onPressTone) + var pressed by remember { mutableStateOf(false) } + val currentOnPressedChange = rememberUpdatedState<(Boolean) -> Unit> { down -> + if (down != pressed) { + client.setVirtualButton(mask, down) + pressed = down + if (down) currentOnPressTone() + } + } + Box( + Modifier + .sizeIn(minWidth = 48.dp, minHeight = 48.dp) + .virtualPressInput(client, mask, currentOnPressedChange), + contentAlignment = Alignment.Center, + ) { + TouchCapFace(label = label, pressed = pressed, diameter = size) + } + DisposableEffect(client, mask) { + onDispose { + client.setVirtualButton(mask, false) + } + } +} + +@Composable +private fun GamepadPillButton( + label: String, + mask: Int, + client: NativeStreamClient, + width: androidx.compose.ui.unit.Dp, + height: androidx.compose.ui.unit.Dp, + onPressTone: () -> Unit = {}, +) { + val currentOnPressTone by rememberUpdatedState(onPressTone) + var pressed by remember { mutableStateOf(false) } + val currentOnPressedChange = rememberUpdatedState<(Boolean) -> Unit> { down -> + if (down != pressed) { + client.setVirtualButton(mask, down) + pressed = down + if (down) currentOnPressTone() + } + } + Box( + Modifier + .width(width) + .height(height) + .virtualPressInput(client, mask, currentOnPressedChange), + contentAlignment = Alignment.Center, + ) { + TouchShoulderFace(label = label, pressed = pressed, width = width, height = height) + } + DisposableEffect(client, mask) { + onDispose { + client.setVirtualButton(mask, false) + } + } +} diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowViewModel.kt b/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowViewModel.kt new file mode 100644 index 000000000..c47e6d48b --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowViewModel.kt @@ -0,0 +1,4714 @@ +package com.opencloudgaming.opennow + +import android.app.Application +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.SystemClock +import android.util.Log +import android.widget.Toast +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import androidx.compose.runtime.Immutable +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withContext +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.OkHttpClient +import java.text.SimpleDateFormat +import java.text.DateFormat +import java.util.Date +import java.util.Locale + +enum class AppPage { + Home, + Library, + Settings, + Stream, +} + +enum class SettingsRouteTarget { + Account, + General, + Stream, + Interface, +} + +internal fun canMinimizeStreamLaunch(streamStatus: String, sessionReady: Boolean): Boolean = + streamStatus != "idle" && !sessionReady + +internal fun manuallySelectedServerForReport( + streamingBaseUrlOverride: String?, + configuredRegion: String, +): Boolean = !streamingBaseUrlOverride.isNullOrBlank() || configuredRegion.isNotBlank() + +/** Keeps the exact old-session GET visible after its full JSON payload rotates out. */ +internal fun recoverySessionProbeDebugSummary(response: GfnSessionDiagnosticResponse): String? { + if (!response.operation.startsWith("session.recovery.probe")) return null + val payload = runCatching { OpenNowJson.parseToJsonElement(response.responseBody).jsonObject }.getOrNull() + val requestStatus = payload?.get("requestStatus") as? JsonObject + val session = payload?.get("session") as? JsonObject + fun JsonObject?.intValue(key: String): Int? = + this?.get(key)?.jsonPrimitive?.intOrNull + fun JsonObject?.stringValue(key: String): String? = + this?.get(key)?.jsonPrimitive?.contentOrNull + val sessionId = response.url.toHttpUrlOrNull() + ?.pathSegments + ?.lastOrNull() + ?.takeIf { it.isNotBlank() } + ?.let(::shortDebugId) + .orEmpty() + return buildString { + append("Old session GET") + if (sessionId.isNotBlank()) append(" session=$sessionId") + append(" source=${response.operation}") + append(" http=${response.statusCode}") + append(" requestStatus=${requestStatus.intValue("statusCode") ?: "unknown"}") + append(" description=${requestStatus.stringValue("statusDescription").orEmpty().ifBlank { "unknown" }}") + append(" unifiedError=${requestStatus.stringValue("unifiedErrorCode").orEmpty().ifBlank { "unknown" }}") + append(" sessionStatus=${session.intValue("status") ?: "unknown"}") + append(" sessionError=${session.intValue("errorCode") ?: "unknown"}") + } +} + +internal fun knownSessionRecoveryCandidate( + session: SessionInfo, + appId: Int, + fallbackActive: ActiveSessionInfo?, + settings: StreamSettings, +): ActiveSessionInfo? { + if (session.serverIp.isBlank() || appId <= 0) return null + val (width, height) = streamResolutionPixels(settings) + return ActiveSessionInfo( + sessionId = session.sessionId, + appId = appId, + gpuType = session.gpuType ?: fallbackActive?.gpuType, + status = session.status.takeIf { it in setOf(2, 3) } ?: 2, + queuePosition = session.queuePosition, + seatSetupStep = session.seatSetupStep, + streamingBaseUrl = session.streamingBaseUrl ?: fallbackActive?.streamingBaseUrl, + serverIp = session.serverIp, + signalingUrl = session.signalingUrl.takeIf { it.isNotBlank() } ?: fallbackActive?.signalingUrl, + resolution = fallbackActive?.resolution ?: "${width}x$height", + fps = fallbackActive?.fps ?: settings.fps, + settingsSignature = fallbackActive?.settingsSignature ?: streamSettingsSessionSignature(settings), + ) +} + +private const val ANDROID_UPDATE_LAUNCH_CHECK_DELAY_MS = 5_000L +internal const val ANDROID_UPDATE_PERIODIC_CHECK_INTERVAL_MS = 6L * 60L * 60L * 1000L +private const val ANDROID_UPDATE_STREAMING_RETRY_DELAY_MS = 30_000L +private const val DEBUG_EVENT_LIMIT = 140 +private const val DEBUG_EVENT_MESSAGE_LIMIT = 640 +private const val DEBUG_PAYLOAD_LIMIT = 12 +private const val DEBUG_PAYLOAD_BODY_LIMIT = 8_000 +private const val LOGIN_PHASE_GETTING_TOKENS = "Getting sign-in tokens" +private const val STREAM_RUNTIME_STATS_EVENT_INTERVAL_MS = 30_000L +private const val SESSION_REPORT_NETWORK_SAMPLE_INTERVAL_MS = 5_000L +private const val ACTIVE_DIAGNOSTIC_SNAPSHOT_INTERVAL_MS = 10_000L +private const val IDLE_DIAGNOSTIC_SNAPSHOT_INTERVAL_MS = 60_000L + +internal class StreamSessionRecoveryTracker { + private var sessionId: String? = null + private var attempts: Int = 0 + + fun nextAttempt(currentSessionId: String): Int { + if (sessionId != currentSessionId) { + sessionId = currentSessionId + attempts = 0 + } + attempts += 1 + return attempts + } + + fun reset() { + sessionId = null + attempts = 0 + } +} + +internal enum class StreamSessionRecoveryDisposition { + ReclaimAllocatedSession, + ReportEndedSession, +} + +/** + * Automatic recovery is deliberately pinned to the allocated session. The attempt count is kept + * for diagnostics, but it must never become permission to stop that session and create a new one. + */ +internal fun streamSessionRecoveryDisposition( + recoveryAttempt: Int, + probedStatus: Int?, +): StreamSessionRecoveryDisposition { + require(recoveryAttempt > 0) + return if (probedStatus != null && isTerminalSessionStatus(probedStatus)) { + StreamSessionRecoveryDisposition.ReportEndedSession + } else { + StreamSessionRecoveryDisposition.ReclaimAllocatedSession + } +} + +internal fun isLikelyDirectSessionServerUrl(value: String): Boolean { + val host = value.toHttpUrlOrNull()?.host ?: return false + fun isIpv4Parts(parts: List): Boolean = + parts.size == 4 && parts.all { part -> part.toIntOrNull() in 0..255 } + + return isIpv4Parts(host.split('.')) || isIpv4Parts(host.substringBefore('.').split('-')) +} + +private data class DebugLogEvent( + val timestampMs: Long, + val category: String, + val message: String, +) + +private data class DebugPayloadEvent( + val timestampMs: Long, + val operation: String, + val method: String, + val url: String, + val statusCode: Int, + val requestBody: String, + val body: String, +) + +private data class TimedStreamRuntimeStats( + val capturedAtMs: Long, + val sessionId: String?, + val stats: StreamRuntimeStats, +) + +data class ActiveSessionDecision( + val activeSession: ActiveSessionInfo, + val requestedGameTitle: String, +) + +@Immutable +data class DiagnosticShareState( + val awaitingConsent: Boolean = false, + val uploading: Boolean = false, + val pasteUrl: String? = null, + val clipboardSummary: String? = null, + val error: String? = null, +) + +@Immutable +data class BugReportSubmissionState( + val uploading: Boolean = false, + val submitted: Boolean = false, + val reference: String? = null, + val error: String? = null, +) + +private data class PendingActiveSessionLaunch( + val game: GameInfo, + val launchAppId: String, + val baseUrl: String, + val settings: StreamSettings, + val accountLinked: Boolean, + val activeSession: ActiveSessionInfo, + val returnPage: AppPage, +) + +@Immutable +data class OpenNowUiState( + val initializing: Boolean = false, + val page: AppPage = AppPage.Home, + val authSession: AuthSession? = null, + val providers: List = listOf(defaultProvider()), + val selectedProvider: LoginProvider = defaultProvider(), + val savedAccounts: List = emptyList(), + val subscriptionInfo: SubscriptionInfo? = null, + val accountConnectors: List = emptyList(), + val loadingAccountConnectors: Boolean = false, + val connectorActionStore: String? = null, + val regions: List = emptyList(), + val games: List = emptyList(), + /** Dedicated provider-ordered feed for the portrait Store hero. */ + val newlyAddedGames: List = emptyList(), + val libraryGames: List = emptyList(), + val queuedGameKeys: List = emptyList(), + val catalogResult: CatalogBrowseResult = CatalogBrowseResult(emptyList()), + val catalogSearch: String = "", + val librarySearch: String = "", + val catalogSortId: String = DEFAULT_CATALOG_SORT_ID, + val catalogFilterIds: List = emptyList(), + val libraryFilterIds: List = emptyList(), + val librarySortId: String = LIBRARY_SORT_DEFAULT, + val loadingGames: Boolean = false, + /** True only while the selected Store search/sort/filter combination has no cached result. */ + val catalogQueryLoading: Boolean = false, + val settingsRefreshing: Boolean = false, + val settingsRouteTarget: SettingsRouteTarget? = null, + val settings: AppSettings = AppSettings(), + val androidTvProfile: Boolean = false, + val codecReport: RuntimeCodecReport? = null, + val recommendedStreamSettings: StreamSettings? = null, + val selectedGame: GameInfo? = null, + val activeSession: ActiveSessionInfo? = null, + val activeSessionDecision: ActiveSessionDecision? = null, + val streamSession: SessionInfo? = null, + val manuallySelectedServerForReport: Boolean = false, + val activeStreamSettings: StreamSettings? = null, + val streamInputModeAtLaunch: StreamInputMode? = null, + val streamGame: GameInfo? = null, + val streamLaunchMinimized: Boolean = false, + val streamReturnPage: AppPage? = null, + val launchPhase: String = "", + val queuePosition: Int? = null, + val queueAdActiveId: String? = null, + val streamStatus: String = "idle", + val error: String? = null, + val deviceLoginPrompt: DeviceLoginPrompt? = null, + val pendingStoreChoiceGame: GameInfo? = null, + /** Set when Play was pressed on a game whose membership tier this account cannot meet. */ + val pendingMembershipNotice: PendingMembershipNotice? = null, + val pendingPrintedWasteGame: GameInfo? = null, + val printedWasteQueue: Map = emptyMap(), + val printedWasteMapping: Map = emptyMap(), + val printedWastePings: Map = emptyMap(), + val printedWasteLoading: Boolean = false, + val printedWasteError: String? = null, + val androidUpdate: AndroidUpdateState = AndroidUpdateState(), + val dismissedAndroidUpdateNoticeKey: String? = null, + val androidPictureInPictureActive: Boolean = false, + val diagnosticShare: DiagnosticShareState = DiagnosticShareState(), + val bugReportSubmission: BugReportSubmissionState = BugReportSubmissionState(), + val bugReportVersionCheck: AndroidBugReportVersionCheckState = AndroidBugReportVersionCheckState(), + val loginToolsVisible: Boolean = false, + val localTvConnector: LocalTvConnectorState = LocalTvConnectorState(), + val remoteStreamMenuRequestToken: Int = 0, + val remoteStatsToggleRequestToken: Int = 0, + val sessionReport: SessionReport? = null, +) + +internal fun OpenNowUiState.isAndroidUpdateCheckBlockedByStream(): Boolean = + streamStatus != "idle" || streamSession != null || activeStreamSettings != null + +/** + * Whether the catalogue currently has anything to show. + * + * The Store, the Library and the cached "main" list all feed off the same fetch, so any one of + * them holding games means that fetch has landed at least once. + */ +internal fun OpenNowUiState.hasLoadedCatalogGames(): Boolean = + games.isNotEmpty() || catalogResult.games.isNotEmpty() || libraryGames.isNotEmpty() + +/** + * The catalogue was fetched exactly once per ViewModel with no retry, so a single failed attempt — + * no network yet on a cold start, or sockets torn down while an aggressive OEM memory manager held + * the process frozen — left the Store empty until the reader happened to pull-to-refresh. These + * bound an automatic ladder instead. + */ +internal const val CATALOG_RETRY_MAX_ATTEMPTS = 4 +internal const val CATALOG_RETRY_BASE_DELAY_MS = 2_000L +internal const val CATALOG_RETRY_MAX_DELAY_MS = 30_000L + +internal fun catalogRetryDelayMs(attempt: Int): Long = + (CATALOG_RETRY_BASE_DELAY_MS shl attempt.coerceIn(0, 16)).coerceAtMost(CATALOG_RETRY_MAX_DELAY_MS) + +/** + * [loadAttempted] keeps the foreground hook from racing the first-run load: on a cold start the + * Activity resumes before the bootstrap has asked for anything, and firing here would run a second + * identical fetch alongside it. + */ +/** + * Whether the Store should still read as loading after the cache has been applied. + * + * The old rule asked whether a cache entry existed for this exact query, not whether anything + * landed on screen. Priming from a library-only cache satisfied that test while leaving `games` + * empty, which dropped the spinner and rendered "No games loaded" over a fetch that was still in + * flight — the empty state, shown as if the request had already come back with nothing. + */ +internal const val CATALOG_SORT_DEFAULT = DEFAULT_CATALOG_SORT_ID + +/** + * A query narrower than "the whole catalogue in its default order". + * + * Cached results are keyed by the exact query that produced them, so a scoped query cannot borrow + * the unscoped cache: showing default-ordered games under a user-chosen sort would be visibly + * wrong rather than merely stale. + */ +internal fun isScopedCatalogQuery( + searchQuery: String, + sortId: String, + filterIds: List, +): Boolean = searchQuery.isNotBlank() || filterIds.isNotEmpty() || sortId != CATALOG_SORT_DEFAULT + +/** Identifies the catalogue cache entry a query reads from. Filters sort to match the store's key. */ +internal data class CatalogCacheKey( + val userId: String, + val baseUrl: String, + val searchQuery: String, + val sortId: String, + val filterIds: List, +) { + companion object { + fun of( + userId: String, + baseUrl: String, + searchQuery: String, + sortId: String, + filterIds: List, + ): CatalogCacheKey = CatalogCacheKey(userId, baseUrl, searchQuery, sortId, filterIds.sorted()) + } +} + +internal class CatalogCacheSnapshot( + val key: CatalogCacheKey, + val main: List?, + val library: List?, + val catalog: CatalogBrowseResult?, + val newlyAdded: CatalogBrowseResult? = null, +) + +internal fun isNewlyAddedCatalogQuery( + searchQuery: String, + sortId: String, + filterIds: List, +): Boolean = searchQuery.isBlank() && + filterIds.isEmpty() && + catalogSortKind(sortId) == CatalogSortKind.NewlyAdded + +/** The games a primed snapshot can put on the Store grid, or empty when it has nothing usable. */ +internal fun primedStoreGames(snapshot: CatalogCacheSnapshot): List { + snapshot.catalog?.let { return it.games } + val scoped = isScopedCatalogQuery(snapshot.key.searchQuery, snapshot.key.sortId, snapshot.key.filterIds) + return if (scoped) emptyList() else snapshot.main.orEmpty() +} + +internal fun catalogStillLoadingAfterCache( + hasGamesToShow: Boolean, + keepRefreshVisible: Boolean, +): Boolean = !hasGamesToShow || keepRefreshVisible + +internal fun shouldRetryCatalogLoad( + signedIn: Boolean, + loadAttempted: Boolean, + hasGames: Boolean, + loadInFlight: Boolean, + streamActive: Boolean, +): Boolean = signedIn && loadAttempted && !hasGames && !loadInFlight && !streamActive + +internal fun OpenNowUiState.isNativeStreamReady(): Boolean = + streamStatus in setOf("connecting", "streaming") && + streamSession?.isReadyForStream() == true + +/** + * Native-touch capability is a local catalogue filter, so mobile must inspect every server page + * before applying it. Other mobile browsing remains bounded. TV keeps the smallest startup request, + * then uses the normal bounded budget for an explicit search or filter so scoped results are complete. + */ +internal fun catalogPageLimit( + androidTvProfile: Boolean, + filterIds: List, + searchQuery: String = "", +): Int = when { + androidTvProfile && searchQuery.isBlank() && filterIds.isEmpty() -> 1 + androidTvProfile -> TV_SCOPED_CATALOG_REQUEST_PAGES + CATALOG_FILTER_TOUCHSCREEN in filterIds -> MAX_CATALOG_REQUEST_PAGES + else -> 3 +} + +private const val TV_SCOPED_CATALOG_REQUEST_PAGES = 3 + +private fun List.withHydratedGameDetails(details: GameInfo): List { + val detailsKey = gameTrackingKey(details) + return map { game -> + if (gameTrackingKey(game) == detailsKey) mergeGameInfo(game, details) else game + } +} + +internal fun shouldHydrateGameDetails(game: GameInfo): Boolean = + game.genres.isEmpty() && !game.uuid.isNullOrBlank() + +class OpenNowViewModel(application: Application) : AndroidViewModel(application) { + private val openNowApplication = application as OpenNowApplication + private val http: OkHttpClient = openNowApplication.httpClient + private val settingsStore = SettingsStore(application) + private val sessionTimerAnchorStore = SessionTimerAnchorStore(application) + private val authStore = openNowApplication.authStore + private val authRepository = openNowApplication.authRepository + private val catalogRepository = GfnCatalogRepository(http) { + gfnLocaleForAndroidLanguageTag(currentAndroidAppLocale(getApplication()).effectiveLanguageTag) + } + private val catalogCacheStore = CatalogCacheStore(application) + private val queuedGameStore = QueuedGameStore(application) + private val subscriptionRepository = GfnSubscriptionRepository(http) + private val accountConnectorRepository = GfnAccountConnectorRepository(http) + private val printedWasteRepository = PrintedWasteRepository(http) + private val sessionRepository = GfnSessionRepository( + authStore = authStore, + http = http, + physicalDisplayResolutionProvider = { application.physicalStreamDisplayResolution() }, + diagnosticsSink = { response -> recordSessionDiagnosticResponse(response) }, + isAndroidTv = isAndroidTvProfile(application), + ) + private val appUpdater = AndroidAppUpdater(application, http) + private val androidUpdateNoticeStore = AndroidUpdateNoticeStore(application) + private val localTvConnector = openNowApplication.localTvConnector + private val diagnosticHistoryStore = openNowApplication.diagnosticHistoryStore + private val queueAdReportMutex = Mutex() + private val accountConnectorRefreshMutex = Mutex() + private val runtimeResolutionNoticeKeys = mutableSetOf() + private val debugEventsLock = Any() + private val debugEvents = ArrayDeque() + private val debugPayloadsLock = Any() + private val debugPayloads = ArrayDeque() + private val authRestoreMutex = Mutex() + @Volatile + private var latestStreamRuntimeStats: TimedStreamRuntimeStats? = null + private var lastRuntimeStatsEventAtMs: Long = 0L + private var streamReportLaunchProfile: StreamReportLaunchProfile? = null + private var streamSessionReportAccumulator: StreamSessionReportAccumulator? = null + private var lastSessionReportNetworkSampleAtMs: Long = 0L + private var sessionReportFinalizedForStop: Boolean = false + private var deviceRecommendation: AndroidDeviceRecommendation? = null + /** + * Completes when the codec probe has landed. + * + * The probe is no longer on the path to first paint, so anything that depends on device + * capability — only stream launch does — waits on this instead of on startup order. + */ + private val deviceCapabilityProbe = CompletableDeferred() + private val settingsDiagnosticTapTracker = RapidTapTracker() + private val loginIconTapTracker = RapidTapTracker() + private val streamSessionRecoveryTracker = StreamSessionRecoveryTracker() + + private val initialAuthSession = authStore.activeSession() + private val androidTvProfile = isAndroidTvProfile(application) + private val initialSettings = settingsStore.settings.value.let { current -> + if (androidTvProfile && current.tvLayoutProfileVersion < TV_LAYOUT_PROFILE_VERSION) { + settingsStore.update { saved -> + saved.copy( + // 36dp on every edge consumed 144 physical pixels per axis at the + // common TV density. Migrate only the legacy default; preserve custom values. + tvSafeAreaPaddingDp = if (saved.tvSafeAreaPaddingDp == 36f) 16f else saved.tvSafeAreaPaddingDp, + tvLayoutProfileVersion = TV_LAYOUT_PROFILE_VERSION, + ) + } + settingsStore.settings.value + } else { + current + } + } + private val _state = MutableStateFlow( + OpenNowUiState( + page = defaultLaunchAppPage(initialSettings), + authSession = initialAuthSession, + providers = initialProviders(initialAuthSession), + selectedProvider = authStore.state.value.selectedProvider ?: initialAuthSession?.provider ?: defaultProvider(), + savedAccounts = authStore.state.value.sessions.map { session -> session.toSavedAccount() }, + loadingGames = initialAuthSession != null, + settings = initialSettings, + catalogSortId = initialSettings.catalogSortId, + catalogFilterIds = initialSettings.catalogFilterIds, + librarySortId = initialSettings.librarySortId, + libraryFilterIds = initialSettings.libraryFilterIds, + androidTvProfile = androidTvProfile, + androidUpdate = appUpdater.state.value, + dismissedAndroidUpdateNoticeKey = androidUpdateNoticeStore.dismissedKey(), + queuedGameKeys = queuedGameStore.load(), + ), + ) + val state: StateFlow = _state.asStateFlow() + + private var gamesJob: Job? = null + private var gameDetailsJob: Job? = null + private var launchJob: Job? = null + private var activeSubscriptionJob: Job? = null + private var pendingActiveSessionLaunch: PendingActiveSessionLaunch? = null + private var loginJob: Job? = null + private var androidUpdateJob: Job? = null + private var androidUpdateAutoJob: Job? = null + private var bugReportUpdateVerificationJob: Job? = null + private var bugReportUpdateCheckActive: Boolean = false + private var settingsRefreshJob: Job? = null + private var authRefreshJob: Job? = null + private var catalogRetryJob: Job? = null + /** Handed to [refreshAfterAuth] so startup parses the cache once, not twice. */ + @Volatile + private var primedCatalogCache: CatalogCacheSnapshot? = null + private var catalogRetryAttempt = 0 + /** False until the first fetch has been asked for; see [shouldRetryCatalogLoad]. */ + private var catalogLoadAttempted = false + + init { + viewModelScope.launch { + settingsStore.settings.collect { next -> + OpenNowAnalytics.applyOptOut(!next.analyticsSharingEnabled) + _state.update { it.copy(settings = next) } + } + } + if (androidTvProfile) { + viewModelScope.launch { + settingsStore.settings + .map { it.localTvRemoteEnabled } + .distinctUntilChanged() + .collect { enabled -> + if (enabled) localTvConnector.startHosting() else localTvConnector.stopHosting() + } + } + } + viewModelScope.launch { + localTvConnector.state.collect { next -> + _state.update { it.copy(localTvConnector = next) } + } + } + viewModelScope.launch { + localTvConnector.launchRequests.collect { request -> + if (!state.value.androidTvProfile) return@collect + val allGames = state.value.games + state.value.libraryGames + val game = allGames.firstOrNull { game -> + game.id == request.gameId || + game.uuid == request.gameId || + game.launchAppId == request.gameId || + game.variants.any { it.id == request.gameId } + } ?: GameInfo( + id = request.gameId, + uuid = request.gameId, + launchAppId = request.gameId.takeIf { it.all(Char::isDigit) }, + title = request.title ?: "Game ${request.gameId}", + variants = listOf(GameVariant(id = request.gameId, store = "Unknown")), + ) + recordDebugEvent("tv-connector", "Accepted encrypted local launch game=${game.title}") + play(game) + } + } + viewModelScope.launch { + localTvConnector.signInRequests.collect { transferredSession -> + if (!state.value.androidTvProfile) return@collect + acceptLocalTvSignIn(transferredSession) + } + } + viewModelScope.launch { + localTvConnector.remoteRequests.collect { request -> + if (!state.value.androidTvProfile) return@collect + handleLocalTvRemoteRequest(request) + } + } + viewModelScope.launch { + appUpdater.state.collect { next -> + _state.update { it.copy(androidUpdate = next) } + } + } + viewModelScope.launch { + state + .map { it.isAndroidUpdateCheckBlockedByStream() to it.androidUpdate.status } + .distinctUntilChanged() + .collect { (blocked, updateStatus) -> + if (blocked && updateStatus == AndroidUpdateStatus.Checking && !bugReportUpdateCheckActive) { + cancelAndroidUpdateCheckForStreaming() + } + } + } + if (appUpdater.state.value.updateChecksSupported) { + startAndroidUpdateAutoChecks() + } + startDiagnosticSnapshotPersistence() + primeCatalogFromCache() + startDeviceCapabilityProbe() + initialize() + } + + private fun startDiagnosticSnapshotPersistence() { + viewModelScope.launch { + state + .map { snapshot -> + snapshot.streamStatus != "idle" || + snapshot.streamSession != null || + snapshot.activeStreamSettings != null + } + .distinctUntilChanged() + .collectLatest { streamActive -> + persistCurrentDiagnosticSnapshot() + val intervalMs = if (streamActive) { + ACTIVE_DIAGNOSTIC_SNAPSHOT_INTERVAL_MS + } else { + IDLE_DIAGNOSTIC_SNAPSHOT_INTERVAL_MS + } + while (true) { + delay(intervalMs) + persistCurrentDiagnosticSnapshot() + } + } + } + } + + private suspend fun persistCurrentDiagnosticSnapshot() { + withContext(Dispatchers.IO) { + runCatching { + val current = sanitizeDiagnosticExport(currentDebugLogText()) + diagnosticHistoryStore.saveCurrent(current) + } + .onFailure { error -> + Log.w(OPENNOW_DEBUG_LOG_TAG, "Could not persist diagnostic history", error) + } + } + } + + private fun recordDebugEvent(category: String, message: String) { + val oneLineMessage = message + .lineSequence() + .joinToString(" ") { it.trim() } + .take(DEBUG_EVENT_MESSAGE_LIMIT) + val event = DebugLogEvent( + timestampMs = System.currentTimeMillis(), + category = category, + message = oneLineMessage, + ) + synchronized(debugEventsLock) { + debugEvents.addLast(event) + while (debugEvents.size > DEBUG_EVENT_LIMIT) { + debugEvents.removeFirst() + } + } + Log.d(OPENNOW_DEBUG_LOG_TAG, "${event.category}: ${event.message}") + } + + private fun debugEventSnapshot(): List = + synchronized(debugEventsLock) { debugEvents.toList() } + + private fun recordSessionDiagnosticResponse(response: GfnSessionDiagnosticResponse) { + val sanitizedBody = sanitizeDiagnosticLogPayload(response.responseBody, DEBUG_PAYLOAD_BODY_LIMIT) + val event = DebugPayloadEvent( + timestampMs = System.currentTimeMillis(), + operation = response.operation, + method = response.method, + url = response.url, + statusCode = response.statusCode, + requestBody = response.requestBody + .takeIf { it.isNotBlank() } + ?.let { sanitizeDiagnosticLogPayload(it, DEBUG_PAYLOAD_BODY_LIMIT) } + .orEmpty(), + body = sanitizedBody, + ) + synchronized(debugPayloadsLock) { + debugPayloads.addLast(event) + while (debugPayloads.size > DEBUG_PAYLOAD_LIMIT) { + debugPayloads.removeFirst() + } + } + recoverySessionProbeDebugSummary(response)?.let { summary -> + recordDebugEvent("recovery", summary) + } + Log.d( + OPENNOW_DEBUG_LOG_TAG, + "gfn-json: ${response.operation} ${response.method} http=${response.statusCode} requestBytes=${response.requestBody.length} responseBytes=${response.responseBody.length} captured=${sanitizedBody.length} host=${hostForDebug(response.url)}", + ) + } + + private fun debugPayloadSnapshot(): List = + synchronized(debugPayloadsLock) { debugPayloads.toList() } + + private fun defaultLaunchAppPage(settings: AppSettings = settingsStore.settings.value): AppPage = + when (settings.launchPage) { + AppLaunchPage.Store -> AppPage.Home + AppLaunchPage.Library -> AppPage.Library + } + + /** + * Paints the last known catalogue before any network work begins. + * + * The cache was only opened inside [refreshAfterAuth], which sits behind a codec probe, a token + * restore and a provider fetch — two network round trips. That left a complete, warm catalogue + * sitting on disk while the reader watched a skeleton for ten seconds, for no reason: the cache + * key needs nothing but the session already on disk, so this runs concurrently with startup + * rather than after it. + */ + private fun primeCatalogFromCache() { + val session = initialAuthSession ?: return + viewModelScope.launch { + val key = CatalogCacheKey.of( + userId = session.user.userId, + baseUrl = effectiveStreamingBaseUrl(session), + searchQuery = state.value.catalogSearch, + sortId = state.value.catalogSortId, + filterIds = state.value.catalogFilterIds, + ) + val snapshot = withContext(Dispatchers.IO) { + runCatching { + CatalogCacheSnapshot( + key = key, + main = catalogCacheStore.loadMainGames(key.userId, key.baseUrl), + library = catalogCacheStore.loadLibraryGames(key.userId, key.baseUrl), + catalog = catalogCacheStore.loadCatalog( + userId = key.userId, + providerStreamingBaseUrl = key.baseUrl, + searchQuery = key.searchQuery, + sortId = key.sortId, + filterIds = key.filterIds, + ), + newlyAdded = catalogCacheStore.loadCatalog( + userId = key.userId, + providerStreamingBaseUrl = key.baseUrl, + searchQuery = "", + sortId = NEWLY_ADDED_CATALOG_SORT_ID, + filterIds = emptyList(), + ), + ) + }.getOrNull() + } ?: return@launch + primedCatalogCache = snapshot + applyPrimedCatalogCache(snapshot) + } + } + + private fun applyPrimedCatalogCache(snapshot: CatalogCacheSnapshot) { + val cachedGfnThursdayGames = gfnThursdayCatalogGames(snapshot.main.orEmpty()) + val gfnThursdayQuery = isNewlyAddedCatalogQuery( + searchQuery = snapshot.key.searchQuery, + sortId = snapshot.key.sortId, + filterIds = snapshot.key.filterIds, + ) + val officialCachedCatalog = if (gfnThursdayQuery && cachedGfnThursdayGames.isNotEmpty()) { + catalogResultWithGfnThursdayGames( + fallback = snapshot.catalog ?: CatalogBrowseResult( + games = emptyList(), + selectedSortId = NEWLY_ADDED_CATALOG_SORT_ID, + ), + games = cachedGfnThursdayGames, + ) + } else { + snapshot.catalog + } + val storeGames = officialCachedCatalog?.games ?: primedStoreGames(snapshot) + val libraryGames = snapshot.library.orEmpty() + val newlyAddedGames = cachedGfnThursdayGames + .ifEmpty { snapshot.newlyAdded?.games.orEmpty() } + if (storeGames.isEmpty() && libraryGames.isEmpty() && newlyAddedGames.isEmpty()) return + var applied = false + _state.update { current -> + // The live fetch always wins. This only fills a screen that is still blank, so a slow + // disk read can never overwrite results that arrived while it was parsing. + if (current.hasLoadedCatalogGames()) return@update current + applied = true + current.copy( + games = storeGames, + newlyAddedGames = newlyAddedGames.ifEmpty { current.newlyAddedGames }, + catalogResult = officialCachedCatalog ?: current.catalogResult, + libraryGames = libraryGames.ifEmpty { current.libraryGames }, + // A refresh is still on its way; the pull-to-refresh indicator should say so. + loadingGames = true, + error = null, + ) + } + if (applied) { + recordDebugEvent( + "catalog", + "Primed catalog from cache store=${storeGames.size} library=${libraryGames.size}", + ) + } + } + + /** + * Probes decoder capability, off the path to first paint. + * + * [CodecProbe.report] calls `WebRtcRuntime.ensureInitialized`, which loads the multi-megabyte + * WebRTC native library and stands up a PeerConnectionFactory. Running that before clearing + * `initializing` meant every cold start paid for the streaming engine before it could draw a + * catalogue — work that only matters once a stream is actually launched. + */ + private fun startDeviceCapabilityProbe() { + viewModelScope.launch { + val codecReport = runCatching { + withContext(Dispatchers.Default) { CodecProbe.report(getApplication()) } + }.getOrElse { error -> + recordDebugEvent("codec", "Codec probe failed error=${error.debugMessage()}") + // Leave the report null: every consumer already treats that as "not probed". + deviceCapabilityProbe.complete(Unit) + return@launch + } + val recommendation = recommendedAndroidStreamProfile(getApplication(), codecReport) + deviceRecommendation = recommendation + val currentSettings = settingsStore.settings.value + val recommendedStream = recommendation.stream.withMicrophoneSettingsFrom(currentSettings.stream) + if ( + currentSettings.streamPreset == StreamPreset.Recommended && + currentSettings.stream != recommendedStream + ) { + settingsStore.update { settings -> + if (settings.streamPreset == StreamPreset.Recommended) { + settings.copy(stream = recommendedStream) + } else { + settings + } + } + } + _state.update { + it.copy( + codecReport = codecReport, + recommendedStreamSettings = recommendation.stream, + settings = settingsStore.settings.value, + ) + } + deviceCapabilityProbe.complete(Unit) + } + } + + /** Stream launch is the only caller: it must not pick a profile before the device is known. */ + private suspend fun awaitDeviceCapabilityProbe() { + if (deviceCapabilityProbe.isCompleted) return + recordDebugEvent("codec", "Waiting on codec probe before launch") + deviceCapabilityProbe.await() + } + + fun initialize() { + viewModelScope.launch { + // Nothing here touches the streaming engine, so the catalogue can paint immediately. + _state.update { it.copy(initializing = false) } + val restoreResult = restoreAuthSession() + val providers = runCatching { authRepository.loginProviders() }.getOrDefault(listOf(defaultProvider())) + val restored = restoreResult.getOrNull() + val activeSession = restored ?: authStore.activeSession() + val selected = activeSession?.provider ?: authStore.state.value.selectedProvider ?: providers.firstOrNull() ?: defaultProvider() + val restoreError = restoreResult.exceptionOrNull()?.message?.takeIf { activeSession == null } + _state.update { + it.copy( + providers = providers, + selectedProvider = selected, + authSession = activeSession, + savedAccounts = authStore.state.value.sessions.map { session -> session.toSavedAccount() }, + initializing = false, + launchPhase = "", + // refreshAfterAuth runs on the next line, so the Store is about to load. Carrying + // a stale false in here renders the empty state over a fetch that is starting. + loadingGames = activeSession != null, + games = if (activeSession == null) emptyList() else it.games, + libraryGames = if (activeSession == null) emptyList() else it.libraryGames, + catalogResult = if (activeSession == null) CatalogBrowseResult(emptyList()) else it.catalogResult, + error = restoreError ?: it.error, + ) + } + if (activeSession != null) { + refreshAfterAuth(activeSession) + } + } + } + + private fun initialProviders(activeSession: AuthSession?): List = + listOfNotNull(authStore.state.value.selectedProvider, activeSession?.provider, defaultProvider()) + .distinctBy { provider -> provider.code.uppercase(Locale.US) } + + private suspend fun restoreAuthSession(throwOnRefreshFailure: Boolean = false): Result = + authRestoreMutex.withLock { + runCatching { + authRepository.restore( + throwOnRefreshFailure = throwOnRefreshFailure, + removeExpiredSessionOnFailure = !throwOnRefreshFailure, + ) + } + } + + /** Returns the in-flight refresh so a caller can wait for a fresh token before retrying. */ + fun refreshAuthSessionIfNeeded(): Job? { + authRefreshJob?.takeIf { it.isActive }?.let { return it } + val expectedUserId = state.value.authSession?.user?.userId ?: return null + val job = viewModelScope.launch { + try { + val result = restoreAuthSession(throwOnRefreshFailure = true) + val refreshed = result.getOrNull() + if (refreshed != null) { + val tokenChanged = refreshed.tokens != state.value.authSession?.tokens + _state.update { current -> + if (current.authSession?.user?.userId != expectedUserId) { + current + } else { + current.copy( + authSession = refreshed, + selectedProvider = refreshed.provider, + savedAccounts = authStore.state.value.sessions.map { session -> session.toSavedAccount() }, + ) + } + } + if (tokenChanged) { + recordDebugEvent("auth", "Refreshed saved sign-in tokens in the background") + } + } + result.exceptionOrNull()?.let { error -> + recordDebugEvent("auth", "Background sign-in refresh failed error=${error.debugMessage()}") + } + } finally { + authRefreshJob = null + } + } + authRefreshJob = job + return job + } + + /** + * Called when the app comes back to the foreground. + * + * Two things can leave a signed-in reader looking at an empty Store: the one startup fetch + * failed and its retry ladder ran out, or the process was frozen long enough for its tokens to + * go stale — and the only in-process token refresh is a 15-minute WorkManager job. Returning to + * the app is the natural moment to repair both. + */ + fun onAppForegrounded() { + val snapshot = state.value + if (snapshot.authSession == null) return + // A fresh visit re-arms the ladder that the last run of failures exhausted. + catalogRetryAttempt = 0 + if ( + !shouldRetryCatalogLoad( + signedIn = true, + loadAttempted = catalogLoadAttempted, + hasGames = snapshot.hasLoadedCatalogGames(), + loadInFlight = gamesJob?.isActive == true, + streamActive = snapshot.isAndroidUpdateCheckBlockedByStream(), + ) + ) { + return + } + recordDebugEvent("catalog", "Reloading empty catalog after returning to the foreground") + startCatalogRecovery(delayMs = 0L) + } + + private fun scheduleCatalogRetry() { + if (catalogRetryAttempt >= CATALOG_RETRY_MAX_ATTEMPTS) { + recordDebugEvent("catalog", "Catalog retries exhausted after $catalogRetryAttempt attempts") + return + } + val delayMs = catalogRetryDelayMs(catalogRetryAttempt) + catalogRetryAttempt += 1 + recordDebugEvent("catalog", "Scheduling catalog retry attempt=$catalogRetryAttempt inMs=$delayMs") + startCatalogRecovery(delayMs) + } + + private fun startCatalogRecovery(delayMs: Long) { + catalogRetryJob?.cancel() + catalogRetryJob = viewModelScope.launch { + if (delayMs > 0L) delay(delayMs) + // An expired token is the likeliest reason the previous attempt failed, and retrying + // the catalogue with the same dead token would only burn an attempt. + refreshAuthSessionIfNeeded()?.join() + val snapshot = state.value + val session = snapshot.authSession ?: return@launch + if ( + !shouldRetryCatalogLoad( + signedIn = true, + loadAttempted = catalogLoadAttempted, + hasGames = snapshot.hasLoadedCatalogGames(), + loadInFlight = gamesJob?.isActive == true, + streamActive = snapshot.isAndroidUpdateCheckBlockedByStream(), + ) + ) { + return@launch + } + refreshAfterAuth(session) + } + } + + fun setPage(page: AppPage) { + _state.update { it.copy(page = page, selectedGame = null) } + } + + fun recordSettingsIconTap() { + if (settingsDiagnosticTapTracker.recordTap(SystemClock.elapsedRealtime())) requestDiagnosticShare() + } + + fun recordLoginIconTap() { + if (!loginIconTapTracker.recordTap(SystemClock.elapsedRealtime())) return + _state.update { it.copy(loginToolsVisible = true) } + } + + fun dismissLoginTools() { + _state.update { it.copy(loginToolsVisible = false) } + } + + fun dismissDiagnosticShare() { + _state.update { it.copy(diagnosticShare = DiagnosticShareState()) } + } + + fun dismissSessionReport() { + _state.update { it.copy(sessionReport = null) } + } + + fun requestDiagnosticShare() { + _state.update { + it.copy(diagnosticShare = DiagnosticShareState(awaitingConsent = true)) + } + } + + fun resetBugReportSubmission() { + if (state.value.bugReportSubmission.uploading) return + _state.update { it.copy(bugReportSubmission = BugReportSubmissionState()) } + } + + fun submitBugReport(title: String, description: String) = + submitBugReport(title, description, knownIssueOverrideKey = null) + + fun submitBugReport(title: String, description: String, knownIssueOverrideKey: String?) { + if (state.value.bugReportSubmission.uploading) return + val snapshot = state.value + val versionBlock = androidBugReportBlockMessage( + update = snapshot.androidUpdate, + versionCheck = snapshot.bugReportVersionCheck, + ) + val appLocale = currentAndroidAppLocale(getApplication()) + val contentError = androidBugReportTitleError(title) + ?: androidBugReportDescriptionError(description) + val validationError = when { + versionBlock != null -> versionBlock + !appLocale.bugReportsAllowed -> + "Set the OpenNOW or device language to English before sending a bug report" + contentError != null -> contentError + else -> null + } + if (validationError != null) { + _state.update { + it.copy( + bugReportSubmission = BugReportSubmissionState(error = validationError), + ) + } + return + } + _state.update { + it.copy(bugReportSubmission = BugReportSubmissionState(uploading = true)) + } + viewModelScope.launch { + try { + val languageCheck = identifyAndroidBugReportLanguage(title, description) + val logFileName = debugLogFileName() + val metadata = buildAndroidBugReportMetadata( + logFileName = logFileName, + knownIssueOverrideKey = knownIssueOverrideKey, + device = AndroidDeviceDiagnostics.snapshot(getApplication()), + ) + val logBytes = withContext(Dispatchers.Default) { + sanitizedDebugLogText().toByteArray(Charsets.UTF_8) + } + val receipt = uploadAndroidBugReport( + http = http, + report = AndroidBugReport( + title = title, + description = description, + versionName = BuildConfig.VERSION_NAME, + versionCode = BuildConfig.VERSION_CODE.toString(), + reporterId = androidBugReportReporterId(authStore.stableDeviceId()), + appLanguageSelectionTag = appLocale.bugReportLanguageTag.orEmpty(), + languageCheck = languageCheck, + metadata = metadata, + files = listOf( + AndroidBugReportAttachment( + fileName = logFileName, + contentType = "text/plain; charset=utf-8", + bytes = logBytes, + ), + ), + ), + ) + recordDebugEvent( + "bug-report", + "PrintedWaste bug report submitted knownIssueOverride=${knownIssueOverrideKey ?: "none"}", + ) + _state.update { + it.copy( + bugReportSubmission = BugReportSubmissionState( + submitted = true, + reference = receipt.reference, + ), + ) + } + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + recordDebugEvent( + "bug-report", + "PrintedWaste bug report failed error=${error.debugMessage()}", + ) + _state.update { + it.copy( + bugReportSubmission = BugReportSubmissionState( + error = error.message ?: "Could not send the bug report", + ), + ) + } + } + } + } + + fun verifyBugReportVersion() { + val snapshot = state.value + if (!snapshot.androidUpdate.installSource.isGooglePlay) return + if (bugReportUpdateVerificationJob?.isActive == true) return + + _state.update { + it.copy( + bugReportVersionCheck = AndroidBugReportVersionCheckState( + status = AndroidBugReportVersionCheckStatus.Checking, + message = "Checking Google Play...", + ), + ) + } + bugReportUpdateCheckActive = true + bugReportUpdateVerificationJob = viewModelScope.launch { + try { + val existingUpdateCheck = androidUpdateJob?.takeIf { it.isActive } + if (existingUpdateCheck != null) { + existingUpdateCheck.join() + } else { + appUpdater.checkForUpdate() + } + val update = appUpdater.state.value + val versionCheck = when (update.status) { + AndroidUpdateStatus.Available, + AndroidUpdateStatus.Downloading, + AndroidUpdateStatus.Downloaded, + -> AndroidBugReportVersionCheckState( + status = AndroidBugReportVersionCheckStatus.UpdateRequired, + message = update.message, + ) + AndroidUpdateStatus.NotAvailable -> AndroidBugReportVersionCheckState( + status = AndroidBugReportVersionCheckStatus.Current, + message = update.message, + ) + else -> AndroidBugReportVersionCheckState( + status = AndroidBugReportVersionCheckStatus.CheckFailed, + message = update.message.takeIf { it.isNotBlank() }, + ) + } + recordDebugEvent( + "bug-report", + "Google Play version preflight result=${versionCheck.status} currentBuild=${update.currentVersionCode} availableBuild=${update.availableVersionCode ?: -1}", + ) + _state.update { it.copy(bugReportVersionCheck = versionCheck) } + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + recordDebugEvent( + "bug-report", + "Google Play version preflight failed error=${error.debugMessage()}", + ) + _state.update { + it.copy( + bugReportVersionCheck = AndroidBugReportVersionCheckState( + status = AndroidBugReportVersionCheckStatus.CheckFailed, + message = error.message ?: "Google Play update check failed.", + ), + ) + } + } finally { + bugReportUpdateCheckActive = false + bugReportUpdateVerificationJob = null + } + } + } + + fun startLocalTvConnector() { + if (!state.value.androidTvProfile) return + settingsStore.update { it.copy(localTvRemoteEnabled = true) } + localTvConnector.startHosting() + } + + fun stopLocalTvConnector() { + if (state.value.androidTvProfile) { + settingsStore.update { it.copy(localTvRemoteEnabled = false) } + } + localTvConnector.stopHosting() + } + + fun refreshLocalTvPairingCode() { + if (!state.value.androidTvProfile || !state.value.settings.localTvRemoteEnabled) return + localTvConnector.refreshPairingCode() + } + + fun setLocalTvDeviceTrusted(trusted: Boolean) { + if (!state.value.androidTvProfile) return + localTvConnector.setPairedDeviceTrusted(trusted) + } + + fun setLocalTvTrustRequested(requested: Boolean) { + if (state.value.androidTvProfile) return + localTvConnector.setPhoneTrustRequest(requested) + } + + fun forgetLocalTvConnector() { + localTvConnector.forgetPhoneTarget() + } + + fun discoverLocalTvs() { + if (state.value.androidTvProfile) return + localTvConnector.discoverTvs() + } + + fun pairDiscoveredLocalTv(tv: DiscoveredLocalTv, code: String) { + if (state.value.androidTvProfile) return + localTvConnector.pairDiscoveredTv(tv, code) + } + + fun pairLocalTvQrValue(value: String?) { + if (state.value.androidTvProfile) return + val uri = value?.trim()?.takeIf(String::isNotBlank)?.let(Uri::parse) + if (!localTvConnector.isPairUri(uri) || uri == null) { + localTvConnector.reportPairingError("That QR code is not an OpenNOW TV pairing code") + return + } + localTvConnector.pairPhone(uri) + } + + fun playOnLocalTv(game: GameInfo) { + if (state.value.androidTvProfile) return + localTvConnector.sendLaunch(gameTrackingKey(game), game.title) + } + + fun signInLocalTv() { + if (state.value.androidTvProfile) return + val session = state.value.authSession ?: run { + _state.update { it.copy(error = "Sign in on the phone first") } + return + } + localTvConnector.sendSignIn(session) + } + + fun switchLocalTvAccount(userId: String) { + if (state.value.androidTvProfile) return + val session = authStore.state.value.sessions.firstOrNull { it.user.userId == userId } ?: run { + _state.update { it.copy(error = "That account is no longer available on this phone") } + return + } + localTvConnector.sendSignIn(session) + } + + fun sendLocalTvRemoteAction(action: String, value: String? = null) { + if (state.value.androidTvProfile) return + localTvConnector.sendRemoteAction(action, value) + } + + private fun handleLocalTvRemoteRequest(request: LocalTvRemoteRequest) { + recordDebugEvent("tv-remote", "Accepted encrypted action=${request.action}") + when (request.action) { + "open_stream_menu" -> _state.update { + it.copy(remoteStreamMenuRequestToken = it.remoteStreamMenuRequestToken + 1) + } + "toggle_stream_stats" -> _state.update { + it.copy(remoteStatsToggleRequestToken = it.remoteStatsToggleRequestToken + 1) + } + "stop_stream" -> stopStream() + "apply_recommended" -> applyStreamPreset(StreamPreset.Recommended) + "set_codec" -> request.value + ?.let { value -> runCatching { VideoCodec.valueOf(value) }.getOrNull() } + ?.let { codec -> updateStreamSettings { it.copy(codec = codec) } } + "set_resolution" -> request.value + ?.takeIf { streamAspectRatioForResolution(it) != null } + ?.let { resolution -> + updateStreamSettings { + it.copy( + resolution = resolution, + aspectRatio = streamAspectRatioForResolution(resolution) ?: it.aspectRatio, + ) + } + } + "set_fps" -> request.value?.toIntOrNull() + ?.takeIf { it in setOf(30, 60, 120) } + ?.let { fps -> updateStreamSettings { it.copy(fps = fps) } } + "set_background" -> request.value?.toBooleanStrictOrNull()?.let { enabled -> + settingsStore.update { it.copy(nerdCatalogBackground = enabled) } + } + "set_ui_sounds" -> request.value?.toBooleanStrictOrNull()?.let { enabled -> + settingsStore.update { it.copy(controllerUiSounds = enabled) } + } + "set_safe_area" -> request.value?.toFloatOrNull()?.coerceIn(0f, 120f)?.let { padding -> + settingsStore.update { it.copy(tvSafeAreaPaddingDp = padding) } + } + "set_hide_server_selector" -> request.value?.toBooleanStrictOrNull()?.let { hidden -> + settingsStore.update { it.copy(hideServerSelector = hidden) } + } + } + } + + private fun acceptLocalTvSignIn(transferredSession: AuthSession) { + viewModelScope.launch { + authStore.upsertSession(transferredSession) + val restored = restoreAuthSession(throwOnRefreshFailure = true).getOrElse { error -> + authStore.removeSession(transferredSession.user.userId) + _state.update { it.copy(error = "Phone sign-in could not be verified: ${error.message.orEmpty()}") } + return@launch + } ?: run { + authStore.removeSession(transferredSession.user.userId) + _state.update { it.copy(error = "Phone sign-in could not be verified") } + return@launch + } + _state.update { + it.copy( + authSession = restored, + selectedProvider = restored.provider, + savedAccounts = authStore.state.value.sessions.map { saved -> saved.toSavedAccount() }, + error = null, + loadingGames = true, + ) + } + Toast.makeText( + getApplication(), + getApplication().getString(R.string.toast_signed_in_from_phone), + Toast.LENGTH_SHORT, + ).show() + recordDebugEvent("tv-connector", "Accepted encrypted local sign-in provider=${restored.provider.code}") + refreshAfterAuth(restored) + } + } + + fun uploadDiagnosticShare() { + if (state.value.diagnosticShare.uploading) return + _state.update { + it.copy(diagnosticShare = DiagnosticShareState(uploading = true)) + } + viewModelScope.launch { + val snapshot = state.value + val summaryHeader = diagnosticSummaryHeader(snapshot) + val sanitizedLog = sanitizedDebugLogText() + val payload = sanitizeDiagnosticExport( + buildString { + appendLine(summaryHeader) + appendLine() + append(sanitizedLog) + }, + ) + runCatching { uploadAndroidDiagnosticPaste(http, payload) } + .onSuccess { pasteUrl -> + _state.update { + it.copy( + diagnosticShare = DiagnosticShareState( + pasteUrl = pasteUrl, + clipboardSummary = "$summaryHeader\nPaste: $pasteUrl", + ), + ) + } + } + .onFailure { error -> + if (error is CancellationException) return@onFailure + _state.update { + it.copy( + diagnosticShare = DiagnosticShareState( + awaitingConsent = true, + error = error.message ?: "Could not upload diagnostics", + ), + ) + } + } + } + } + + private fun diagnosticSummaryHeader(snapshot: OpenNowUiState): String { + val recommendation = deviceRecommendation + val model = listOf(Build.MANUFACTURER, Build.MODEL) + .map(String::trim) + .filter(String::isNotBlank) + .distinct() + .joinToString(" ") + val accountType = snapshot.subscriptionInfo?.membershipTier + ?: snapshot.authSession?.user?.membershipTier + ?: "Unknown" + val provider = snapshot.authSession?.provider?.displayName?.takeIf { it.isNotBlank() } ?: "Unknown" + return buildString { + appendLine("OpenNOW Android ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})") + appendLine("Client: ${if (snapshot.androidTvProfile) "Android TV" else "Android mobile"}") + appendLine("Hardware: $model · Android ${Build.VERSION.RELEASE}") + appendLine("Screen: ${recommendation?.displayWidth ?: "?"}x${recommendation?.displayHeight ?: "?"} · processors ${recommendation?.processorCount ?: "?"} · memory ${recommendation?.totalMemoryMiB?.let { "$it MiB" } ?: "unknown"}") + appendLine("Membership: $provider · $accountType") + appendLine("Profile: ${snapshot.settings.streamPreset} · ${snapshot.settings.stream.resolution}@${snapshot.settings.stream.fps} · ${snapshot.settings.stream.codec} · ${snapshot.settings.stream.maxBitrateMbps} Mbps") + append("Status: ${snapshot.streamStatus} · ${snapshot.error?.take(160)?.let(::sanitizeDiagnosticExport) ?: "no current error"}") + } + } + + fun openAndroidUpdateSettings() { + _state.update { + it.copy( + page = AppPage.Settings, + selectedGame = null, + settingsRouteTarget = SettingsRouteTarget.General, + ) + } + } + + fun openAccountSettings() { + _state.update { + it.copy( + page = AppPage.Settings, + selectedGame = null, + settingsRouteTarget = SettingsRouteTarget.Account, + ) + } + } + + fun openStreamSettings() { + _state.update { + it.copy( + page = AppPage.Settings, + selectedGame = null, + settingsRouteTarget = SettingsRouteTarget.Stream, + ) + } + } + + fun openInterfaceSettings() { + _state.update { + it.copy( + page = AppPage.Settings, + selectedGame = null, + settingsRouteTarget = SettingsRouteTarget.Interface, + ) + } + } + + fun consumeSettingsRouteTarget(target: SettingsRouteTarget) { + _state.update { current -> + if (current.settingsRouteTarget == target) { + current.copy(settingsRouteTarget = null) + } else { + current + } + } + } + + fun selectProvider(provider: LoginProvider) { + if (!provider.supportsDeviceCodeLogin && state.value.deviceLoginPrompt != null) { + loginJob?.cancel() + loginJob = null + } + _state.update { + it.copy( + selectedProvider = provider, + deviceLoginPrompt = if (provider.supportsDeviceCodeLogin) it.deviceLoginPrompt else null, + launchPhase = if (provider.supportsDeviceCodeLogin) it.launchPhase else "", + ) + } + } + + fun login(provider: LoginProvider = state.value.selectedProvider) { + loginJob?.cancel() + loginJob = viewModelScope.launch { + val useDeviceCode = state.value.androidTvProfile && provider.supportsDeviceCodeLogin + _state.update { + it.copy( + error = null, + launchPhase = if (useDeviceCode) "Requesting TV sign-in code" else "Opening ${provider.displayName} login", + deviceLoginPrompt = null, + ) + } + runCatching { + loginWithBestAvailableMethod(provider, useDeviceCode) + } + .onSuccess { session -> + completeLogin(session) + } + .onFailure { error -> + if (error is CancellationException) return@onFailure + _state.update { it.copy(error = error.message ?: "Login failed", launchPhase = "", deviceLoginPrompt = null) } + } + } + } + + fun loginWithCode(provider: LoginProvider = state.value.selectedProvider) { + if (!provider.supportsDeviceCodeLogin) { + login(provider) + return + } + loginJob?.cancel() + loginJob = viewModelScope.launch { + _state.update { + it.copy( + error = null, + launchPhase = "Requesting sign-in code", + deviceLoginPrompt = null, + ) + } + runCatching { + loginWithDeviceCode(provider) + } + .onSuccess { session -> + completeLogin(session, loginMethod = "device_code") + } + .onFailure { error -> + if (error is CancellationException) return@onFailure + _state.update { it.copy(error = error.message ?: "Code sign-in failed", launchPhase = "", deviceLoginPrompt = null) } + } + } + } + + fun loginWithToken(tokenInput: String, provider: LoginProvider = state.value.selectedProvider) { + loginJob?.cancel() + loginJob = viewModelScope.launch { + _state.update { + it.copy( + error = null, + launchPhase = "Checking sign-in token", + deviceLoginPrompt = null, + loginToolsVisible = false, + ) + } + runCatching { authRepository.loginWithToken(provider, tokenInput) } + .onSuccess { session -> completeLogin(session, loginMethod = "token") } + .onFailure { error -> + if (error is CancellationException) return@onFailure + _state.update { + it.copy( + error = error.message ?: "Token sign-in failed", + launchPhase = "", + deviceLoginPrompt = null, + ) + } + } + } + } + + private suspend fun completeLogin(session: AuthSession, loginMethod: String? = null) { + _state.update { + it.copy( + authSession = session, + selectedProvider = session.provider, + savedAccounts = authStore.state.value.sessions.map { saved -> saved.toSavedAccount() }, + launchPhase = "", + deviceLoginPrompt = null, + error = null, + page = defaultLaunchAppPage(), + loginToolsVisible = false, + ) + } + OpenNowAnalytics.capture( + event = "user_logged_in", + properties = buildMap { + put("provider", session.provider.code) + put("membership_tier", session.user.membershipTier) + loginMethod?.let { put("login_method", it) } + }, + ) + refreshAfterAuth(session) + } + + private suspend fun loginWithBestAvailableMethod(provider: LoginProvider, useDeviceCode: Boolean): AuthSession { + if (useDeviceCode) { + return loginWithDeviceCode(provider) + } + + return try { + authRepository.login(provider) { + _state.update { it.copy(launchPhase = LOGIN_PHASE_GETTING_TOKENS, error = null) } + } + } catch (error: Throwable) { + if (error is CancellationException || !isLoopbackLoginFailure(error)) { + throw error + } + if (!provider.supportsDeviceCodeLogin) { + throw error + } + _state.update { + it.copy( + launchPhase = "Requesting sign-in code", + error = "Browser sign-in could not reach the local callback. Use this code to finish sign-in.", + ) + } + loginWithDeviceCode(provider, clearErrorOnPrompt = false) + } + } + + private suspend fun loginWithDeviceCode(provider: LoginProvider, clearErrorOnPrompt: Boolean = true): AuthSession = + authRepository.loginWithDeviceCode(provider) { prompt -> + _state.update { + it.copy( + deviceLoginPrompt = prompt, + launchPhase = "Waiting for sign-in", + error = if (clearErrorOnPrompt) null else it.error, + ) + } + } + + private fun isLoopbackLoginFailure(error: Throwable): Boolean { + val message = generateSequence(error) { it.cause } + .mapNotNull { it.message } + .joinToString(" ") + .lowercase() + return "oauth callback" in message || + "callback ports" in message || + "local callback" in message || + "localhost" in message || + "127.0.0.1" in message + } + + fun cancelLogin() { + loginJob?.cancel() + loginJob = null + _state.update { it.copy(launchPhase = "", deviceLoginPrompt = null) } + } + + fun logout() { + viewModelScope.launch { + pendingActiveSessionLaunch = null + OpenNowAnalytics.capture(event = "user_logged_out") + OpenNowAnalytics.reset() + authRepository.logout() + val nextSession = authStore.activeSession() + _state.update { + it.copy( + authSession = nextSession, + selectedProvider = nextSession?.provider ?: it.selectedProvider, + savedAccounts = authStore.state.value.sessions.map { saved -> saved.toSavedAccount() }, + subscriptionInfo = null, + accountConnectors = emptyList(), + loadingAccountConnectors = false, + connectorActionStore = null, + games = emptyList(), + newlyAddedGames = emptyList(), + libraryGames = emptyList(), + libraryFilterIds = emptyList(), + streamSession = null, + activeStreamSettings = null, + activeSession = null, + activeSessionDecision = null, + deviceLoginPrompt = null, + pendingStoreChoiceGame = null, + page = AppPage.Home, + ) + } + if (nextSession != null) { + refreshAfterAuth(nextSession) + } + } + } + + fun switchAccount(userId: String) { + viewModelScope.launch { + pendingActiveSessionLaunch = null + _state.update { it.copy(settingsRefreshing = true, error = null) } + try { + authStore.setActiveSession(userId) + val sessionResult = restoreAuthSession() + val session = sessionResult.getOrElse { error -> + _state.update { current -> + current.copy( + authSession = authStore.activeSession(), + savedAccounts = authStore.state.value.sessions.map { saved -> saved.toSavedAccount() }, + error = error.message ?: "Could not refresh the selected account. Please sign in again.", + settingsRefreshing = false, + ) + } + recordDebugEvent("auth", "Account switch refresh failed error=${error.debugMessage()}") + return@launch + } + if (session == null) { + // target session was expired/invalid and got removed. + // Fall back to whatever active session is left. + val fallbackSession = restoreAuthSession().getOrNull() + _state.update { current -> + current.copy( + authSession = fallbackSession, + selectedProvider = fallbackSession?.provider ?: current.selectedProvider, + savedAccounts = authStore.state.value.sessions.map { saved -> saved.toSavedAccount() }, + subscriptionInfo = null, + accountConnectors = emptyList(), + loadingAccountConnectors = false, + connectorActionStore = null, + games = emptyList(), + newlyAddedGames = emptyList(), + libraryGames = emptyList(), + catalogResult = CatalogBrowseResult(emptyList()), + libraryFilterIds = emptyList(), + selectedGame = null, + activeSession = null, + activeSessionDecision = null, + error = "Failed to switch account: session expired. Please log in again.", + page = AppPage.Home, + settingsRefreshing = false, + ) + } + return@launch + } + gamesJob?.cancel() + _state.update { current -> + current.copy( + authSession = session, + selectedProvider = session.provider, + savedAccounts = authStore.state.value.sessions.map { saved -> saved.toSavedAccount() }, + subscriptionInfo = null, + accountConnectors = emptyList(), + loadingAccountConnectors = false, + connectorActionStore = null, + games = emptyList(), + newlyAddedGames = emptyList(), + libraryGames = emptyList(), + catalogResult = CatalogBrowseResult(emptyList()), + libraryFilterIds = emptyList(), + catalogQueryLoading = false, + selectedGame = null, + activeSession = null, + activeSessionDecision = null, + error = null, + page = AppPage.Home, + settingsRefreshing = false, + ) + } + OpenNowAnalytics.capture( + event = "account_switched", + properties = mapOf( + "provider" to session.provider.code, + "membership_tier" to session.user.membershipTier, + ), + ) + refreshAfterAuth(session) + } catch (e: Exception) { + if (e is CancellationException) throw e + _state.update { current -> + current.copy( + error = e.message ?: "Failed to switch account", + settingsRefreshing = false, + ) + } + } + } + } + + fun logoutAll() { + pendingActiveSessionLaunch = null + authRepository.logoutAll() + _state.update { + it.copy( + authSession = null, + savedAccounts = emptyList(), + subscriptionInfo = null, + accountConnectors = emptyList(), + loadingAccountConnectors = false, + connectorActionStore = null, + games = emptyList(), + newlyAddedGames = emptyList(), + libraryGames = emptyList(), + libraryFilterIds = emptyList(), + catalogQueryLoading = false, + streamSession = null, + activeStreamSettings = null, + activeSession = null, + activeSessionDecision = null, + deviceLoginPrompt = null, + pendingStoreChoiceGame = null, + page = AppPage.Home, + ) + } + } + + fun refreshGames() { + val session = state.value.authSession ?: return + catalogRetryJob?.cancel() + catalogRetryAttempt = 0 + viewModelScope.launch { + refreshAfterAuth(session, keepRefreshVisibleWithCache = true) + } + } + + fun setCatalogSearch(query: String) { + _state.update { it.copy(catalogSearch = query, catalogQueryLoading = true) } + if (query.isNotBlank()) { + OpenNowAnalytics.capture( + event = "catalog_searched", + properties = mapOf("query" to query), + ) + } + refreshCatalogDebounced() + } + + fun setLibrarySearch(query: String) { + _state.update { it.copy(librarySearch = query) } + } + + fun toggleLibraryFilter(filterId: String) { + val nextFilters = state.value.libraryFilterIds.let { current -> + if (filterId in current) current - filterId else current + filterId + } + _state.update { + it.copy(libraryFilterIds = nextFilters) + } + settingsStore.update { it.copy(libraryFilterIds = nextFilters) } + } + + fun clearLibraryFilters() { + _state.update { it.copy(libraryFilterIds = emptyList()) } + settingsStore.update { it.copy(libraryFilterIds = emptyList()) } + } + + fun setLibrarySort(sortId: String) { + if (sortId !in setOf(LIBRARY_SORT_DEFAULT, LIBRARY_SORT_RECENT, LIBRARY_SORT_TITLE)) return + _state.update { it.copy(librarySortId = sortId) } + settingsStore.update { it.copy(librarySortId = sortId) } + } + + fun setCatalogSort(sortId: String) { + _state.update { it.copy(catalogSortId = sortId, catalogQueryLoading = true) } + settingsStore.update { it.copy(catalogSortId = sortId) } + refreshCatalogDebounced() + } + + fun toggleCatalogFilter(filterId: String) { + val adding = filterId !in state.value.catalogFilterIds + val nextFilters = state.value.catalogFilterIds.let { current -> + if (filterId in current) current - filterId else current + filterId + } + _state.update { it.copy(catalogFilterIds = nextFilters, catalogQueryLoading = true) } + settingsStore.update { it.copy(catalogFilterIds = nextFilters) } + OpenNowAnalytics.capture( + event = "catalog_filter_applied", + properties = mapOf( + "filter_id" to filterId, + "action" to if (adding) "add" else "remove", + ), + ) + refreshCatalogDebounced() + } + + fun clearCatalogFilters() { + _state.update { it.copy(catalogFilterIds = emptyList(), catalogQueryLoading = true) } + settingsStore.update { it.copy(catalogFilterIds = emptyList()) } + refreshCatalogDebounced() + } + + fun selectGame(game: GameInfo) { + gameDetailsJob?.cancel() + _state.update { it.copy(selectedGame = game) } + // PostHog may flush on release builds. Keep that work off the main thread so the state + // update, destination artwork, and activation haptic can all land in the next frame. + viewModelScope.launch(Dispatchers.IO) { + OpenNowAnalytics.capture( + event = "game_selected", + properties = mapOf( + "game_id" to game.id, + "game_title" to game.title, + ), + ) + } + if (!shouldHydrateGameDetails(game)) return + val auth = state.value.authSession ?: return + val selectedKey = gameTrackingKey(game) + gameDetailsJob = viewModelScope.launch { + val details = try { + withContext(Dispatchers.IO) { + catalogRepository.hydrateGameDetails( + token = auth.tokens.idToken ?: auth.tokens.accessToken, + providerStreamingBaseUrl = effectiveStreamingBaseUrl(auth), + game = game, + ) + } + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + recordDebugEvent("catalog", "Game detail metadata failed title=${game.title} error=${error.debugMessage()}") + return@launch + } + _state.update { current -> + val selected = current.selectedGame + if (selected == null || gameTrackingKey(selected) != selectedKey) { + current + } else { + current.copy( + selectedGame = mergeGameInfo(selected, details), + games = current.games.withHydratedGameDetails(details), + newlyAddedGames = current.newlyAddedGames.withHydratedGameDetails(details), + libraryGames = current.libraryGames.withHydratedGameDetails(details), + catalogResult = current.catalogResult.copy( + games = current.catalogResult.games.withHydratedGameDetails(details), + ), + ) + } + } + } + } + + fun clearSelectedGame() { + gameDetailsJob?.cancel() + gameDetailsJob = null + _state.update { it.copy(selectedGame = null) } + } + + fun updateSettings(next: AppSettings) { + settingsStore.replace(next) + } + + fun addLocalApp(packageName: String) { + if (packageName.isBlank() || packageName == getApplication().packageName) return + settingsStore.update { current -> + current.copy( + localAppPackageNames = (current.localAppPackageNames + packageName).distinct(), + ) + } + } + + fun removeLocalApp(packageName: String) { + settingsStore.update { current -> + current.copy(localAppPackageNames = current.localAppPackageNames - packageName) + } + } + + fun setLocalAppsCollapsed(collapsed: Boolean) { + settingsStore.update { current -> current.copy(localAppsCollapsed = collapsed) } + } + + fun checkAndroidUpdate() { + startAndroidUpdateCheck(automatic = false) + } + + fun dismissAndroidUpdateNotice() { + val key = androidUpdateNoticeKey(state.value.androidUpdate) ?: return + androidUpdateNoticeStore.dismiss(key) + _state.update { it.copy(dismissedAndroidUpdateNoticeKey = key) } + } + + fun downloadAndroidUpdate() { + if (androidUpdateJob?.isActive == true || !state.value.androidUpdate.canDownload) return + OpenNowAnalytics.capture(event = "app_update_downloaded") + androidUpdateJob = viewModelScope.launch { + appUpdater.downloadUpdate() + } + } + + fun performAndroidUpdatePrimaryAction() { + val update = state.value.androidUpdate + if (update.canOpenPlayStore) { + OpenNowAnalytics.capture( + event = "app_update_opened_play_store", + properties = buildMap { + put("current_version_code", update.currentVersionCode) + update.availableVersionCode?.let { put("available_version_code", it) } + }, + ) + appUpdater.openPlayStoreListing() + } else { + downloadAndroidUpdate() + } + } + + fun installAndroidUpdate() { + if (!state.value.androidUpdate.canInstall) return + appUpdater.installDownloadedUpdate() + } + + private fun startAndroidUpdateAutoChecks() { + if (androidUpdateAutoJob?.isActive == true) return + if (!state.value.androidUpdate.updateChecksSupported) return + androidUpdateAutoJob = viewModelScope.launch { + delay(ANDROID_UPDATE_LAUNCH_CHECK_DELAY_MS) + while (true) { + runAutomaticAndroidUpdateCheck() + delay(ANDROID_UPDATE_PERIODIC_CHECK_INTERVAL_MS) + } + } + } + + private suspend fun runAutomaticAndroidUpdateCheck() { + if (!state.value.androidUpdate.updateChecksSupported) return + if (!state.value.settings.autoCheckForUpdates) return + waitForAndroidUpdateCheckWindow() + val snapshot = state.value + if (!snapshot.settings.autoCheckForUpdates || !snapshot.androidUpdate.shouldRunAutomaticCheck()) return + startAndroidUpdateCheck(automatic = true)?.join() + } + + private suspend fun waitForAndroidUpdateCheckWindow() { + while (state.value.isAndroidUpdateCheckBlockedByStream()) { + delay(ANDROID_UPDATE_STREAMING_RETRY_DELAY_MS) + } + } + + private fun startAndroidUpdateCheck(automatic: Boolean): Job? { + if (androidUpdateJob?.isActive == true) return null + if (!state.value.androidUpdate.updateChecksSupported) return null + if (state.value.isAndroidUpdateCheckBlockedByStream()) { + if (!automatic) { + appUpdater.markCheckDeferredForStreaming() + } + return null + } + return viewModelScope.launch { + if (state.value.isAndroidUpdateCheckBlockedByStream()) { + if (!automatic) { + appUpdater.markCheckDeferredForStreaming() + } + return@launch + } + appUpdater.checkForUpdate() + }.also { job -> + androidUpdateJob = job + } + } + + private fun cancelAndroidUpdateCheckForStreaming() { + if (state.value.androidUpdate.status != AndroidUpdateStatus.Checking) return + androidUpdateJob?.cancel() + androidUpdateJob = null + appUpdater.markCheckDeferredForStreaming() + } + + fun refreshSettings() { + if (settingsRefreshJob?.isActive == true) return + settingsRefreshJob = viewModelScope.launch { + _state.update { it.copy(settingsRefreshing = true, error = null) } + try { + val updateJob = startAndroidUpdateCheck(automatic = false) + val session = state.value.authSession + val accountJob = session?.let { activeSession -> + launch { refreshSettingsAccountData(activeSession) } + } + val accountConnectorsJob = session?.let { activeSession -> + launch { refreshAccountConnectors(activeSession) } + } + updateJob?.join() + accountJob?.join() + accountConnectorsJob?.join() + } finally { + _state.update { it.copy(settingsRefreshing = false) } + } + } + } + + fun resetSettings() { + Toast.makeText( + getApplication(), + getApplication().getString(R.string.toast_clearing_app_data), + Toast.LENGTH_SHORT, + ).show() + wipeAppDataAndRelaunch(getApplication()) + } + + fun resetStreamTutorial() { + settingsStore.update { it.copy(androidStreamGuideDismissed = false) } + Toast.makeText( + getApplication(), + getApplication().getString(R.string.toast_tutorial_reset), + Toast.LENGTH_SHORT, + ).show() + } + + fun clearCatalogCache() { + viewModelScope.launch { + val removed = withContext(Dispatchers.IO) { catalogCacheStore.clear() } + Toast.makeText( + getApplication(), + getApplication().getString( + if (removed == 0) R.string.toast_cache_already_clear else R.string.toast_cache_cleared, + ), + Toast.LENGTH_SHORT, + ).show() + state.value.authSession?.let { refreshAfterAuth(it) } + } + } + + fun refreshAccountConnectors() { + val session = state.value.authSession ?: return + viewModelScope.launch { + refreshAccountConnectors(session) + } + } + + private suspend fun refreshAccountConnectors(session: AuthSession) { + accountConnectorRefreshMutex.withLock { + val token = accountConnectorAuthToken(session) + _state.update { it.copy(loadingAccountConnectors = true) } + runCatching { withTimeout(15_000L) { accountConnectorRepository.fetchConnectors(token) } } + .onSuccess { connectors -> + _state.update { it.copy(accountConnectors = connectors, loadingAccountConnectors = false) } + } + .onFailure { error -> + if (error is CancellationException) return@onFailure + _state.update { it.copy(loadingAccountConnectors = false, error = error.message ?: "Failed to load account connections") } + } + } + } + + private suspend fun refreshSettingsAccountData(session: AuthSession) { + val token = accountConnectorAuthToken(session) + coroutineScope { + val subscription = async { + runCatching { + val vpcId = catalogRepository.getVpcId(token, session.provider.streamingServiceUrl) + subscriptionRepository.fetchSubscription(token, session.user.userId, vpcId) + }.getOrNull() + } + val regions = async { + runCatching { fetchDynamicRegions(http, token, session.provider.streamingServiceUrl).first } + .getOrDefault(emptyList()) + } + val fetchedSubscription = subscription.await() + val enrichedSession = persistSubscriptionTier(session, fetchedSubscription) + val fetchedRegions = regions.await() + _state.update { current -> + current.copy( + authSession = current.authSession + ?.takeIf { it.user.userId == enrichedSession.user.userId } + ?.let { enrichedSession } + ?: current.authSession, + savedAccounts = savedAccountsSnapshot(), + subscriptionInfo = fetchedSubscription ?: current.subscriptionInfo, + regions = fetchedRegions.ifEmpty { current.regions }, + ) + } + } + } + + fun connectAccountConnector(store: String, openUrl: (String) -> Unit) { + val session = state.value.authSession ?: return + viewModelScope.launch { + val token = accountConnectorAuthToken(session) + _state.update { it.copy(connectorActionStore = store, error = null) } + runCatching { withTimeout(20_000L) { accountConnectorRepository.loginUrl(store, token) } } + .onSuccess { url -> + _state.update { it.copy(connectorActionStore = null) } + openUrl(url) + refreshAccountConnectorsAfterLinking() + } + .onFailure { error -> + if (error is CancellationException) return@onFailure + val message = error.message + ?: getApplication().getString(R.string.error_store_connect_failed) + Toast.makeText(getApplication(), message, Toast.LENGTH_SHORT).show() + _state.update { it.copy(connectorActionStore = null, error = message) } + } + } + } + + private suspend fun accountConnectorAuthToken(session: AuthSession): String { + val latestSession = refreshedSessionOrFallback( + fallback = session, + refresh = { authRepository.restore(forceRefresh = false) }, + onFailure = { error -> + recordDebugEvent("auth", "Account token refresh failed error=${error.debugMessage()}") + }, + ) + return latestSession.tokens.idToken ?: latestSession.tokens.accessToken + } + + private fun refreshAccountConnectorsAfterLinking() { + viewModelScope.launch { + repeat(6) { attempt -> + delay(if (attempt == 0) 5_000L else 10_000L) + val session = state.value.authSession ?: return@launch + refreshAccountConnectors(session) + } + } + } + + fun disconnectAccountConnector(store: String) { + val session = state.value.authSession ?: return + viewModelScope.launch { + val token = accountConnectorAuthToken(session) + _state.update { it.copy(connectorActionStore = store, error = null) } + runCatching { accountConnectorRepository.disconnect(store, token) } + .onSuccess { + Toast.makeText( + getApplication(), + getApplication().getString(R.string.toast_store_disconnected), + Toast.LENGTH_SHORT, + ).show() + _state.update { it.copy(connectorActionStore = null) } + refreshAccountConnectors() + } + .onFailure { error -> + if (error is CancellationException) return@onFailure + val message = error.message + ?: getApplication().getString(R.string.error_store_disconnect_failed) + Toast.makeText(getApplication(), message, Toast.LENGTH_SHORT).show() + _state.update { it.copy(connectorActionStore = null, error = message) } + } + } + } + + fun updateStreamSettings(transform: (StreamSettings) -> StreamSettings) { + val snapshot = state.value + settingsStore.update { + it.copy( + stream = transform(it.stream) + .withAndroidSettingsAvailability() + .withFpsAllowed(snapshot.subscriptionInfo, snapshot.authSession?.user?.membershipTier) + .withAndroidHdrCompatibility(androidTvProfile), + streamPreset = StreamPreset.Custom, + ) + } + } + + fun applyStreamPreset(preset: StreamPreset) { + val snapshot = state.value + settingsStore.update { settings -> + val presetStream = (if (preset == StreamPreset.Recommended) { + (deviceRecommendation ?: recommendedAndroidStreamProfile(getApplication(), snapshot.codecReport)).stream + .withoutExperimentalTransportRequests() + } else { + settings.stream.applyingStreamPreset(preset) + }).withMicrophoneSettingsFrom(settings.stream) + settings.copy( + streamPreset = preset, + stream = presetStream + .withAndroidSettingsAvailability() + .withResolutionAllowed(snapshot.subscriptionInfo, snapshot.authSession?.user?.membershipTier) + .withFpsAllowed(snapshot.subscriptionInfo, snapshot.authSession?.user?.membershipTier) + .withAndroidHdrCompatibility(androidTvProfile), + ) + } + } + + fun updateFavorites(gameId: String) { + val adding = gameId !in settingsStore.settings.value.favoriteGameIds + settingsStore.update { + val next = if (gameId in it.favoriteGameIds) it.favoriteGameIds - gameId else it.favoriteGameIds + gameId + it.copy(favoriteGameIds = next) + } + OpenNowAnalytics.capture( + event = "favorite_toggled", + properties = mapOf( + "game_id" to gameId, + "action" to if (adding) "add" else "remove", + ), + ) + } + + fun setDefaultGameVariant(gameId: String, variantId: String?) { + settingsStore.update { + val next = it.defaultGameVariantIds.toMutableMap() + if (variantId.isNullOrBlank()) { + next.remove(gameId) + } else { + next[gameId] = variantId + } + it.copy(defaultGameVariantIds = next) + } + } + + fun dismissMembershipNotice() { + _state.update { it.copy(pendingMembershipNotice = null) } + } + + /** Launches anyway. The warning informs; it does not decide for the player. */ + fun continuePastMembershipNotice() { + val pending = state.value.pendingMembershipNotice ?: return + _state.update { it.copy(pendingMembershipNotice = null) } + OpenNowAnalytics.capture( + event = "membership_gate_overridden", + properties = mapOf( + "game_id" to pending.game.id, + "required_plan" to pending.requirement.requiredPlanLabel, + ), + ) + play( + game = pending.game, + streamingBaseUrlOverride = pending.streamingBaseUrlOverride, + skipPrintedWaste = pending.skipPrintedWaste, + skipStoreChoice = pending.skipStoreChoice, + skipMembershipNotice = true, + ) + } + + fun dismissStoreChoice() { + _state.update { it.copy(pendingStoreChoiceGame = null) } + } + + fun chooseStore(game: GameInfo) { + val launchVariants = launchableGameVariants(game.variants) + if (launchVariants.size > 1) { + _state.update { it.copy(pendingStoreChoiceGame = game, selectedGame = null, error = null) } + } else { + play(game, skipStoreChoice = true) + } + } + + fun playVariant(game: GameInfo, variant: GameVariant) { + _state.update { it.copy(pendingStoreChoiceGame = null) } + play(game.withSelectedVariant(variant.id), skipStoreChoice = true) + } + + fun play( + game: GameInfo, + streamingBaseUrlOverride: String? = null, + skipPrintedWaste: Boolean = false, + skipStoreChoice: Boolean = false, + skipMembershipNotice: Boolean = false, + ) { + if (launchJob?.isActive == true) { + recordDebugEvent("launch", "Ignored play request while another launch is active game=${game.title}") + return + } + // Warn before launching rather than after: GFN accepts the session and then fails, or + // silently downgrades, and from the player's side that is indistinguishable from a bug. + if (!skipMembershipNotice) { + val requirement = gameMembershipRequirement( + game = game, + subscriptionInfo = state.value.subscriptionInfo, + fallbackMembershipTier = state.value.authSession?.user?.membershipTier, + ) + if (requirement != null) { + recordDebugEvent( + "launch", + "Membership gate game=${game.title} requires=${requirement.requiredPlanLabel} " + + "current=${requirement.currentPlanLabel}", + ) + OpenNowAnalytics.capture( + event = "membership_gate_shown", + properties = mapOf( + "game_id" to game.id, + "required_plan" to requirement.requiredPlanLabel, + "current_plan" to requirement.currentPlanLabel, + ), + ) + _state.update { + it.copy( + pendingMembershipNotice = PendingMembershipNotice( + game = game, + requirement = requirement, + streamingBaseUrlOverride = streamingBaseUrlOverride, + skipPrintedWaste = skipPrintedWaste, + skipStoreChoice = skipStoreChoice, + ), + selectedGame = null, + error = null, + ) + } + return + } + } + if (!skipStoreChoice) { + val launchVariants = launchableGameVariants(game.variants) + val defaultVariantId = state.value.settings.defaultGameVariantIds[game.id] + val defaultVariant = launchVariants.firstOrNull { it.id == defaultVariantId } + if (defaultVariant != null) { + recordDebugEvent("launch", "Using default launcher ${gameStoreDisplayName(defaultVariant.store)} for ${game.title}") + Toast.makeText( + getApplication(), + getApplication().getString( + R.string.store_selector_default_launch_notice, + gameStoreDisplayName(defaultVariant.store), + ), + Toast.LENGTH_SHORT, + ).show() + play( + game.withSelectedVariant(defaultVariant.id), + streamingBaseUrlOverride, + skipPrintedWaste, + skipStoreChoice = true, + skipMembershipNotice = true, + ) + return + } + if (launchVariants.size > 1) { + recordDebugEvent("launch", "Waiting for launcher choice game=${game.title} variants=${launchVariants.size}") + _state.update { it.copy(pendingStoreChoiceGame = game, selectedGame = null, error = null) } + return + } + } + launchJob = viewModelScope.launch { + val auth = state.value.authSession ?: run { + recordDebugEvent("launch", "Play request ignored without an auth session game=${game.title}") + return@launch + } + // Wait for active subscription fetch to finish so we have accurate membership info to allow resolutions + activeSubscriptionJob?.join() + val returnPage = state.value.page.takeUnless { it == AppPage.Stream } ?: state.value.streamReturnPage ?: AppPage.Home + if (!skipPrintedWaste && streamingBaseUrlOverride == null && shouldUsePrintedWasteQueue(auth)) { + recordDebugEvent("queue", "Opening PrintedWaste selector game=${game.title}") + showPrintedWasteSelector(game) + return@launch + } + awaitDeviceCapabilityProbe() + val requestedSettings = streamSettingsBeforeDeviceAdjustment() + val settings = requestedSettings.adjustedForDevice(state.value.codecReport) + prepareSessionReport( + gameTitle = game.title, + selectedSettings = state.value.settings.stream, + eligibleSettings = requestedSettings, + initialSettings = settings, + ) + if (settings != requestedSettings) { + recordDebugEvent( + "launch", + "Adjusted stream settings requested=${requestedSettings.debugSummary()} effective=${settings.debugSummary()}", + ) + } + val token = auth.tokens.idToken ?: auth.tokens.accessToken + val baseUrl = streamingBaseUrlOverride ?: effectiveStreamingBaseUrl() + val manuallySelectedServer = manuallySelectedServerForReport( + streamingBaseUrlOverride = streamingBaseUrlOverride, + configuredRegion = requestedSettings.region, + ) + pendingActiveSessionLaunch = null + recordDebugEvent( + "launch", + "Starting launch game=${game.title} base=${hostForDebug(baseUrl)} settings=${settings.debugSummary()} override=${streamingBaseUrlOverride != null}", + ) + recordQueuedGame(game) + OpenNowAnalytics.capture( + event = "stream_started", + properties = mapOf( + "game_id" to game.id, + "game_title" to game.title, + "resolution" to settings.resolution, + "fps" to settings.fps, + "codec" to settings.codec.name, + ), + ) + _state.update { + it.copy( + streamStatus = "queue", + launchPhase = "Resolving game", + streamGame = game, + manuallySelectedServerForReport = manuallySelectedServer, + activeStreamSettings = settings, + streamInputModeAtLaunch = null, + selectedGame = null, + page = AppPage.Stream, + streamReturnPage = returnPage, + streamLaunchMinimized = false, + error = null, + queuePosition = null, + queueAdActiveId = null, + pendingStoreChoiceGame = null, + pendingPrintedWasteGame = null, + activeSessionDecision = null, + printedWasteError = null, + printedWastePings = emptyMap(), + sessionReport = null, + ) + } + runCatching { + val requestedVariantId = game.variants.getOrNull(game.selectedVariantIndex)?.id + ?: game.variants.firstOrNull()?.id + var launchGame = game + var selectedVariant = launchGame.variants.firstOrNull { it.id == requestedVariantId } + ?: launchGame.variants.getOrNull(launchGame.selectedVariantIndex) + ?: launchGame.variants.firstOrNull() + _state.update { it.copy(launchPhase = "Refreshing game access") } + runCatching { + catalogRepository.hydrateGameForLaunch(token, baseUrl, launchGame, selectedVariant) + }.onSuccess { hydrated -> + launchGame = hydrated + selectedVariant = launchGame.variants.firstOrNull { it.id == requestedVariantId } + ?: launchGame.variants.getOrNull(launchGame.selectedVariantIndex) + ?: launchGame.variants.firstOrNull() + }.onFailure { error -> + recordDebugEvent("launch", "Game access refresh failed; using cached metadata error=${error.debugMessage()}") + } + if (shouldMarkVariantOwnedBeforeLaunch(selectedVariant)) { + val unownedVariant = checkNotNull(selectedVariant) + _state.update { it.copy(launchPhase = "Marking game as owned") } + catalogRepository.addOwnedVariant(token, unownedVariant.id) + launchGame = launchGame.withManuallyOwnedVariant(unownedVariant.id) + selectedVariant = launchGame.variants.firstOrNull { it.id == unownedVariant.id } + recordDebugEvent("launch", "Marked variant as owned in GFN library variant=${unownedVariant.id}") + } + val accountLinked = shouldSendAccountLinked(launchGame, selectedVariant) + _state.update { it.copy(launchPhase = "Resolving game", streamGame = launchGame) } + val candidateId = selectedVariant?.id ?: launchGame.launchAppId ?: launchGame.uuid ?: launchGame.id + val launchAppId = candidateId.takeIf { it.all(Char::isDigit) } + ?: launchGame.launchAppId?.takeIf { it.all(Char::isDigit) } + ?: catalogRepository.resolveLaunchAppId(token, candidateId, baseUrl) + ?: error("Could not resolve numeric appId for ${launchGame.title}") + recordDebugEvent("launch", "Resolved appId=$launchAppId candidate=$candidateId game=${launchGame.title}") + + _state.update { it.copy(launchPhase = "Checking active sessions") } + val active = sessionRepository.getActiveSessions(token, baseUrl, settings) + recordDebugEvent("queue", "Active sessions checked count=${active.size} ${active.joinToString(limit = 4) { it.debugSummary() }}") + val numericLaunchAppId = launchAppId.toIntOrNull() + val activeConflict = activeSessionLaunchConflict(active, numericLaunchAppId, settings) + if (activeConflict != null) { + pendingActiveSessionLaunch = PendingActiveSessionLaunch( + game = launchGame, + launchAppId = launchAppId, + baseUrl = baseUrl, + settings = settings, + accountLinked = accountLinked, + activeSession = activeConflict, + returnPage = returnPage, + ) + recordDebugEvent("queue", "Active session decision required ${activeConflict.debugSummary()} requestedApp=$launchAppId") + _state.update { + it.copy( + activeSession = activeConflict, + activeSessionDecision = ActiveSessionDecision( + activeSession = activeConflict, + requestedGameTitle = launchGame.title, + ), + streamSession = null, + launchPhase = "Active session found", + queuePosition = activeConflict.queuePosition, + queueAdActiveId = null, + ) + } + return@runCatching null + } + _state.update { it.copy(launchPhase = "Creating session") } + val created = sessionRepository.createSession( + token = token, + streamingBaseUrl = baseUrl, + appId = launchAppId, + internalTitle = launchGame.title, + zone = "prod", + settings = settings, + accountLinked = accountLinked, + appLaunchMode = appLaunchModeFor(launchGame, settings), + ) + recordDebugEvent("queue", "Created session ${created.debugSummary()}") + pollUntilReady(token, created, settings) + }.onSuccess { readySession -> + if (readySession == null) return@onSuccess + markSessionReadyForNativeStream(readySession, settings) + }.onFailure { error -> + if (error is CancellationException) return@onFailure + recordDebugEvent("launch", "Launch failed game=${game.title} error=${error.debugMessage()}") + val returnPage = state.value.streamReturnPage ?: AppPage.Home + _state.update { + it.copy( + error = normalizeLaunchError(error, game.title), + streamStatus = "idle", + activeStreamSettings = null, + streamReturnPage = null, + launchPhase = "", + streamLaunchMinimized = false, + queuePosition = null, + queueAdActiveId = null, + pendingStoreChoiceGame = null, + activeSessionDecision = null, + page = returnPage, + ) + } + } + } + } + + private fun recordQueuedGame(game: GameInfo) { + val next = queuedGameStore.record(gameTrackingKey(game)) + _state.update { it.copy(queuedGameKeys = next) } + } + + private fun markSessionReadyForNativeStream(readySession: SessionInfo, settings: StreamSettings) { + val anchoredSession = readySession.withSessionTimerAnchor() + if (streamReportLaunchProfile == null) { + prepareSessionReport( + gameTitle = state.value.streamGame?.title.orEmpty(), + selectedSettings = state.value.settings.stream, + eligibleSettings = settings, + initialSettings = settings, + ) + } + recordDebugEvent("stream", "Session ready for native stream ${anchoredSession.debugSummary()}") + _state.update { + it.copy( + streamSession = anchoredSession, + activeStreamSettings = settings, + streamStatus = "connecting", + launchPhase = "Connecting stream", + streamLaunchMinimized = false, + queuePosition = null, + queueAdActiveId = null, + activeSessionDecision = null, + page = AppPage.Stream, + ) + } + } + + private fun prepareSessionReport( + gameTitle: String, + selectedSettings: StreamSettings, + eligibleSettings: StreamSettings, + initialSettings: StreamSettings, + ) { + streamReportLaunchProfile = StreamReportLaunchProfile( + gameTitle = gameTitle, + selectedSettings = selectedSettings, + eligibleSettings = eligibleSettings, + initialSettings = initialSettings, + ) + streamSessionReportAccumulator = null + lastSessionReportNetworkSampleAtMs = 0L + sessionReportFinalizedForStop = false + } + + private fun ensureSessionReportAccumulator(nowMs: Long = System.currentTimeMillis()) { + if (sessionReportFinalizedForStop || streamSessionReportAccumulator != null) return + val snapshot = state.value + if (snapshot.streamSession == null || snapshot.streamStatus !in setOf("connecting", "streaming")) return + val initialSettings = snapshot.activeStreamSettings ?: return + val profile = streamReportLaunchProfile ?: StreamReportLaunchProfile( + gameTitle = snapshot.streamGame?.title.orEmpty(), + selectedSettings = snapshot.settings.stream, + eligibleSettings = initialSettings, + initialSettings = initialSettings, + ).also { streamReportLaunchProfile = it } + streamSessionReportAccumulator = StreamSessionReportAccumulator(profile, startedAtMs = nowMs) + lastSessionReportNetworkSampleAtMs = 0L + } + + private fun finishSessionReport(nowMs: Long = System.currentTimeMillis()): SessionReport? { + if (sessionReportFinalizedForStop) return null + sessionReportFinalizedForStop = true + val report = streamSessionReportAccumulator?.finish(nowMs) + if (report != null) { + recordDebugEvent( + "stream", + "Session report score=${report.score} samples=${report.sampleCount} " + + "ping=${report.averagePingMs ?: -1} loss=${report.packetLossPct ?: -1.0} " + + "bitrate=${report.averageBitrateKbps ?: -1}", + ) + } + streamSessionReportAccumulator = null + streamReportLaunchProfile = null + lastSessionReportNetworkSampleAtMs = 0L + return report + } + + fun stopStream() { + val beforeStop = state.value + streamSessionRecoveryTracker.reset() + val completedSessionReport = finishSessionReport() + val shouldShowCompletedSessionReport = beforeStop.settings.showSessionReportAfterStream + recordDebugEvent( + "stream", + "Stop requested status=${beforeStop.streamStatus} session=${beforeStop.streamSession?.shortDebugId().orEmpty()} game=${beforeStop.streamGame?.title.orEmpty()}", + ) + launchJob?.cancel() + launchJob = null + pendingActiveSessionLaunch = null + viewModelScope.launch { + val auth = state.value.authSession + val snapshot = state.value + val returnPage = snapshot.streamReturnPage ?: AppPage.Home + val session = snapshot.streamSession + val streamSettings = snapshot.activeStreamSettings ?: effectiveStreamSettings() + if (auth != null && session != null) { + runCatching { sessionRepository.stopSession(auth.tokens.idToken ?: auth.tokens.accessToken, session, streamSettings) } + .onSuccess { + sessionTimerAnchorStore.clear(session.sessionId) + recordDebugEvent("stream", "Stopped cloud session ${session.shortDebugId()}") + } + .onFailure { error -> recordDebugEvent("stream", "Failed to stop cloud session ${session.shortDebugId()} error=${error.debugMessage()}") } + } else if (auth != null) { + val token = auth.tokens.idToken ?: auth.tokens.accessToken + val active = snapshot.activeSession + ?: runCatching { + sessionRepository.getActiveSessions(token, effectiveStreamingBaseUrl(auth), streamSettings) + .firstOrNull { it.status in setOf(1, 2, 3) } + }.getOrNull() + if (active != null) { + runCatching { sessionRepository.stopActiveSession(token, active, streamSettings) } + .onSuccess { + sessionTimerAnchorStore.clear(active.sessionId) + recordDebugEvent("stream", "Stopped active session ${active.shortDebugId()}") + } + .onFailure { error -> recordDebugEvent("stream", "Failed to stop active session ${active.shortDebugId()} error=${error.debugMessage()}") } + } else { + recordDebugEvent("stream", "No cloud session found to stop") + } + } + OpenNowAnalytics.capture( + event = "stream_stopped", + properties = mapOf( + "game_title" to (state.value.streamGame?.title ?: ""), + "game_id" to (state.value.streamGame?.id ?: ""), + ), + ) + _state.update { + it.copy( + streamSession = null, + activeStreamSettings = null, + streamInputModeAtLaunch = null, + streamGame = null, + streamStatus = "idle", + streamLaunchMinimized = false, + streamReturnPage = null, + launchPhase = "", + queuePosition = null, + queueAdActiveId = null, + pendingStoreChoiceGame = null, + activeSessionDecision = null, + page = returnPage, + sessionReport = if (shouldShowCompletedSessionReport) { + completedSessionReport ?: it.sessionReport + } else { + null + }, + ) + } + refreshActiveSession() + recordDebugEvent("stream", "Stream state reset returnPage=$returnPage") + } + } + + fun refreshPrintedWasteQueues() { + val game = state.value.pendingPrintedWasteGame ?: return + recordDebugEvent("queue", "Refreshing PrintedWaste queues game=${game.title}") + viewModelScope.launch { + loadPrintedWasteQueue(game) + } + } + + fun minimizeStreamLaunch() { + recordDebugEvent("queue", "Minimize launch requested status=${state.value.streamStatus} phase=${state.value.launchPhase}") + _state.update { current -> + if (!canMinimizeStreamLaunch(current.streamStatus, current.streamSession?.isReadyForStream() == true)) { + current + } else { + current.copy(streamLaunchMinimized = true, page = current.streamReturnPage ?: AppPage.Home) + } + } + } + + fun restoreStreamLaunch() { + recordDebugEvent("queue", "Restore launch requested status=${state.value.streamStatus} phase=${state.value.launchPhase}") + _state.update { current -> + if (current.streamStatus == "idle") current else current.copy(streamLaunchMinimized = false, page = AppPage.Stream) + } + } + + fun dismissActiveSessionDecision() { + val pending = pendingActiveSessionLaunch + recordDebugEvent("queue", "Active session decision dismissed session=${pending?.activeSession?.shortDebugId().orEmpty()}") + pendingActiveSessionLaunch = null + val returnPage = pending?.returnPage ?: state.value.streamReturnPage ?: AppPage.Home + _state.update { + it.copy( + streamStatus = "idle", + activeStreamSettings = null, + streamInputModeAtLaunch = null, + streamGame = null, + streamSession = null, + activeSessionDecision = null, + streamReturnPage = null, + launchPhase = "", + streamLaunchMinimized = false, + queuePosition = null, + queueAdActiveId = null, + page = returnPage, + ) + } + } + + fun terminateActiveSessionAndStartNew() { + if (launchJob?.isActive == true) { + recordDebugEvent("queue", "Ignored replace active session request while another launch is active") + return + } + val pending = pendingActiveSessionLaunch ?: run { + recordDebugEvent("queue", "Replace active session ignored without pending launch") + return + } + pendingActiveSessionLaunch = null + recordDebugEvent("queue", "Replace active session requested active=${pending.activeSession.debugSummary()} game=${pending.game.title}") + launchJob = viewModelScope.launch { + val auth = state.value.authSession ?: run { + recordDebugEvent("queue", "Replace active session ignored without an auth session") + return@launch + } + val token = auth.tokens.idToken ?: auth.tokens.accessToken + _state.update { + it.copy( + streamStatus = "queue", + launchPhase = "Ending active session", + activeSession = pending.activeSession, + activeSessionDecision = null, + streamSession = null, + streamGame = pending.game, + activeStreamSettings = pending.settings, + page = AppPage.Stream, + streamReturnPage = pending.returnPage, + streamLaunchMinimized = false, + error = null, + queuePosition = null, + queueAdActiveId = null, + sessionReport = null, + ) + } + runCatching { + runCatching { sessionRepository.stopActiveSession(token, pending.activeSession, pending.settings) } + .onSuccess { + sessionTimerAnchorStore.clear(pending.activeSession.sessionId) + recordDebugEvent("queue", "Stopped active session before new launch ${pending.activeSession.shortDebugId()}") + } + .onFailure { error -> recordDebugEvent("queue", "Failed to stop active session before new launch ${pending.activeSession.shortDebugId()} error=${error.debugMessage()}") } + _state.update { it.copy(activeSession = null, launchPhase = "Creating session") } + val created = sessionRepository.createSession( + token = token, + streamingBaseUrl = pending.baseUrl, + appId = pending.launchAppId, + internalTitle = pending.game.title, + zone = "prod", + settings = pending.settings, + accountLinked = pending.accountLinked, + appLaunchMode = appLaunchModeFor(pending.game, pending.settings), + ) + recordDebugEvent("queue", "Created replacement session ${created.debugSummary()}") + pollUntilReady(token, created, pending.settings) + }.onSuccess { readySession -> + markSessionReadyForNativeStream(readySession, pending.settings) + }.onFailure { error -> + if (error is CancellationException) return@onFailure + recordDebugEvent("launch", "Replace active session launch failed game=${pending.game.title} error=${error.debugMessage()}") + val returnPage = state.value.streamReturnPage ?: pending.returnPage + _state.update { + it.copy( + error = normalizeLaunchError(error, pending.game.title), + streamStatus = "idle", + activeStreamSettings = null, + streamReturnPage = null, + activeSessionDecision = null, + launchPhase = "", + streamLaunchMinimized = false, + queuePosition = null, + queueAdActiveId = null, + pendingStoreChoiceGame = null, + page = returnPage, + ) + } + } + } + } + + fun resumeActiveSession() { + if (launchJob?.isActive == true) { + recordDebugEvent("queue", "Ignored resume request while another launch is active") + return + } + pendingActiveSessionLaunch?.let { pending -> + resumePendingActiveSession(pending) + return + } + recordDebugEvent("queue", "Resume active session requested cached=${state.value.activeSession?.debugSummary().orEmpty()}") + launchJob = viewModelScope.launch { + val auth = state.value.authSession ?: run { + recordDebugEvent("queue", "Resume ignored without an auth session") + return@launch + } + val settings = effectiveStreamSettings() + val token = auth.tokens.idToken ?: auth.tokens.accessToken + val baseUrl = effectiveStreamingBaseUrl(auth) + val cachedActive = state.value.activeSession + val returnPage = state.value.page.takeUnless { it == AppPage.Stream } ?: state.value.streamReturnPage ?: AppPage.Home + _state.update { + it.copy( + streamStatus = "queue", + launchPhase = "Checking active sessions", + activeStreamSettings = settings, + page = AppPage.Stream, + streamReturnPage = returnPage, + streamLaunchMinimized = false, + selectedGame = null, + pendingStoreChoiceGame = null, + pendingPrintedWasteGame = null, + activeSessionDecision = null, + error = null, + queuePosition = null, + queueAdActiveId = null, + sessionReport = null, + ) + } + runCatching { + val active = cachedActive ?: sessionRepository.getActiveSessions(token, baseUrl, settings) + .let { activeSessionLaunchConflict(it, launchAppId = null, settings = settings) } + ?: error("No active cloud session was found. Start a game to create a new one.") + val resumeSettings = resumeSettingsForActiveSession(active, settings) + prepareSessionReport( + gameTitle = gameForActiveSession(active)?.title.orEmpty(), + selectedSettings = state.value.settings.stream, + eligibleSettings = streamSettingsBeforeDeviceAdjustment(), + initialSettings = resumeSettings, + ) + recordDebugEvent("queue", "Resume found active ${active.debugSummary()} base=${hostForDebug(baseUrl)} settings=${resumeSettings.debugSummary()}") + val matchingGame = gameForActiveSession(active) + _state.update { + it.copy( + activeSession = active, + streamGame = matchingGame, + streamSession = active.toPendingSession(zone = "prod"), + activeStreamSettings = resumeSettings, + launchPhase = if (active.isReadyForClaim()) "Resuming session" else loadingPhaseFor(active.toPendingSession(zone = "prod")), + ) + } + resumeKnownActiveSession(token, active, resumeSettings, baseUrl) + }.onSuccess { readySession -> + recordDebugEvent("stream", "Resume ready for native stream ${readySession.debugSummary()}") + markSessionReadyForNativeStream(readySession, state.value.activeStreamSettings ?: settings) + }.onFailure { error -> + if (error is CancellationException) return@onFailure + recordDebugEvent("queue", "Resume failed error=${error.debugMessage()}") + val returnPage = state.value.streamReturnPage ?: AppPage.Home + _state.update { + it.copy( + error = normalizeLaunchError(error, state.value.streamGame?.title), + streamStatus = "idle", + activeStreamSettings = null, + streamReturnPage = null, + launchPhase = "", + streamLaunchMinimized = false, + queuePosition = null, + queueAdActiveId = null, + pendingStoreChoiceGame = null, + pendingPrintedWasteGame = null, + activeSessionDecision = null, + page = returnPage, + ) + } + } + } + } + + private fun resumePendingActiveSession(pending: PendingActiveSessionLaunch) { + pendingActiveSessionLaunch = null + recordDebugEvent("queue", "Resume pending active session requested active=${pending.activeSession.debugSummary()} requestedGame=${pending.game.title}") + launchJob = viewModelScope.launch { + val auth = state.value.authSession ?: run { + recordDebugEvent("queue", "Pending resume ignored without an auth session") + return@launch + } + val token = auth.tokens.idToken ?: auth.tokens.accessToken + val resumeSettings = resumeSettingsForActiveSession(pending.activeSession, pending.settings) + val pendingSession = pending.activeSession.toPendingSession(zone = "prod") + prepareSessionReport( + gameTitle = gameForActiveSession(pending.activeSession)?.title ?: pending.game.title, + selectedSettings = state.value.settings.stream, + eligibleSettings = streamSettingsBeforeDeviceAdjustment(), + initialSettings = resumeSettings, + ) + _state.update { + it.copy( + streamStatus = "queue", + launchPhase = if (pending.activeSession.isReadyForClaim()) "Resuming session" else loadingPhaseFor(pendingSession), + activeSession = pending.activeSession, + activeSessionDecision = null, + streamSession = pendingSession, + streamGame = gameForActiveSession(pending.activeSession) ?: pending.game.takeIf { pending.activeSession.appId == pending.launchAppId.toIntOrNull() }, + activeStreamSettings = resumeSettings, + page = AppPage.Stream, + streamReturnPage = pending.returnPage, + streamLaunchMinimized = false, + error = null, + queuePosition = queueDisplayPosition(pendingSession), + queueAdActiveId = null, + sessionReport = null, + ) + } + runCatching { + resumeKnownActiveSession(token, pending.activeSession, resumeSettings, pending.baseUrl) + }.onSuccess { readySession -> + recordDebugEvent("stream", "Pending resume ready for native stream ${readySession.debugSummary()}") + markSessionReadyForNativeStream(readySession, resumeSettings) + }.onFailure { error -> + if (error is CancellationException) return@onFailure + recordDebugEvent("queue", "Pending resume failed error=${error.debugMessage()}") + _state.update { + it.copy( + error = normalizeLaunchError(error, pending.game.title), + streamStatus = "idle", + activeStreamSettings = null, + streamReturnPage = null, + activeSessionDecision = null, + launchPhase = "", + streamLaunchMinimized = false, + queuePosition = null, + queueAdActiveId = null, + pendingStoreChoiceGame = null, + pendingPrintedWasteGame = null, + page = pending.returnPage, + ) + } + } + } + } + + fun launchWithPrintedWaste(zoneUrl: String?) { + val game = state.value.pendingPrintedWasteGame ?: return + recordDebugEvent("queue", "PrintedWaste selection game=${game.title} zone=${hostForDebug(zoneUrl)} auto=${zoneUrl == null}") + launchJob?.cancel() + launchJob = null + _state.update { + it.copy( + pendingPrintedWasteGame = null, + printedWasteError = null, + printedWasteLoading = false, + ) + } + play(game, streamingBaseUrlOverride = zoneUrl, skipPrintedWaste = true, skipStoreChoice = true) + } + + private fun effectiveStreamSettings(): StreamSettings { + return streamSettingsBeforeDeviceAdjustment().adjustedForDevice(state.value.codecReport) + } + + private fun resumeSettingsForActiveSession(active: ActiveSessionInfo, requested: StreamSettings): StreamSettings { + val resolution = active.resolution?.takeIf { parseResolutionPixelsOrNull(it) != null } + return requested + .let { settings -> + if (resolution == null) { + settings + } else { + settings.copy( + resolution = resolution, + aspectRatio = streamAspectRatioForResolution(resolution) ?: settings.aspectRatio, + ) + } + } + .let { settings -> active.fps?.takeIf { it > 0 }?.let { settings.copy(fps = it) } ?: settings } + .withCodecColorCompatibility() + } + + private fun gameForActiveSession(active: ActiveSessionInfo): GameInfo? = + (state.value.games + state.value.libraryGames) + .firstOrNull { game -> + game.launchAppId == active.appId.toString() || + game.variants.any { variant -> variant.id == active.appId.toString() } + } + + private fun streamSettingsBeforeDeviceAdjustment(): StreamSettings { + val snapshot = state.value + return snapshot.settings.stream.eligibleForAndroidLaunch( + subscriptionInfo = snapshot.subscriptionInfo, + fallbackMembershipTier = snapshot.authSession?.user?.membershipTier, + androidTvProfile = androidTvProfile, + ) + } + + private suspend fun resolveFallbackLaunchAppId( + token: String, + game: GameInfo?, + active: ActiveSessionInfo?, + baseUrl: String, + ): String { + if (game == null) { + return active?.appId?.takeIf { it > 0 }?.toString() + ?: error("Could not resolve appId for safe H264 retry.") + } + val selectedVariant = game.variants.getOrNull(game.selectedVariantIndex) ?: game.variants.firstOrNull() + val candidateId = selectedVariant?.id ?: game.launchAppId ?: game.uuid ?: game.id + return candidateId.takeIf { it.all(Char::isDigit) } + ?: game.launchAppId?.takeIf { it.all(Char::isDigit) } + ?: active?.appId?.takeIf { it > 0 }?.toString() + ?: catalogRepository.resolveLaunchAppId(token, candidateId, baseUrl) + ?: error("Could not resolve numeric appId for ${game.title}") + } + + private fun String.isLikelyDirectServerUrl(): Boolean { + return isLikelyDirectSessionServerUrl(this) + } + + fun dismissPrintedWasteSelector() { + _state.update { + it.copy( + pendingPrintedWasteGame = null, + printedWasteLoading = false, + printedWasteError = null, + ) + } + } + + fun reportQueueAd( + adId: String, + action: String, + watchedTimeInMs: Long? = null, + pausedTimeInMs: Long? = null, + cancelReason: String? = null, + errorInfo: String? = null, + ) { + viewModelScope.launch { + val auth = state.value.authSession ?: run { + recordDebugEvent("ad", "Ignoring ad report without auth ad=${shortDebugId(adId)} action=$action") + return@launch + } + val session = state.value.streamSession ?: run { + recordDebugEvent("ad", "Ignoring ad report without session ad=${shortDebugId(adId)} action=$action") + return@launch + } + val normalizedAction = action.lowercase() + val isTerminalAction = normalizedAction == "finish" || normalizedAction == "cancel" + recordDebugEvent( + "ad", + "Report action=$normalizedAction ad=${shortDebugId(adId)} session=${session.shortDebugId()} watched=${watchedTimeInMs ?: 0} reason=${cancelReason.orEmpty()}", + ) + if (!isTerminalAction) { + _state.update { + it.copy(queueAdActiveId = adId) + } + } + runCatching { + queueAdReportMutex.withLock { + val reportSession = state.value.streamSession + ?.takeIf { it.sessionId == session.sessionId } + ?: session + if (isTerminalAction) { + val nextAdId = nextSessionAdId(reportSession.adState, adId) + _state.update { current -> + val currentSession = current.streamSession + if (currentSession?.sessionId == reportSession.sessionId) { + current.copy( + streamSession = removeSessionAdItem(currentSession, adId), + queueAdActiveId = nextAdId, + ) + } else { + current + } + } + } + sessionRepository.reportSessionAd( + token = auth.tokens.idToken ?: auth.tokens.accessToken, + session = reportSession, + adId = adId, + action = normalizedAction, + settings = state.value.settings.stream, + watchedTimeInMs = watchedTimeInMs, + pausedTimeInMs = pausedTimeInMs ?: 0L, + cancelReason = cancelReason, + errorInfo = errorInfo, + ) + } + }.onSuccess { updated -> + recordDebugEvent("ad", "Report accepted action=$normalizedAction updated=${updated.debugSummary()}") + _state.update { current -> + val previous = current.streamSession?.takeIf { it.sessionId == updated.sessionId } ?: session + val merged = mergeQueueAdReportResult( + previous = previous, + updated = updated, + adId = adId, + terminalAction = isTerminalAction, + ) + current.copy( + streamSession = merged, + queuePosition = queueDisplayPosition(merged), + queueAdActiveId = chooseQueueAdActiveId(current.queueAdActiveId, merged), + ) + } + }.onFailure { error -> + recordDebugEvent("ad", "Report failed action=$normalizedAction ad=${shortDebugId(adId)} error=${error.debugMessage()}") + _state.update { current -> + val currentSession = current.streamSession + if (normalizedAction == "finish" && currentSession?.adState != null) { + current.copy( + streamSession = currentSession.copy( + adState = currentSession.adState.copy( + sessionAds = emptyList(), + ads = emptyList(), + serverSentEmptyAds = false, + ), + ), + queueAdActiveId = null, + ) + } else { + current.copy(error = error.message ?: "Queue ad update failed") + } + } + } + } + } + + fun markStreamConnected() { + ensureSessionReportAccumulator() + if (state.value.streamStatus == "streaming") return + recordDebugEvent("stream", "Native stream connected session=${state.value.streamSession?.shortDebugId().orEmpty()} game=${state.value.streamGame?.title.orEmpty()}") + OpenNowAnalytics.capture( + event = "stream_connected", + properties = mapOf( + "game_title" to (state.value.streamGame?.title ?: ""), + "game_id" to (state.value.streamGame?.id ?: ""), + "resolution" to (state.value.activeStreamSettings?.resolution ?: ""), + "fps" to (state.value.activeStreamSettings?.fps ?: 0), + "codec" to (state.value.activeStreamSettings?.codec?.name ?: ""), + ), + ) + _state.update { it.copy(streamStatus = "streaming", launchPhase = "") } + } + + fun setAndroidPictureInPictureActive(active: Boolean) { + _state.update { current -> + if (current.androidPictureInPictureActive == active) current else current.copy(androidPictureInPictureActive = active) + } + } + + fun updateStreamRuntimeStats(stats: StreamRuntimeStats) { + if (!stats.hasDebugValues()) return + val now = System.currentTimeMillis() + ensureSessionReportAccumulator(now) + val reportNetwork = if (now - lastSessionReportNetworkSampleAtMs >= SESSION_REPORT_NETWORK_SAMPLE_INTERVAL_MS) { + lastSessionReportNetworkSampleAtMs = now + AndroidRuntimeDiagnostics.networkSnapshot(getApplication()) + } else { + null + } + streamSessionReportAccumulator?.record(stats, reportNetwork) + latestStreamRuntimeStats = TimedStreamRuntimeStats( + capturedAtMs = now, + sessionId = state.value.streamSession?.sessionId, + stats = stats, + ) + if (now - lastRuntimeStatsEventAtMs >= STREAM_RUNTIME_STATS_EVENT_INTERVAL_MS) { + lastRuntimeStatsEventAtMs = now + val requestedSettings = streamSettingsBeforeDeviceAdjustment() + val transportSettings = state.value.activeStreamSettings ?: requestedSettings + recordDebugEvent( + "runtime", + "stats requestedMaxBitrateMbps=${requestedSettings.maxBitrateMbps} transportMaxBitrateMbps=${transportSettings.maxBitrateMbps} " + + "${stats.debugSummary()} device=${AndroidRuntimeDiagnostics.snapshot(getApplication()).debugSummary()}", + ) + } + } + + fun markStreamError(message: String) { + recordDebugEvent("stream", "Native stream error message=${message.take(DEBUG_EVENT_MESSAGE_LIMIT)} session=${state.value.streamSession?.shortDebugId().orEmpty()}") + OpenNowAnalytics.capture( + event = "stream_error", + properties = mapOf( + "error_message" to message, + "game_title" to (state.value.streamGame?.title ?: ""), + "game_id" to (state.value.streamGame?.id ?: ""), + ), + ) + _state.update { it.copy(error = message, streamStatus = "idle", activeStreamSettings = null, launchPhase = "") } + } + + fun recordNativeStreamState(message: String) { + recordDebugEvent("native", "state=$message session=${state.value.streamSession?.shortDebugId().orEmpty()}") + } + + fun recordLocalVideoTransportFallback(reason: String, fallbackSettings: StreamSettings) { + ensureSessionReportAccumulator() + val currentSettings = state.value.activeStreamSettings ?: effectiveStreamSettings() + _state.update { current -> + if (current.streamSession == null || current.streamStatus == "idle") { + current + } else { + current.copy(activeStreamSettings = fallbackSettings) + } + } + streamSessionReportAccumulator?.recordRecovery(reason, fallbackSettings) + recordDebugEvent( + "recovery", + "Restarted local transport with codec fallback while keeping cloud session reason=${reason.take(DEBUG_EVENT_MESSAGE_LIMIT)} current=${currentSettings.debugSummary()} fallback=${fallbackSettings.debugSummary()}", + ) + } + + internal fun recordActiveStreamMode(status: ActiveStreamModeStatus) { + ensureSessionReportAccumulator() + streamSessionReportAccumulator?.recordActiveMode(status) + val current = state.value + val currentSettings = current.activeStreamSettings ?: effectiveStreamSettings() + val noticeKey = listOf( + current.streamGame?.id ?: current.activeSession?.appId?.toString() ?: current.streamSession?.sessionId.orEmpty(), + streamSettingsSessionSignature(currentSettings), + status.displayedResolution, + status.requestedResolution, + status.serverNegotiatedResolution.orEmpty(), + status.serverFinalSelectedResolution.orEmpty(), + status.resolutionSource?.name.orEmpty(), + status.safeVideoRecoveryActive.toString(), + status.transportCodec.name, + ).joinToString("|") + if (!runtimeResolutionNoticeKeys.add(noticeKey)) return + val resolutionSource = when (status.resolutionSource) { + StreamResolutionChangeSource.ServerNegotiatedFallback -> "Server negotiated fallback" + StreamResolutionChangeSource.ProviderOrGameModeChange -> "Provider/game runtime mode changed" + null -> "Client transport profile changed" + } + val recovery = if (status.safeVideoRecoveryActive) { + " clientRecovery=safe-${status.transportCodec.name}" + } else { + "" + } + recordDebugEvent( + "stream", + "$resolutionSource displayed=${status.displayedResolution} requested=${status.requestedResolution} " + + "server=${status.serverNegotiatedResolution.orEmpty()} final=${status.serverFinalSelectedResolution.orEmpty()}" + + "$recovery; keeping connected transport=${currentSettings.debugSummary()}", + ) + if (status.resolutionSource != null) { + refreshRuntimeSessionSnapshot(status) + } + } + + private fun refreshRuntimeSessionSnapshot(observedMode: ActiveStreamModeStatus) { + val initial = state.value + val auth = initial.authSession ?: return + val session = initial.streamSession ?: return + val settings = initial.activeStreamSettings ?: effectiveStreamSettings() + viewModelScope.launch { + val latest = runCatching { + sessionRepository.pollSession( + token = auth.tokens.idToken ?: auth.tokens.accessToken, + streamingBaseUrl = session.streamingBaseUrl ?: effectiveStreamingBaseUrl(auth), + serverIp = session.serverIp, + zone = session.zone, + sessionId = session.sessionId, + clientId = session.clientId, + deviceId = session.deviceId, + settings = settings, + ) + }.getOrElse { error -> + if (error is CancellationException) throw error + recordDebugEvent( + "stream", + "Runtime mode server snapshot failed session=${session.shortDebugId()} " + + "displayed=${observedMode.displayedResolution} error=${error.debugMessage()}", + ) + return@launch + } + if (state.value.streamSession?.sessionId != session.sessionId) return@launch + recordDebugEvent( + "stream", + "Runtime mode server snapshot session=${session.shortDebugId()} status=${latest.status} " + + "source=${observedMode.resolutionSource?.name.orEmpty()} displayed=${observedMode.displayedResolution} " + + "${latest.monitorSnapshot?.debugSummary().orEmpty()}", + ) + _state.update { current -> + val currentSession = current.streamSession + if (currentSession?.sessionId != session.sessionId) { + current + } else { + current.copy( + streamSession = currentSession.copy( + status = latest.status, + negotiatedStreamProfile = latest.negotiatedStreamProfile, + monitorSnapshot = latest.monitorSnapshot, + requestedStreamingFeatures = latest.requestedStreamingFeatures, + finalizedStreamingFeatures = latest.finalizedStreamingFeatures, + ), + ) + } + } + } + } + + fun recoverStreamSession(reason: String) { + if (launchJob?.isActive == true) { + recordDebugEvent("recovery", "Ignored stream recovery while launch job is active reason=${reason.take(DEBUG_EVENT_MESSAGE_LIMIT)}") + return + } + val initial = state.value + val auth = initial.authSession ?: run { + recordDebugEvent("recovery", "Recovery missing auth reason=${reason.take(DEBUG_EVENT_MESSAGE_LIMIT)}") + markStreamError(reason) + return + } + val initialSession = initial.streamSession ?: run { + recordDebugEvent("recovery", "Recovery missing stream session reason=${reason.take(DEBUG_EVENT_MESSAGE_LIMIT)}") + markStreamError(reason) + return + } + val currentSettings = initial.activeStreamSettings ?: effectiveStreamSettings() + val recoveryAttempt = streamSessionRecoveryTracker.nextAttempt(initialSession.sessionId) + recordDebugEvent( + "recovery", + "Recovery requested attempt=$recoveryAttempt reason=${reason.take(DEBUG_EVENT_MESSAGE_LIMIT)} " + + "session=${initialSession.debugSummary()} settings=${currentSettings.debugSummary()}", + ) + launchJob = viewModelScope.launch { + val token = auth.tokens.idToken ?: auth.tokens.accessToken + val snapshot = state.value + val previousSession = snapshot.streamSession ?: initialSession + val active = snapshot.activeSession + val game = snapshot.streamGame + val baseUrl = listOfNotNull( + previousSession.streamingBaseUrl, + active?.streamingBaseUrl, + effectiveStreamingBaseUrl(auth), + ).firstOrNull { !it.isLikelyDirectServerUrl() } ?: effectiveStreamingBaseUrl(auth) + val returnPage = snapshot.streamReturnPage ?: snapshot.page.takeUnless { it == AppPage.Stream } ?: AppPage.Home + + _state.update { + it.copy( + streamSession = null, + activeStreamSettings = currentSettings, + streamStatus = "connecting", + launchPhase = "Recovering stream", + page = AppPage.Stream, + streamReturnPage = returnPage, + streamLaunchMinimized = false, + error = null, + queuePosition = null, + queueAdActiveId = null, + ) + } + + runCatching { + val probedPreviousSession = runCatching { + sessionRepository.pollSession( + token = token, + streamingBaseUrl = previousSession.streamingBaseUrl ?: baseUrl, + serverIp = previousSession.serverIp, + zone = previousSession.zone, + sessionId = previousSession.sessionId, + clientId = previousSession.clientId, + deviceId = previousSession.deviceId, + settings = currentSettings, + diagnosticOperation = "session.recovery.probe", + ) + }.onSuccess { probed -> + recordDebugEvent( + "recovery", + "Old session GET completed session=${probed.shortDebugId()} status=${probed.status}", + ) + }.onFailure { error -> + if (error is CancellationException) throw error + recordDebugEvent( + "recovery", + "Old session GET failed session=${previousSession.shortDebugId()} error=${error.debugMessage()}", + ) + }.getOrNull() + when ( + streamSessionRecoveryDisposition( + recoveryAttempt = recoveryAttempt, + probedStatus = probedPreviousSession?.status, + ) + ) { + StreamSessionRecoveryDisposition.ReportEndedSession -> { + val ended = checkNotNull(probedPreviousSession) + recordDebugEvent( + "recovery", + "Provider ended allocated session status=${ended.status}; automatic replacement disabled", + ) + throw TerminalSessionStatusException(ended.status, ended) + } + StreamSessionRecoveryDisposition.ReclaimAllocatedSession -> { + if (recoveryAttempt > 1) { + recordDebugEvent( + "recovery", + "Repeated recovery remains pinned to allocated session=${previousSession.shortDebugId()} attempt=$recoveryAttempt", + ) + } + } + } + val resolvedAppId = runCatching { + resolveFallbackLaunchAppId( + token = token, + game = game, + active = active, + baseUrl = baseUrl, + ) + }.getOrNull() + val activeSessions = sessionRepository.getActiveSessions(token, baseUrl, currentSettings) + recordDebugEvent("recovery", "Recovery active sessions count=${activeSessions.size} base=${hostForDebug(baseUrl)}") + val readyCandidate = activeSessionRecoveryCandidate( + sessions = activeSessions, + previousSessionId = previousSession.sessionId, + launchAppId = resolvedAppId?.toIntOrNull(), + settings = currentSettings, + ) + if (readyCandidate?.sessionId == previousSession.sessionId && !readyCandidate.matchesStreamSettings(currentSettings)) { + recordDebugEvent( + "recovery", + "Reclaiming current session after local profile fallback active=${readyCandidate.debugSummary()} settings=${currentSettings.debugSummary()}", + ) + } + val cachedCurrentSession = active?.takeIf { + it.sessionId == previousSession.sessionId && it.matchesStreamGeometry(currentSettings) + } + val probedCandidate = probedPreviousSession?.let { probed -> + knownSessionRecoveryCandidate( + session = probed, + appId = resolvedAppId?.toIntOrNull() ?: active?.appId ?: 0, + fallbackActive = cachedCurrentSession, + settings = currentSettings, + ) + } + val fallbackCandidate = readyCandidate + ?: probedCandidate + ?: knownSessionRecoveryCandidate( + session = previousSession, + appId = resolvedAppId?.toIntOrNull() ?: active?.appId ?: 0, + fallbackActive = cachedCurrentSession, + settings = currentSettings, + )?.takeIf { it.matchesStreamGeometry(currentSettings) } + ?: error("The running session could not be found anymore, so recovery was not possible.") + recordDebugEvent("recovery", "Claiming recovery candidate ${fallbackCandidate.debugSummary()}") + claimActiveSessionOrContinuePolling( + token = token, + active = fallbackCandidate, + settings = currentSettings, + recoveryMode = true, + ) + }.onSuccess { readySession -> + val anchoredSession = readySession.withSessionTimerAnchor() + recordDebugEvent("recovery", "Recovery claim ready ${anchoredSession.debugSummary()}") + _state.update { + it.copy( + streamSession = anchoredSession, + activeSession = anchoredSession.toActiveRecoverySession(active, currentSettings), + activeStreamSettings = currentSettings, + streamStatus = "connecting", + launchPhase = "Reconnecting stream", + streamLaunchMinimized = false, + queuePosition = null, + queueAdActiveId = null, + page = AppPage.Stream, + ) + } + }.onFailure { error -> + if (error is CancellationException) return@onFailure + recordDebugEvent("recovery", "Recovery failed error=${error.debugMessage()}") + _state.update { + it.copy( + error = normalizeLaunchError(error, game?.title), + streamStatus = "idle", + activeStreamSettings = null, + streamReturnPage = null, + launchPhase = "", + streamLaunchMinimized = false, + queuePosition = null, + queueAdActiveId = null, + page = returnPage, + ) + } + } + } + } + + private fun SessionInfo.withSessionTimerAnchor(): SessionInfo = + copy( + timerStartedAtMs = sessionTimerAnchorStore.startedAtMsFor( + sessionId = sessionId, + preferredStartedAtMs = timerStartedAtMs, + ), + ) + + fun handleExternalLaunchIntent(intent: Intent?) { + if (intent == null) return + val uri = intent.data + if (localTvConnector.isPairUri(uri)) { + if (state.value.androidTvProfile) { + _state.update { it.copy(error = "Pairing links must be opened on the Android phone") } + } else if (uri != null) { + localTvConnector.pairPhone(uri) + } + return + } + if (authRepository.handleOAuthRedirect(uri)) { + _state.update { it.copy(launchPhase = LOGIN_PHASE_GETTING_TOKENS, error = null) } + return + } + val id = extractExternalLaunchId(intent) + if (id.isNullOrBlank()) return + val allGames = state.value.games + state.value.libraryGames + val game = allGames.firstOrNull { game -> + game.id == id || game.uuid == id || game.launchAppId == id || game.variants.any { it.id == id } + } ?: GameInfo( + id = id, + uuid = id, + launchAppId = id.takeIf { it.all(Char::isDigit) }, + title = intent.getStringExtra("title") ?: "Game $id", + selectedVariantIndex = 0, + variants = listOf(GameVariant(id = id, store = "Unknown")), + ) + play(game) + } + + private fun extractExternalLaunchId(intent: Intent): String? { + val uri = intent.data + return externalLaunchIdFromParts( + extras = listOf( + intent.getStringExtra("id"), + intent.getStringExtra("appId"), + intent.getStringExtra("launchAppId"), + ), + scheme = uri?.scheme, + host = uri?.host, + pathSegments = uri?.pathSegments.orEmpty(), + schemeSpecificPart = uri?.schemeSpecificPart, + queryParameters = mapOf( + "id" to uri?.let { runCatching { it.getQueryParameter("id") }.getOrNull() }, + "appId" to uri?.let { runCatching { it.getQueryParameter("appId") }.getOrNull() }, + "launchAppId" to uri?.let { runCatching { it.getQueryParameter("launchAppId") }.getOrNull() }, + ), + ) + } + + private fun GameInfo.withSelectedVariant(variantId: String): GameInfo { + val selectedIndex = variants.indexOfFirst { it.id == variantId } + return if (selectedIndex >= 0) copy(selectedVariantIndex = selectedIndex) else this + } + + private fun currentDebugLogText(): String { + val snapshot = state.value + val session = snapshot.streamSession + val codecReport = snapshot.codecReport + return buildString { + appendLine("OpenNOW Android diagnostics") + appendLine(snapshot.androidUpdate.debugHeaderLine()) + appendLine(AndroidDeviceDiagnostics.snapshot(getApplication()).debugSummary()) + appendLine("page=${snapshot.page} initializing=${snapshot.initializing} loadingGames=${snapshot.loadingGames}") + appendLine("user=${snapshot.authSession?.user?.displayName.orEmpty()} tier=${snapshot.subscriptionInfo?.membershipTier ?: snapshot.authSession?.user?.membershipTier.orEmpty()} provider=${snapshot.authSession?.provider?.code.orEmpty()}") + appendLine("streamStatus=${snapshot.streamStatus} launchPhase=${snapshot.launchPhase} queuePosition=${snapshot.queuePosition}") + appendLine("streamGame=${snapshot.streamGame?.title.orEmpty()} selectedGame=${snapshot.selectedGame?.title.orEmpty()}") + appendLine("sessionId=${session?.sessionId.orEmpty()} sessionStatus=${session?.status} seatSetupStep=${session?.seatSetupStep} serverIp=${session?.serverIp.orEmpty()} base=${session?.streamingBaseUrl.orEmpty()}") + appendLine("adsRequired=${isSessionAdsRequired(session?.adState)} ads=${sessionAdItems(session?.adState).size} activeAd=${snapshot.queueAdActiveId.orEmpty()} queuePaused=${session?.adState?.isQueuePaused}") + appendLine("adMessage=${session?.adState?.message.orEmpty()} grace=${session?.adState?.gracePeriodSeconds} serverSentEmptyAds=${session?.adState?.serverSentEmptyAds}") + appendLine("negotiated=${session?.negotiatedStreamProfile?.debugSummary().orEmpty()} monitors=${session?.monitorSnapshot?.debugSummary().orEmpty()} requestedFeatures=${session?.requestedStreamingFeatures?.debugSummary().orEmpty()} finalizedFeatures=${session?.finalizedStreamingFeatures?.debugSummary().orEmpty()}") + appendLine("printedWaste.loading=${snapshot.printedWasteLoading} queueZones=${snapshot.printedWasteQueue.size} mappingZones=${snapshot.printedWasteMapping.size} pings=${snapshot.printedWastePings.size} error=${snapshot.printedWasteError.orEmpty()}") + appendLine("settings.resolution=${snapshot.settings.stream.resolution} fps=${snapshot.settings.stream.fps} codec=${snapshot.settings.stream.codec} bitrate=${snapshot.settings.stream.maxBitrateMbps}") + appendLine("settings.preset=${snapshot.settings.streamPreset} recommendation=${deviceRecommendation?.debugSummary() ?: "pending"}") + snapshot.activeStreamSettings?.let { active -> + appendLine("active.resolution=${active.resolution} fps=${active.fps} codec=${active.codec} bitrate=${active.maxBitrateMbps}") + } + appendLine( + "input.keyboardLayout=${snapshot.settings.stream.keyboardLayout} " + + "mouseLock=${snapshot.settings.externalMousePointerLock} touch=${snapshot.settings.androidTouch}", + ) + appendLine("codec.native=${codecReport?.nativeRuntimeSummary.orEmpty()} lowPower=${codecReport?.lowPowerGpuProfile} constrained=${codecReport?.constrainedRuntimeProfile} tv=${codecReport?.androidTvProfile}") + appendLine("device.runtime=${AndroidRuntimeDiagnostics.snapshot(getApplication()).debugSummary()}") + appendLine("stream.runtime.latest=${latestStreamRuntimeStats?.debugSummary(System.currentTimeMillis()) ?: "empty"}") + appendLine(ProcessCpuDiagnostics.snapshot()) + codecReport?.capabilities?.forEach { cap -> + appendLine("codec.${cap.codec}: decoder=${cap.decoderName ?: "none"} hardware=${cap.hardwareDecoder} nativeAvailable=${cap.nativeDecoderAvailable ?: "unknown"} webRtc=${cap.webRtcDecoderName ?: "none"} webRtcAvailable=${cap.webRtcDecoderAvailable ?: "unknown"} webRtcHardware=${cap.webRtcHardwareDecoderAvailable ?: "unknown"} encoder=${cap.encoderName ?: "none"}") + } + appendLine(DisplayRefreshDiagnostics.snapshot()) + appendLine(NativeInputDiagnostics.snapshot()) + appendLine(OpenNowHttpDiagnostics.snapshot()) + snapshot.error?.let { appendLine("error=$it") } + val events = debugEventSnapshot() + appendLine("events.count=${events.size} max=$DEBUG_EVENT_LIMIT") + if (events.isEmpty()) { + appendLine("events=(empty)") + } else { + val formatter = DateFormat.getTimeInstance(DateFormat.MEDIUM, Locale.US) + events.forEachIndexed { index, event -> + appendLine("event.${index + 1} ${formatter.format(Date(event.timestampMs))} [${event.category}] ${event.message}") + } + } + val payloads = debugPayloadSnapshot() + appendLine("advancedJson.count=${payloads.size} max=$DEBUG_PAYLOAD_LIMIT") + if (payloads.isEmpty()) { + appendLine("advancedJson=(empty)") + } else { + val formatter = DateFormat.getTimeInstance(DateFormat.MEDIUM, Locale.US) + payloads.forEachIndexed { index, payload -> + appendLine("advancedJson.${index + 1} ${formatter.format(Date(payload.timestampMs))} [${payload.operation}] ${payload.method} http=${payload.statusCode} url=${payload.url}") + if (payload.requestBody.isNotBlank()) { + appendLine("request:") + appendLine(payload.requestBody) + } + appendLine("response:") + appendLine(payload.body) + } + } + } + } + + fun debugLogText(): String = appendPreviousDiagnosticSnapshot( + current = currentDebugLogText(), + previous = diagnosticHistoryStore.previousSnapshot(), + ) + + suspend fun sanitizedDebugLogText(): String { + val raw = withContext(Dispatchers.IO) { debugLogText() } + return withContext(Dispatchers.Default) { sanitizeDiagnosticExport(raw) } + } + + fun debugLogFileName(): String { + val timestamp = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date()) + return "opennow-android-logs-$timestamp.txt" + } + + private companion object { + const val TV_INITIAL_CATALOG_GAME_LIMIT = 120 + const val TV_LAYOUT_PROFILE_VERSION = 1 + } + + private suspend fun refreshAfterAuth(session: AuthSession, keepRefreshVisibleWithCache: Boolean = false) { + catalogLoadAttempted = true + _state.update { it.copy(loadingGames = true, error = null) } + val baseUrl = effectiveStreamingBaseUrl(session) + val token = session.tokens.idToken ?: session.tokens.accessToken + val initialCatalogSearch = state.value.catalogSearch + val initialCatalogSortId = state.value.catalogSortId + val initialCatalogFilterIds = state.value.catalogFilterIds + val cacheKey = CatalogCacheKey.of( + userId = session.user.userId, + baseUrl = baseUrl, + searchQuery = initialCatalogSearch, + sortId = initialCatalogSortId, + filterIds = initialCatalogFilterIds, + ) + // One-shot: startup already parsed these, and after this the network result is authoritative. + val primed = primedCatalogCache?.takeIf { it.key == cacheKey } + primedCatalogCache = null + val (cachedMain, cachedLibrary, unboundedCachedCatalog) = primed?.let { + Triple(it.main, it.library, it.catalog) + } ?: withContext(Dispatchers.IO) { + Triple( + catalogCacheStore.loadMainGames(session.user.userId, baseUrl), + catalogCacheStore.loadLibraryGames(session.user.userId, baseUrl), + catalogCacheStore.loadCatalog( + userId = session.user.userId, + providerStreamingBaseUrl = baseUrl, + searchQuery = initialCatalogSearch, + sortId = initialCatalogSortId, + filterIds = initialCatalogFilterIds, + ), + ) + } + val cachedGfnThursdayGames = gfnThursdayCatalogGames(cachedMain.orEmpty()) + val cachedNewlyAddedQuery = isNewlyAddedCatalogQuery( + searchQuery = initialCatalogSearch, + sortId = initialCatalogSortId, + filterIds = initialCatalogFilterIds, + ) + val officialCachedCatalog = if (cachedNewlyAddedQuery && cachedGfnThursdayGames.isNotEmpty()) { + catalogResultWithGfnThursdayGames( + fallback = unboundedCachedCatalog ?: CatalogBrowseResult( + games = emptyList(), + selectedSortId = NEWLY_ADDED_CATALOG_SORT_ID, + ), + games = cachedGfnThursdayGames, + ) + } else { + unboundedCachedCatalog + } + val cachedCatalog = if (androidTvProfile) { + officialCachedCatalog?.copy(games = officialCachedCatalog.games.take(TV_INITIAL_CATALOG_GAME_LIMIT)) + } else { + officialCachedCatalog + } + val cachedNewlyAdded = primed?.newlyAdded ?: withContext(Dispatchers.IO) { + catalogCacheStore.loadCatalog( + userId = session.user.userId, + providerStreamingBaseUrl = baseUrl, + searchQuery = "", + sortId = NEWLY_ADDED_CATALOG_SORT_ID, + filterIds = emptyList(), + ) + } + val hasScopedCatalogQuery = + isScopedCatalogQuery(initialCatalogSearch, initialCatalogSortId, initialCatalogFilterIds) + if (cachedMain != null || cachedLibrary != null || cachedCatalog != null || cachedNewlyAdded != null) { + val cachedMergedLibrary = withContext(Dispatchers.Default) { + mergeKnownLibraryGames( + cachedLibrary.orEmpty(), + cachedMain.orEmpty(), + cachedCatalog?.games.orEmpty(), + ) + } + _state.update { + val nextGames = cachedCatalog?.games ?: if (hasScopedCatalogQuery) { + emptyList() + } else { + it.games.ifEmpty { cachedMain.orEmpty() } + } + val nextCatalogResult = cachedCatalog ?: if (hasScopedCatalogQuery) { + it.catalogResult.copy(games = emptyList()) + } else { + it.catalogResult + } + // The Store grid renders from these two and nothing else, so a warm library cache + // is not a reason to tell the reader the load has finished. + val hasGamesToShow = nextGames.isNotEmpty() || nextCatalogResult.games.isNotEmpty() + it.copy( + games = nextGames, + newlyAddedGames = cachedGfnThursdayGames.ifEmpty { + cachedNewlyAdded?.games?.ifEmpty { it.newlyAddedGames } ?: it.newlyAddedGames + }, + libraryGames = cachedMergedLibrary.ifEmpty { cachedLibrary ?: it.libraryGames }, + catalogResult = nextCatalogResult, + loadingGames = catalogStillLoadingAfterCache(hasGamesToShow, keepRefreshVisibleWithCache), + catalogQueryLoading = !hasGamesToShow, + error = null, + ) + } + } + val subscriptionJob = viewModelScope.launch { + val sub = withContext(Dispatchers.IO) { + runCatching { + val vpcId = catalogRepository.getVpcId(token, session.provider.streamingServiceUrl) + subscriptionRepository.fetchSubscription(token, session.user.userId, vpcId) + }.getOrNull() + } + val enrichedSession = persistSubscriptionTier(session, sub) + _state.update { current -> + current.copy( + authSession = current.authSession + ?.takeIf { it.user.userId == enrichedSession.user.userId } + ?.let { enrichedSession } + ?: current.authSession, + savedAccounts = savedAccountsSnapshot(), + subscriptionInfo = sub, + ) + } + } + activeSubscriptionJob = subscriptionJob + val accountConnectorsJob = viewModelScope.launch { + _state.update { it.copy(loadingAccountConnectors = true) } + val connectors = withContext(Dispatchers.IO) { + runCatching { accountConnectorRepository.fetchConnectors(token) }.getOrDefault(emptyList()) + } + _state.update { it.copy(accountConnectors = connectors, loadingAccountConnectors = false) } + } + val regionsJob = viewModelScope.launch { + val regions = withContext(Dispatchers.IO) { + runCatching { fetchDynamicRegions(http, token, session.provider.streamingServiceUrl).first }.getOrDefault(emptyList()) + } + _state.update { it.copy(regions = regions) } + } + gamesJob?.cancel() + gamesJob = viewModelScope.launch { + runCatching { + coroutineScope { + val includeSupplementalPublicVariants = !androidTvProfile + val initialNewlyAddedQuery = isNewlyAddedCatalogQuery( + searchQuery = initialCatalogSearch, + sortId = initialCatalogSortId, + filterIds = initialCatalogFilterIds, + ) + val catalogDeferred = async(Dispatchers.IO) { + catalogRepository.browseCatalog( + token = token, + providerStreamingBaseUrl = baseUrl, + searchQuery = initialCatalogSearch, + sortId = initialCatalogSortId, + filterIds = initialCatalogFilterIds, + maxPages = catalogPageLimit( + androidTvProfile = androidTvProfile, + filterIds = initialCatalogFilterIds, + searchQuery = initialCatalogSearch, + ), + includeSupplementalPublicVariants = includeSupplementalPublicVariants, + ).also { catalog -> + _state.update { current -> + if ( + !initialNewlyAddedQuery && + current.authSession?.user?.userId == session.user.userId && + current.catalogSearch == initialCatalogSearch && + current.catalogSortId == initialCatalogSortId && + current.catalogFilterIds == initialCatalogFilterIds + ) { + current.copy( + catalogResult = catalog, + games = if (androidTvProfile) catalog.games else current.games, + ) + } else { + current + } + } + } + } + // MainV2 is a ~600KB personalized panel response. Keep the normal TV path + // bounded, but fetch it for an explicit Latest Added query because its + // GFN Thursday section is NVIDIA's authoritative weekly content. + val mainDeferred: Deferred>>? = if (androidTvProfile && !initialNewlyAddedQuery) { + null + } else { + async(Dispatchers.IO) { + try { + Result.success( + catalogRepository.fetchMainGames(token, baseUrl, includeSupplementalPublicVariants), + ) + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + Result.failure(error) + } + } + } + val libraryDeferred = async(Dispatchers.IO) { + catalogRepository.fetchLibraryGames(token, baseUrl, includeSupplementalPublicVariants) + .also { library -> + _state.update { current -> + if (current.authSession?.user?.userId == session.user.userId) { + current.copy(libraryGames = library) + } else { + current + } + } + } + } + val providerCatalog = catalogDeferred.await() + val main = mainDeferred?.await()?.getOrElse { error -> + recordDebugEvent("catalog", "Main panel refresh failed; using catalog fallback error=${error.debugMessage()}") + providerCatalog.games + } ?: providerCatalog.games + val gfnThursdayGames = gfnThursdayCatalogGames(main) + val newlyAddedCatalog = when { + gfnThursdayGames.isNotEmpty() -> + catalogResultWithGfnThursdayGames(providerCatalog, gfnThursdayGames) + initialNewlyAddedQuery -> providerCatalog + else -> try { + withContext(Dispatchers.IO) { + catalogRepository.browseCatalog( + token = token, + providerStreamingBaseUrl = baseUrl, + searchQuery = "", + sortId = NEWLY_ADDED_CATALOG_SORT_ID, + filterIds = emptyList(), + maxPages = 1, + includeSupplementalPublicVariants = false, + ) + } + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + recordDebugEvent("catalog", "Newly added hero refresh failed error=${error.debugMessage()}") + cachedNewlyAdded + } + } + val catalog = if (initialNewlyAddedQuery) { + newlyAddedCatalog ?: providerCatalog + } else { + providerCatalog + } + newlyAddedCatalog?.let { newest -> + _state.update { current -> + if (current.authSession?.user?.userId == session.user.userId) { + current.copy(newlyAddedGames = newest.games) + } else { + current + } + } + } + val library = libraryDeferred.await() + val mergedLibrary = withContext(Dispatchers.Default) { + mergeKnownLibraryGames(library, main, catalog.games) + } + recordDebugEvent( + "catalog", + "Library counts raw=${library.size} main=${main.size} mainOwned=${main.count(::isGameInLibrary)} " + + "catalog=${catalog.games.size} catalogOwned=${catalog.games.count(::isGameInLibrary)} " + + "gfnThursday=${gfnThursdayGames.size} merged=${mergedLibrary.size} " + + "activeFilters=${state.value.libraryFilterIds.sorted().joinToString(",").ifBlank { "none" }}", + ) + withContext(Dispatchers.IO) { + catalogCacheStore.saveMainGames(session.user.userId, baseUrl, main) + catalogCacheStore.saveLibraryGames(session.user.userId, baseUrl, mergedLibrary) + catalogCacheStore.saveCatalog( + userId = session.user.userId, + providerStreamingBaseUrl = baseUrl, + searchQuery = initialCatalogSearch, + sortId = initialCatalogSortId, + filterIds = initialCatalogFilterIds, + result = catalog, + ) + newlyAddedCatalog?.let { newest -> + catalogCacheStore.saveCatalog( + userId = session.user.userId, + providerStreamingBaseUrl = baseUrl, + searchQuery = "", + sortId = NEWLY_ADDED_CATALOG_SORT_ID, + filterIds = emptyList(), + result = newest, + ) + } + } + Triple(mergedLibrary, catalog, newlyAddedCatalog) + } + }.onSuccess { (library, catalog, newlyAddedCatalog) -> + _state.update { current -> + if ( + current.authSession?.user?.userId == session.user.userId && + current.catalogSearch == initialCatalogSearch && + current.catalogSortId == initialCatalogSortId && + current.catalogFilterIds == initialCatalogFilterIds + ) { + current.copy( + // The browse result owns catalogue order and filtering. MainV2 is + // supplemental metadata and must never replace a user-sorted page. + games = catalog.games, + newlyAddedGames = newlyAddedCatalog?.games ?: current.newlyAddedGames, + libraryGames = library, + catalogResult = catalog, + loadingGames = false, + catalogQueryLoading = false, + error = null, + ) + } else { + current + } + } + catalogRetryJob?.cancel() + catalogRetryAttempt = 0 + refreshActiveSession() + }.onFailure { error -> + if (error is CancellationException) return@onFailure + _state.update { current -> + val hasUsableGames = + cachedMain != null || + cachedLibrary != null || + cachedCatalog != null || + current.games.isNotEmpty() || + current.libraryGames.isNotEmpty() || + current.catalogResult.games.isNotEmpty() + current.copy( + loadingGames = false, + catalogQueryLoading = false, + error = if (hasUsableGames) null else error.message ?: "Failed to load games", + ) + } + recordDebugEvent("catalog", "Catalog load failed error=${error.debugMessage()}") + // An empty Store with an error on it used to be terminal until someone pulled to + // refresh. Nothing else in the app ever asks again. + if (!state.value.hasLoadedCatalogGames()) { + scheduleCatalogRetry() + } + } + } + subscriptionJob.join() + accountConnectorsJob.join() + regionsJob.join() + } + + private fun refreshCatalogDebounced() { + gamesJob?.cancel() + gamesJob = viewModelScope.launch { + val auth = state.value.authSession ?: return@launch + val baseUrl = effectiveStreamingBaseUrl(auth) + val searchQuery = state.value.catalogSearch + val sortId = state.value.catalogSortId + val filterIds = state.value.catalogFilterIds + val newlyAddedQuery = isNewlyAddedCatalogQuery(searchQuery, sortId, filterIds) + val unboundedCachedCatalog = withContext(Dispatchers.IO) { + catalogCacheStore.loadCatalog( + userId = auth.user.userId, + providerStreamingBaseUrl = baseUrl, + searchQuery = searchQuery, + sortId = sortId, + filterIds = filterIds, + ) + } + val cachedCatalog = if (androidTvProfile) { + unboundedCachedCatalog?.copy(games = unboundedCachedCatalog.games.take(TV_INITIAL_CATALOG_GAME_LIMIT)) + } else { + unboundedCachedCatalog + } + _state.update { current -> + if ( + current.catalogSearch == searchQuery && + current.catalogSortId == sortId && + current.catalogFilterIds == filterIds + ) { + // A cached result that exists but holds no games still leaves the grid blank. + val hasGamesToShow = cachedCatalog?.games?.isNotEmpty() == true + current.copy( + loadingGames = catalogStillLoadingAfterCache(hasGamesToShow, keepRefreshVisible = false), + catalogQueryLoading = !hasGamesToShow, + catalogResult = cachedCatalog ?: current.catalogResult.copy(games = emptyList()), + games = cachedCatalog?.games ?: emptyList(), + error = null, + ) + } else { + current + } + } + runCatching { + withContext(Dispatchers.IO) { + coroutineScope { + val token = auth.tokens.idToken ?: auth.tokens.accessToken + val providerCatalogDeferred = async { + catalogRepository.browseCatalog( + token = token, + providerStreamingBaseUrl = baseUrl, + searchQuery = searchQuery, + sortId = sortId, + filterIds = filterIds, + maxPages = catalogPageLimit( + androidTvProfile = androidTvProfile, + filterIds = filterIds, + searchQuery = searchQuery, + ), + includeSupplementalPublicVariants = !androidTvProfile, + ) + } + val gfnThursdayDeferred: Deferred>>? = if (newlyAddedQuery) { + async { + try { + Result.success( + catalogRepository.fetchGfnThursdayGames( + token = token, + providerStreamingBaseUrl = baseUrl, + includeSupplementalPublicVariants = !androidTvProfile, + ), + ) + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + Result.failure(error) + } + } + } else { + null + } + val providerCatalog = providerCatalogDeferred.await() + val gfnThursdayGames = gfnThursdayDeferred?.await()?.getOrElse { error -> + recordDebugEvent("catalog", "GFN Thursday refresh failed; using provider sort error=${error.debugMessage()}") + emptyList() + }.orEmpty() + catalogResultWithGfnThursdayGames(providerCatalog, gfnThursdayGames) + } + } + }.onSuccess { result -> + val mergedLibrary = withContext(Dispatchers.Default) { + mergeKnownLibraryGames(state.value.libraryGames, result.games) + } + withContext(Dispatchers.IO) { + catalogCacheStore.saveCatalog( + userId = auth.user.userId, + providerStreamingBaseUrl = baseUrl, + searchQuery = searchQuery, + sortId = sortId, + filterIds = filterIds, + result = result, + ) + if (newlyAddedQuery && sortId != NEWLY_ADDED_CATALOG_SORT_ID) { + catalogCacheStore.saveCatalog( + userId = auth.user.userId, + providerStreamingBaseUrl = baseUrl, + searchQuery = "", + sortId = NEWLY_ADDED_CATALOG_SORT_ID, + filterIds = emptyList(), + result = result, + ) + } + } + _state.update { + if ( + it.catalogSearch == searchQuery && + it.catalogSortId == sortId && + it.catalogFilterIds == filterIds + ) { + it.copy( + catalogResult = result, + newlyAddedGames = if (newlyAddedQuery) result.games else it.newlyAddedGames, + loadingGames = false, + catalogQueryLoading = false, + games = result.games, + libraryGames = mergedLibrary.ifEmpty { it.libraryGames }, + ) + } else { + it + } + } + }.onFailure { error -> + if (error is CancellationException) return@onFailure + _state.update { current -> + if ( + current.catalogSearch == searchQuery && + current.catalogSortId == sortId && + current.catalogFilterIds == filterIds + ) { + current.copy( + error = if (cachedCatalog != null) null else error.message ?: "Catalog refresh failed", + loadingGames = false, + catalogQueryLoading = false, + ) + } else { + current + } + } + } + } + } + + private suspend fun showPrintedWasteSelector(game: GameInfo) { + recordDebugEvent("queue", "Loading PrintedWaste selector game=${game.title}") + _state.update { + it.copy( + pendingStoreChoiceGame = null, + pendingPrintedWasteGame = game, + printedWasteLoading = true, + printedWasteError = null, + printedWasteQueue = emptyMap(), + printedWasteMapping = emptyMap(), + printedWastePings = emptyMap(), + ) + } + loadPrintedWasteQueue(game) + } + + private suspend fun loadPrintedWasteQueue(game: GameInfo) { + recordDebugEvent("queue", "Fetching PrintedWaste queue data game=${game.title}") + _state.update { + it.copy( + pendingStoreChoiceGame = null, + pendingPrintedWasteGame = game, + printedWasteLoading = true, + printedWasteError = null, + ) + } + runCatching { + coroutineScope { + val queue = async { printedWasteRepository.fetchQueue() } + val mapping = async { printedWasteRepository.fetchServerMapping() } + val queueData = queue.await() + val mappingData = mapping.await() + val regions = queueData + .filter { (zoneId, _) -> isStandardPrintedWasteZone(zoneId) && mappingData[zoneId]?.nuked != true } + .map { (zoneId, _) -> + StreamRegion(name = zoneId, url = printedWasteZoneUrl(zoneId), pingMs = null) + } + val pings = printedWasteRepository.pingRegions(regions).associate { it.url to it.pingMs } + Triple(queueData, mappingData, pings) + } + }.onSuccess { (queue, mapping, pings) -> + val usableZones = queue + .filter { (zoneId, _) -> isStandardPrintedWasteZone(zoneId) && mapping[zoneId]?.nuked != true } + .keys + val bestZone = usableZones + .mapNotNull { zoneId -> + val zone = queue[zoneId] ?: return@mapNotNull null + val url = printedWasteZoneUrl(zoneId) + Triple(zoneId, zone.QueuePosition, pings[url]) + } + .minWithOrNull( + compareBy>( + { it.third ?: Long.MAX_VALUE }, + { it.second }, + ), + ) + recordDebugEvent( + "queue", + "PrintedWaste queue loaded zones=${queue.size} usable=${usableZones.size} best=${bestZone?.first.orEmpty()} bestQueue=${bestZone?.second ?: 0} bestPing=${bestZone?.third ?: -1}", + ) + _state.update { + it.copy( + printedWasteQueue = queue, + printedWasteMapping = mapping, + printedWastePings = pings, + printedWasteLoading = false, + printedWasteError = null, + ) + } + }.onFailure { error -> + if (error is CancellationException) return@onFailure + recordDebugEvent( + "queue", + "PrintedWaste queue load failed error=${error.debugMessage()} using=default", + ) + launchWithPrintedWaste(null) + } + } + + private suspend fun refreshActiveSession() { + val auth = state.value.authSession ?: return + val settings = effectiveStreamSettings() + val token = auth.tokens.idToken ?: auth.tokens.accessToken + val active = runCatching { sessionRepository.getActiveSessions(token, effectiveStreamingBaseUrl(auth), settings) } + .getOrDefault(emptyList()) + .firstOrNull { it.status in setOf(1, 2, 3) && it.matchesStreamSettings(settings) } + _state.update { it.copy(activeSession = active) } + } + + private suspend fun resumeKnownActiveSession( + token: String, + active: ActiveSessionInfo, + settings: StreamSettings, + baseUrl: String, + ): SessionInfo { + if (!active.matchesStreamSettings(settings)) { + recordDebugEvent( + "queue", + "Explicit resume is using active session settings active=${active.debugSummary()} requested=${settings.debugSummary()}", + ) + } + if (active.isReadyForClaim()) { + recordDebugEvent("queue", "Active session already ready for claim ${active.debugSummary()}") + _state.update { it.copy(launchPhase = "Resuming session") } + return claimActiveSessionOrContinuePolling(token, active, settings) + } + + val pending = active.toPendingSession(zone = "prod") + recordDebugEvent("queue", "Hydrating active session before resume ${pending.debugSummary()}") + val hydrated = runCatching { + sessionRepository.pollSession( + token = token, + streamingBaseUrl = active.streamingBaseUrl ?: baseUrl, + serverIp = active.serverIp, + zone = "prod", + sessionId = active.sessionId, + clientId = null, + deviceId = null, + settings = settings, + ) + }.getOrElse { error -> + recordDebugEvent("queue", "Resume hydrate failed session=${pending.shortDebugId()} error=${error.debugMessage()}") + pending + } + val latest = mergeQueueSessionState(pending, hydrated) + recordDebugEvent("queue", "Resume hydrate result ${latest.debugSummary()}") + _state.update { + it.copy( + streamSession = latest, + launchPhase = loadingPhaseFor(latest), + queuePosition = queueDisplayPosition(latest), + queueAdActiveId = chooseQueueAdActiveId(it.queueAdActiveId, latest), + ) + } + if (latest.isReadyForStream()) { + val hydratedActive = active.copy( + status = latest.status, + queuePosition = latest.queuePosition, + seatSetupStep = latest.seatSetupStep, + streamingBaseUrl = latest.streamingBaseUrl ?: active.streamingBaseUrl, + serverIp = latest.serverIp, + signalingUrl = latest.signalingUrl, + ) + recordDebugEvent("queue", "Resume session became ready ${latest.debugSummary()}") + _state.update { it.copy(launchPhase = "Resuming session") } + return claimActiveSessionOrContinuePolling(token, hydratedActive, settings) + } + return pollUntilReady(token, latest, settings) + } + + /** + * The host builds its virtual input devices from this when the session is created, and never + * revisits it. It must therefore agree with what [shouldUseNativeTouch] decides at stream time: + * a session created as GAMEPAD_FRIENDLY has no touchscreen, and will silently drop perfectly + * well-formed touch packets. + */ + private fun appLaunchModeFor(game: GameInfo?, settings: StreamSettings): Int = + if (resolveStreamInputModeAtLaunch(game, settings) == StreamInputMode.NativeTouch) { + GfnAppLaunchMode.TOUCH_FRIENDLY + } else { + GfnAppLaunchMode.GAMEPAD_FRIENDLY + } + + private fun resolveStreamInputModeAtLaunch(game: GameInfo?, settings: StreamSettings): StreamInputMode { + _state.value.streamInputModeAtLaunch?.let { return it } + val nativeTouchAvailable = !androidTvProfile && shouldUseNativeTouch( + _state.value.settings.androidTouch.effectiveNativeTouchMode(), + game, + settings, + ) + val mode = streamInputModeAtStart( + nativeTouchAvailable = nativeTouchAvailable, + keyboardMouseConnected = hasConnectedPhysicalKeyboardOrMouse(), + ) + _state.update { current -> current.copy(streamInputModeAtLaunch = mode) } + return mode + } + + private suspend fun claimActiveSessionOrContinuePolling( + token: String, + active: ActiveSessionInfo, + settings: StreamSettings, + recoveryMode: Boolean = false, + ): SessionInfo { + return try { + // Claiming re-sends the session request body, so repeating the mode the session was + // created with keeps it from being downgraded mid-flight. + sessionRepository.claimSession( + token = token, + active = active, + settings = settings, + appLaunchMode = appLaunchModeFor(_state.value.streamGame, settings), + recoveryMode = recoveryMode, + ) + } catch (error: SessionClaimNotReadyException) { + val fallback = active.toPendingSession(zone = "prod") + val latest = error.latestSession?.let { mergeQueueSessionState(fallback, it) } ?: fallback + if (isTerminalSessionStatus(latest.status)) { + throw TerminalSessionStatusException(latest.status, latest) + } + recordDebugEvent("queue", "Claim stayed pending; continuing queue polling ${latest.debugSummary()}") + _state.update { + it.copy( + streamSession = latest, + activeStreamSettings = settings, + launchPhase = loadingPhaseFor(latest), + queuePosition = queueDisplayPosition(latest), + queueAdActiveId = chooseQueueAdActiveId(it.queueAdActiveId, latest), + ) + } + pollUntilReady(token, latest, settings) + } + } + + private suspend fun pollUntilReady(token: String, created: SessionInfo, settings: StreamSettings): SessionInfo { + var latest = created + var pollCount = 0 + if (isTerminalSessionStatus(latest.status)) { + throw TerminalSessionStatusException(latest.status, latest) + } + recordDebugEvent("queue", "Begin polling ${latest.debugSummary()}") + _state.update { + it.copy( + streamSession = latest, + launchPhase = loadingPhaseFor(latest), + queuePosition = queueDisplayPosition(latest), + queueAdActiveId = chooseQueueAdActiveId(it.queueAdActiveId, latest), + ) + } + while (!latest.isReadyForStream()) { + val waitMs = if (shouldWaitForQueueAdPlayback(latest.adState)) 30_000L else 2_000L + if (waitMs > 2_000L) { + recordDebugEvent( + "queue", + "Waiting for queue ad playback session=${latest.shortDebugId()} ads=${sessionAdItems(latest.adState).size} paused=${latest.adState?.isQueuePaused} message=${latest.adState?.message.orEmpty()}", + ) + } + if (waitMs > 2_000L) { + var elapsedMs = 0L + while (elapsedMs < waitMs) { + kotlinx.coroutines.delay(500L) + elapsedMs += 500L + state.value.streamSession + ?.takeIf { it.sessionId == latest.sessionId } + ?.let { latest = mergeQueueSessionState(latest, it) } + if (!shouldWaitForQueueAdPlayback(latest.adState) || latest.isReadyForStream()) { + break + } + } + } else { + kotlinx.coroutines.delay(waitMs) + } + if (latest.isReadyForStream()) { + break + } + pollCount += 1 + val polled = try { + sessionRepository.pollSession( + token = token, + streamingBaseUrl = latest.streamingBaseUrl ?: effectiveStreamingBaseUrl(), + serverIp = latest.serverIp, + zone = latest.zone, + sessionId = latest.sessionId, + clientId = latest.clientId, + deviceId = latest.deviceId, + settings = settings, + ) + } catch (e: Exception) { + if (e is CancellationException) throw e + recordDebugEvent("queue", "Poll #$pollCount failed due to network error: ${e.message}. Retrying in 2 seconds...") + kotlinx.coroutines.delay(2_000L) + continue + } + latest = mergeQueueSessionState(latest, polled) + recordDebugEvent("queue", "Poll #$pollCount result ${latest.debugSummary()}") + if (isTerminalSessionStatus(latest.status)) { + recordDebugEvent("queue", "Polling stopped at terminal session status=${latest.status} ${latest.shortDebugId()}") + throw TerminalSessionStatusException(latest.status, latest) + } + _state.update { + it.copy( + streamSession = latest, + launchPhase = loadingPhaseFor(latest), + queuePosition = queueDisplayPosition(latest), + queueAdActiveId = chooseQueueAdActiveId(it.queueAdActiveId, latest), + ) + } + } + recordDebugEvent("queue", "Polling complete after $pollCount polls ${latest.debugSummary()}") + return latest + } + + private fun effectiveStreamingBaseUrl(sessionOverride: AuthSession? = null): String { + val settings = state.value.settings + val auth = sessionOverride ?: state.value.authSession + return settings.stream.region.trim().ifBlank { auth?.provider?.streamingServiceUrl ?: state.value.selectedProvider.streamingServiceUrl } + } + + private fun shouldUsePrintedWasteQueue(auth: AuthSession): Boolean { + if (state.value.settings.hideServerSelector) return false + if (!auth.provider.code.equals("NVIDIA", ignoreCase = true)) return false + if (!isFreeTier()) return false + return !isAllianceStreamingBaseUrl(effectiveStreamingBaseUrl(auth)) + } + + private fun isFreeTier(): Boolean { + val tier = state.value.subscriptionInfo?.membershipTier ?: state.value.authSession?.user?.membershipTier + return tier.isNullOrBlank() || tier.equals("FREE", ignoreCase = true) + } + + private fun isAllianceStreamingBaseUrl(streamingBaseUrl: String): Boolean { + val host = runCatching { Uri.parse(streamingBaseUrl).host.orEmpty() }.getOrDefault("") + return host.isNotBlank() && !host.endsWith(".nvidiagrid.net", ignoreCase = true) + } + + private fun chooseQueueAdActiveId(currentId: String?, session: SessionInfo?): String? { + val ads = sessionAdItems(session?.adState) + if (!isSessionAdsRequired(session?.adState) || ads.isEmpty()) return null + return ads.firstOrNull { it.adId == currentId }?.adId ?: ads.first().adId + } + + private fun loadingPhaseFor(session: SessionInfo): String = + when { + queueDisplayPosition(session) != null || session.seatSetupStep == 1 -> "Queue" + session.status == 0 || session.status == 1 -> "Checking queue" + else -> "Setting up rig" + } + + private fun ActiveSessionInfo.toPendingSession(zone: String): SessionInfo { + val host = serverIp.orEmpty() + val signalingServer = when { + host.isBlank() -> "" + host.contains(":") -> host + else -> "$host:443" + } + return SessionInfo( + sessionId = sessionId, + status = status, + queuePosition = queuePosition, + seatSetupStep = seatSetupStep, + zone = zone, + streamingBaseUrl = streamingBaseUrl, + serverIp = host, + signalingServer = signalingServer, + signalingUrl = signalingUrl ?: host.takeIf { it.isNotBlank() }?.let { "wss://$it:443/nvst/" }.orEmpty(), + gpuType = gpuType, + deviceId = authStore.stableDeviceId(), + ) + } + + private fun SessionInfo.toActiveRecoverySession( + fallbackActive: ActiveSessionInfo?, + settings: StreamSettings, + ): ActiveSessionInfo? { + val appId = fallbackActive?.takeIf { it.sessionId == sessionId }?.appId ?: fallbackActive?.appId ?: return null + return knownSessionRecoveryCandidate( + session = this, + appId = appId, + fallbackActive = fallbackActive, + settings = settings, + ) + } + + private fun shouldSendAccountLinked(game: GameInfo, variant: GameVariant?): Boolean { + return shouldLaunchWithAccountLinked(game, variant) + } + + private fun normalizeLaunchError(error: Throwable, gameTitle: String? = null): String = + normalizeLaunchErrorMessage(error, gameTitle) + + private fun AuthSession.toSavedAccount(): SavedAccount = + SavedAccount( + userId = user.userId, + displayName = user.displayName, + email = user.email, + avatarUrl = user.avatarUrl, + membershipTier = user.membershipTier, + providerCode = provider.code, + ) + + private fun AuthSession.withSubscriptionTier(subscription: SubscriptionInfo?): AuthSession { + val tier = subscription?.membershipTier?.takeIf { it.isNotBlank() } ?: return this + return if (user.membershipTier == tier) this else copy(user = user.copy(membershipTier = tier)) + } + + private fun persistSubscriptionTier(session: AuthSession, subscription: SubscriptionInfo?): AuthSession { + val enriched = session.withSubscriptionTier(subscription) + if (enriched != session) authStore.upsertSession(enriched) + return enriched + } + + private fun savedAccountsSnapshot(): List = + authStore.state.value.sessions.map { session -> session.toSavedAccount() } +} + +internal fun externalLaunchIdFromParts( + extras: List, + scheme: String?, + host: String?, + pathSegments: List, + schemeSpecificPart: String?, + queryParameters: Map, +): String? { + val normalizedScheme = scheme.orEmpty().lowercase(Locale.US) + val uriCandidates = if (normalizedScheme == "opennow") { + buildList { + add(queryParameters["id"]) + add(queryParameters["appId"]) + add(queryParameters["launchAppId"]) + val routeHost = host.orEmpty() + if (routeHost.equals("launch", ignoreCase = true)) { + add(pathSegments.firstOrNull()) + } else if (routeHost.isNotBlank()) { + add(routeHost) + } + if (pathSegments.firstOrNull()?.equals("launch", ignoreCase = true) == true) { + add(pathSegments.getOrNull(1)) + } + add(pathSegments.lastOrNull()) + add(schemeSpecificPart) + } + } else { + emptyList() + } + return (extras + uriCandidates).firstNotNullOfOrNull { it.normalizedExternalLaunchId() } +} + +private fun String?.normalizedExternalLaunchId(): String? { + val trimmed = this?.trim()?.trim('/', '?', '#') ?: return null + if (trimmed.isBlank() || trimmed.equals("launch", ignoreCase = true)) return null + val cleaned = trimmed + .removePrefix("//") + .substringBefore('#') + .substringBefore('?') + .trim('/') + if (cleaned.isBlank() || cleaned.equals("launch", ignoreCase = true)) return null + return cleaned.split('/').lastOrNull { it.isNotBlank() && !it.equals("launch", ignoreCase = true) } +} + +private fun shortDebugId(value: String?): String { + val text = value.orEmpty() + if (text.length <= 12) return text + return "${text.take(6)}...${text.takeLast(4)}" +} + +private fun hostForDebug(url: String?): String = + runCatching { Uri.parse(url.orEmpty()).host.orEmpty() } + .getOrDefault("") + .ifBlank { url.orEmpty().take(80) } + +private fun Throwable.debugMessage(): String { + val type = javaClass.simpleName.ifBlank { "Throwable" } + val text = message.orEmpty() + .lineSequence() + .joinToString(" ") { it.trim() } + .take(DEBUG_EVENT_MESSAGE_LIMIT) + return if (text.isBlank()) type else "$type: $text" +} + +private fun StreamSettings.debugSummary(): String = + "res=$resolution aspect=$aspectRatio fps=$fps bitrate=$maxBitrateMbps codec=$codec color=${colorQuality.name} hdr=$hdrEnabled l4s=$enableL4S sharp=$streamSharpeningEnabled" + +private fun StreamRuntimeStats.hasDebugValues(): Boolean = + bitrateKbps != null || + availableIncomingBitrateKbps != null || + pingMs != null || + fps != null || + receivedFps != null || + decodedFps != null || + processCpuPercent != null || + !resolution.isNullOrBlank() || + !codec.isNullOrBlank() + +private fun StreamRuntimeStats.debugSummary(): String = + "bitrateKbps=${bitrateKbps ?: 0} availableIncomingBitrateKbps=${availableIncomingBitrateKbps ?: -1} " + + "pingMs=${pingMs ?: -1} fps=${fps ?: 0} receivedFps=${receivedFps ?: 0} decodedFps=${decodedFps ?: 0} " + + "decodeMs=${decodeMs ?: -1.0} jitterMs=${jitterMs ?: -1.0} packetLossPct=${packetLossPct ?: -1.0} " + + "packetsLostDelta=${packetsLostDelta ?: -1} packetsReceivedDelta=${packetsReceivedDelta ?: -1} " + + "processCpuPct=${processCpuPercent ?: -1.0} deviceCpuCapacityPct=${deviceCpuCapacityPercent ?: -1.0} cpuCores=${cpuLogicalCoreCount ?: 0} " + + "resolution=${resolution.orEmpty()} codec=${codec.orEmpty()}" + +private fun TimedStreamRuntimeStats.debugSummary(nowMs: Long): String { + val formatter = DateFormat.getTimeInstance(DateFormat.MEDIUM, Locale.US) + val ageMs = (nowMs - capturedAtMs).coerceAtLeast(0L) + return "capturedAt=${formatter.format(Date(capturedAtMs))} ageMs=$ageMs session=${shortDebugId(sessionId)} ${stats.debugSummary()}" +} + +private fun SessionInfo.shortDebugId(): String = shortDebugId(sessionId) + +private fun SessionInfo.debugSummary(): String = + "id=${shortDebugId()} status=$status ready=${isReadyForStream()} queue=${queuePosition ?: "-"} seat=${seatSetupStep ?: "-"} adsRequired=${isSessionAdsRequired(adState)} ads=${sessionAdItems(adState).size} paused=${adState?.isQueuePaused} base=${hostForDebug(streamingBaseUrl)} server=${serverIp.take(80)}" + +private fun ActiveSessionInfo.shortDebugId(): String = shortDebugId(sessionId) + +private fun ActiveSessionInfo.debugSummary(): String = + "id=${shortDebugId()} app=$appId status=$status queue=${queuePosition ?: "-"} seat=${seatSetupStep ?: "-"} base=${hostForDebug(streamingBaseUrl)} server=${serverIp.orEmpty().take(80)} res=${resolution.orEmpty()} fps=${fps ?: 0}" + +private fun NegotiatedStreamProfile.debugSummary(): String = + "res=${resolution.orEmpty()} fps=${fps ?: 0} codec=${codec?.name.orEmpty()} color=${colorQuality?.name.orEmpty()} l4s=$enableL4S reflex=$enableReflex" + +private fun SessionMonitorSnapshot.debugSummary(): String = + "requested=${requestedResolution.orEmpty()}@${requestedFps ?: 0} " + + "returned=${returnedResolution.orEmpty()}@${returnedFps ?: 0} final=${finalSelectedResolution.orEmpty()}" + +private fun StreamingFeatures.debugSummary(): String = + "reflex=$reflex bitDepth=$bitDepth chroma=$chromaFormat l4s=$enabledL4S hdr=$trueHdr" diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/Persistence.kt b/android/app/src/main/java/com/opencloudgaming/opennow/Persistence.kt new file mode 100644 index 000000000..ec84e0bd2 --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/Persistence.kt @@ -0,0 +1,807 @@ +package com.opencloudgaming.opennow + +import android.content.Context +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.encodeToString +import kotlinx.serialization.serializer +import java.security.MessageDigest +import java.util.UUID +import android.util.Xml +import org.xmlpull.v1.XmlPullParser +import java.io.File +import java.io.FileInputStream +import java.util.zip.GZIPInputStream +import java.util.zip.GZIPOutputStream +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.launch + +private const val STORE_NAME = "opennow_native" +private const val CATALOG_CACHE_STORE_NAME = "opennow_catalog_cache" +private const val SECURE_STORE_NAME = "opennow_auth_secure" +private const val KEY_SETTINGS = "settings" +private const val KEY_AUTH = "auth" +private const val KEY_DEVICE_ID = "gfn_device_id" +private const val KEY_CATALOG_CACHE_PREFIX = "catalog_cache_" +private const val KEY_ANDROID_UPDATE_DISMISSED_NOTICE = "android_update_dismissed_notice" +private const val KEY_QUEUED_GAME_KEYS = "queued_game_keys" +private const val CATALOG_CACHE_TTL_MS = 12L * 60L * 60L * 1000L + +/** + * Largest compressed catalogue entry worth retaining on disk. + * + * This is both a storage bound and a decompression-memory guard. See `CatalogCacheStore.save`. + */ +private const val MAX_COMPRESSED_CATALOG_CACHE_BYTES = 768 * 1024 +private const val CATALOG_CACHE_DIRECTORY_NAME = "catalog-cache-v2" + +/** + * Games kept in a cached list or browse result. + * + * A cache entry exists to put something on screen instantly at launch, not to mirror the whole + * catalogue — the live fetch replaces it within seconds regardless. Bounding it up front means the + * oversized case never reaches the encoder at all, rather than being detected by + * [MAX_COMPRESSED_CATALOG_CACHE_BYTES] after compression. Android TV already + * bounded what it restored (`TV_INITIAL_CATALOG_GAME_LIMIT`); this applies the same idea to what + * gets written, on every profile. + */ +private const val MAX_CACHED_CATALOG_GAMES = 400 +private const val QUEUED_GAME_LIMIT = 24 +// Keys that must never be written to the external (potentially world-readable) file. +private val SENSITIVE_KEYS = setOf(KEY_AUTH, KEY_DEVICE_ID) +private val AUTH_STORE_LOCK = Any() + +class ExternalPrefs private constructor(context: Context, val name: String) { + private val primaryFile: File + private val fallbackFile: File + private val data = mutableMapOf() + private val lock = Any() + // Single-thread dispatcher: apply() writes are serialized in submission order + // (last-write-wins) without blocking the caller. + private val writeScope = CoroutineScope(Dispatchers.IO.limitedParallelism(1)) + + init { + val extDir = runCatching { context.getExternalFilesDir(null) }.getOrNull() + primaryFile = File(extDir ?: context.filesDir, "$name.xml") + fallbackFile = File(context.filesDir, "$name.xml") + synchronized(lock) { + migrateFromInternal(context, name) + load() + } + } + + companion object { + private val instances = mutableMapOf() + private val globalLock = Any() + + fun get(context: Context, name: String): ExternalPrefs { + return synchronized(globalLock) { + instances.getOrPut(name) { + ExternalPrefs(context.applicationContext, name) + } + } + } + } + + private fun migrateFromInternal(context: Context, name: String) { + if (primaryFile.exists() || fallbackFile.exists()) return + val internalPrefs = context.applicationContext.getSharedPreferences(name, Context.MODE_PRIVATE) + val allInternal = internalPrefs.all + if (allInternal.isNotEmpty()) { + // Sensitive keys must not land in the external (world-readable) file. + // Migrate them directly into the secure internal store instead, + // skipping any key that is already present there. + val securePrefs = context.applicationContext + .getSharedPreferences(SECURE_STORE_NAME, Context.MODE_PRIVATE) + val secureEdit = securePrefs.edit() + var hasSensitive = false + allInternal.forEach { (k, v) -> + if (v is String && k in SENSITIVE_KEYS && !securePrefs.contains(k)) { + secureEdit.putString(k, v) + hasSensitive = true + } + } + if (hasSensitive) secureEdit.commit() + + // Migrate non-sensitive keys to the external file + allInternal.forEach { (k, v) -> + if (v is String && k !in SENSITIVE_KEYS) data[k] = v + } + val primarySuccess = writeToFile(primaryFile, data.toMap()) + val fallbackSuccess = if (!primarySuccess) writeToFile(fallbackFile, data.toMap()) else true + if (primarySuccess || fallbackSuccess) { + internalPrefs.edit().clear().commit() + } + } + } + + private fun load() { + val hasPrimary = primaryFile.exists() + val hasFallback = fallbackFile.exists() + val targetFile = when { + hasPrimary && hasFallback -> { + if (primaryFile.lastModified() >= fallbackFile.lastModified()) { + primaryFile + } else { + fallbackFile + } + } + hasPrimary -> primaryFile + hasFallback -> fallbackFile + else -> return + } + // Snapshot existing data so we can restore it if parsing fails mid-way + val existing = data.toMap() + runCatching { + val parser = Xml.newPullParser() + FileInputStream(targetFile).use { fis -> + parser.setInput(fis, "UTF-8") + var event = parser.eventType + while (event != XmlPullParser.END_DOCUMENT) { + if (event == XmlPullParser.START_TAG && parser.name == "string") { + val name = parser.getAttributeValue(null, "name") + val value = parser.nextText() + if (name != null) { + data[name] = value + } + } + event = parser.next() + } + } + }.onFailure { + it.printStackTrace() + // Restore pre-parse snapshot to avoid leaving data in a partial state + data.clear() + data.putAll(existing) + val alternativeFile = if (targetFile == primaryFile) fallbackFile else primaryFile + if (alternativeFile.exists()) { + val existingBeforeAlt = data.toMap() + runCatching { + val parser = Xml.newPullParser() + FileInputStream(alternativeFile).use { fis -> + parser.setInput(fis, "UTF-8") + var event = parser.eventType + while (event != XmlPullParser.END_DOCUMENT) { + if (event == XmlPullParser.START_TAG && parser.name == "string") { + val name = parser.getAttributeValue(null, "name") + val value = parser.nextText() + if (name != null) { + data[name] = value + } + } + event = parser.next() + } + } + }.onFailure { e -> + e.printStackTrace() + // Restore again if the alternative file also failed mid-parse + data.clear() + data.putAll(existingBeforeAlt) + } + } + } + } + + private fun save(mapSnapshot: Map) { + synchronized(lock) { + val success = writeToFile(primaryFile, mapSnapshot) + if (!success) { + writeToFile(fallbackFile, mapSnapshot) + } + } + } + + private fun writeToFile(file: File, mapSnapshot: Map): Boolean { + return runCatching { + val parent = file.parentFile ?: return false + parent.mkdirs() + val tmpFile = File(parent, "${file.name}.tmp") + tmpFile.bufferedWriter().use { writer -> + writer.write("\n") + writer.write("\n") + for ((k, v) in mapSnapshot) { + writer.write(" ") + writer.write(escapeXmlText(v)) + writer.write("\n") + } + writer.write("\n") + } + if (tmpFile.exists()) { + if (tmpFile.renameTo(file)) { + true + } else { + tmpFile.copyTo(file, overwrite = true) + tmpFile.delete() + true + } + } else { + false + } + }.getOrElse { + it.printStackTrace() + false + } + } + + private fun escapeXmlAttribute(str: String): String = + str.replace("&", "&").replace("<", "<").replace(">", ">") + .replace("\"", """).replace("'", "'") + + private fun escapeXmlText(str: String): String = + str.replace("&", "&").replace("<", "<").replace(">", ">") + + fun getString(key: String, defValue: String?): String? = synchronized(lock) { data[key] ?: defValue } + + val all: Map get() = synchronized(lock) { data.toMap() } + + fun edit(): Editor = Editor() + + inner class Editor { + private val actions = mutableListOf<() -> Unit>() + + fun putString(key: String, value: String?): Editor { + actions.add { + if (value == null) data.remove(key) else data[key] = value + } + return this + } + + fun remove(key: String): Editor { + actions.add { data.remove(key) } + return this + } + + // Captures snapshot synchronously under the lock then dispatches the write + // on a single-thread background scope, so: + // - The caller is never blocked by IO + // - Writes are still dispatched in submission order (last-write-wins guaranteed) + fun apply() { + val snapshot = synchronized(lock) { + actions.forEach { it() } + actions.clear() + data.toMap() + } + writeScope.launch { save(snapshot) } + } + + // Single synchronized block keeps action application, snapshot capture, and file + // write atomic — eliminating the interleaving window where a concurrent commit() + // could write a newer snapshot between our two previously-separate lock sections. + fun commit(): Boolean = synchronized(lock) { + actions.forEach { it() } + actions.clear() + val snapshot = data.toMap() + val success = writeToFile(primaryFile, snapshot) + if (!success) writeToFile(fallbackFile, snapshot) else true + } + } +} + +private fun Float.finiteIn(minimum: Float, maximum: Float, fallback: Float): Float = + if (isFinite()) coerceIn(minimum, maximum) else fallback + +internal fun AppSettings.normalizedForAndroid(): AppSettings { + val streamDefaults = StreamSettings() + val touchDefaults = AndroidTouchSettings() + val compatibleStream = stream.withAndroidSettingsAvailability() + val lowPowerSafe = compatibleStream.copy( + codec = compatibleStream.codec, + sessionProxyUrl = stream.sessionProxyUrl.trim(), + maxBitrateMbps = compatibleStream.maxBitrateMbps.coerceIn(1, 150), + fps = compatibleStream.fps.coerceIn(30, 360), + mouseSensitivity = compatibleStream.mouseSensitivity.finiteIn(0.25f, 3f, streamDefaults.mouseSensitivity), + mouseAcceleration = compatibleStream.mouseAcceleration.coerceIn(1, 150), + mouseScrollSensitivity = compatibleStream.mouseScrollSensitivity.coerceIn(10, 100), + streamSharpeningAmount = compatibleStream.streamSharpeningAmount.finiteIn( + 0f, + 1f, + streamDefaults.streamSharpeningAmount, + ), + ) + val normalizedCatalogSortId = catalogSortId.trim().ifBlank { DEFAULT_CATALOG_SORT_ID } + val migratedCatalogSortId = if ( + catalogSortDefaultVersion < CATALOG_SORT_DEFAULT_VERSION && + normalizedCatalogSortId == "relevance" + ) { + DEFAULT_CATALOG_SORT_ID + } else { + normalizedCatalogSortId + } + return copy( + uiAccent = if (uiAccent == UiAccent.LegacyOrange) UiAccent.Violet else uiAccent, + stream = lowPowerSafe, + posterSizeScale = posterSizeScale.finiteIn(MIN_GAME_CARD_SCALE, MAX_GAME_CARD_SCALE, 1f), + streamKeyboardButtonPosition = streamKeyboardButtonPosition.normalized(), + androidTouch = androidTouch.copy( + touchSkinTint = androidTouch.touchSkinTint.withoutRemovedWarmTint(), + opacity = androidTouch.opacity.finiteIn(0f, 1f, touchDefaults.opacity), + scale = androidTouch.scale.finiteIn(0.6f, 1.4f, touchDefaults.scale), + buttonScale = androidTouch.buttonScale.finiteIn(0.65f, 1.5f, touchDefaults.buttonScale), + stickScale = androidTouch.stickScale.finiteIn(0.65f, 1.5f, touchDefaults.stickScale), + faceButtonScale = androidTouch.faceButtonScale.finiteIn(0.6f, 1.5f, touchDefaults.faceButtonScale), + dpadScale = androidTouch.dpadScale.finiteIn(0.6f, 1.5f, touchDefaults.dpadScale), + shoulderButtonScale = androidTouch.shoulderButtonScale.finiteIn(0.6f, 1.5f, touchDefaults.shoulderButtonScale), + centerButtonScale = androidTouch.centerButtonScale.finiteIn(0.6f, 1.5f, touchDefaults.centerButtonScale), + leftStickScale = androidTouch.leftStickScale.finiteIn(0.6f, 1.5f, touchDefaults.leftStickScale), + rightStickScale = androidTouch.rightStickScale.finiteIn(0.6f, 1.5f, touchDefaults.rightStickScale), + stickKnobScale = androidTouch.stickKnobScale.finiteIn(0.28f, 0.72f, touchDefaults.stickKnobScale), + extraButtonActions = List(TOUCH_EXTRA_BUTTON_COUNT) { index -> + androidTouch.extraButtonAction(index) + }, + extraButtonScale = androidTouch.extraButtonScale.finiteIn(0.6f, 1.6f, touchDefaults.extraButtonScale), + aimZoneScale = androidTouch.aimZoneScale.finiteIn(0.5f, 1.5f, touchDefaults.aimZoneScale), + aimZoneSensitivity = androidTouch.aimZoneSensitivity.finiteIn( + 0.25f, + 3f, + touchDefaults.aimZoneSensitivity, + ), + joystickDeadZone = androidTouch.joystickDeadZone.finiteIn(0f, 0.3f, touchDefaults.joystickDeadZone), + gyroscopeSensitivity = androidTouch.gyroscopeSensitivity.finiteIn(0.25f, 3f, touchDefaults.gyroscopeSensitivity), + gyroscopeDeadZone = androidTouch.gyroscopeDeadZone.finiteIn(0f, 0.2f, touchDefaults.gyroscopeDeadZone), + gyroscopeSmoothing = androidTouch.gyroscopeSmoothing.finiteIn(0f, 0.9f, touchDefaults.gyroscopeSmoothing), + edgePaddingDp = androidTouch.edgePaddingDp.finiteIn(0f, 72f, touchDefaults.edgePaddingDp), + bottomPaddingDp = androidTouch.bottomPaddingDp.finiteIn(0f, 120f, touchDefaults.bottomPaddingDp), + leftOffsetXDp = androidTouch.leftOffsetXDp.finiteIn(-220f, 220f, touchDefaults.leftOffsetXDp), + leftOffsetYDp = androidTouch.leftOffsetYDp.finiteIn(-160f, 160f, touchDefaults.leftOffsetYDp), + rightOffsetXDp = androidTouch.rightOffsetXDp.finiteIn(-220f, 220f, touchDefaults.rightOffsetXDp), + rightOffsetYDp = androidTouch.rightOffsetYDp.finiteIn(-160f, 160f, touchDefaults.rightOffsetYDp), + nativeTouchScrollScale = androidTouch.nativeTouchScrollScale.finiteIn( + 0.25f, + 2f, + touchDefaults.nativeTouchScrollScale, + ), + nativeTouchJitterThresholdDp = androidTouch.nativeTouchJitterThresholdDp.finiteIn( + 0f, + 24f, + touchDefaults.nativeTouchJitterThresholdDp, + ), + offsets = androidTouch.offsets.mapValues { (_, offset) -> + TouchOffset( + x = offset.x.finiteIn(-320f, 320f, 0f), + y = offset.y.finiteIn(-320f, 320f, 0f), + ) + }, + ), + streamIntroMusic = streamIntroMusic, + queueReadyMusic = queueReadyMusic, + legacyCropStreamToFill = false, + stretchStreamToFit = stretchStreamToFit, + streamPresentationProfileVersion = streamPresentationProfileVersion.coerceAtLeast(STREAM_PRESENTATION_PROFILE_VERSION), + showSessionReportAfterStream = + if (sessionReportDefaultVersion < SESSION_REPORT_DEFAULT_VERSION) false + else showSessionReportAfterStream, + sessionReportDefaultVersion = SESSION_REPORT_DEFAULT_VERSION, + nerdCatalogBackgroundUri = nerdCatalogBackgroundUri?.trim()?.takeIf { it.isNotBlank() }, + localAppPackageNames = normalizeLocalAppPackageNames(localAppPackageNames), + absoluteCinemaEverywhere = absoluteCinemaEffects && absoluteCinemaEverywhere, + catalogSortId = migratedCatalogSortId, + catalogSortDefaultVersion = CATALOG_SORT_DEFAULT_VERSION, + catalogFilterIds = catalogFilterIds.map(String::trim).filter(String::isNotBlank).distinct(), + librarySortId = librarySortId.takeIf { + it in setOf(LIBRARY_SORT_DEFAULT, LIBRARY_SORT_RECENT, LIBRARY_SORT_TITLE) + } ?: LIBRARY_SORT_DEFAULT, + libraryFilterIds = libraryFilterIds.map(String::trim).filter(String::isNotBlank).distinct(), + tvSafeAreaPaddingDp = tvSafeAreaPaddingDp.finiteIn(0f, 120f, 16f), + tvLayoutProfileVersion = tvLayoutProfileVersion.coerceAtLeast(0), + controllerUiSounds = controllerUiSounds, + autoFullScreen = true, + ) +} + +class SettingsStore(context: Context) { + private val prefs = ExternalPrefs.get(context, STORE_NAME) + private val _settings = MutableStateFlow( + load() + .withCurrentStreamPresentationDefaults() + .normalizedForAndroid(), + ) + val settings: StateFlow = _settings + + /** + * Serializing [AppSettings] used to happen on whichever thread called [update] — in practice the + * main thread, on every favourite tap, every toggle and every frame of a slider drag. The object + * carries the full favourite list, the local-app list, the per-game variant map and the touch + * layout offsets, so that encode is not cheap. + * + * The store is last-write-wins, so persistence conflates: [collectLatest] cancels an in-flight + * encode the moment a newer value arrives, and a drag that produces fifty values writes once. + * Reads stay synchronous off [_settings], so nothing observable is deferred — only the disk. + */ + private val persistScope = CoroutineScope(Dispatchers.IO.limitedParallelism(1)) + + init { + persistScope.launch { + // drop(1): the initial value came off disk; rewriting it verbatim on every launch would + // burn a startup write for nothing. + _settings.drop(1).collectLatest { snapshot -> + runCatching { + prefs.edit().putString(KEY_SETTINGS, OpenNowJson.encodeToString(snapshot)).apply() + } + } + } + } + + private fun load(): AppSettings { + val raw = prefs.getString(KEY_SETTINGS, null) ?: return AppSettings() + return runCatching { OpenNowJson.decodeFromString(raw) }.getOrElse { AppSettings() } + } + + fun update(transform: (AppSettings) -> AppSettings) { + _settings.value = transform(_settings.value) + .withCurrentStreamPresentationDefaults() + .normalizedForAndroid() + } + + fun replace(next: AppSettings) { + _settings.value = next + .withCurrentStreamPresentationDefaults() + .normalizedForAndroid() + } + + fun reset() { + replace(AppSettings()) + } +} + +class AuthStore(context: Context) { + private val sharedPrefs = context.applicationContext.getSharedPreferences(SECURE_STORE_NAME, Context.MODE_PRIVATE) + private val _state = MutableStateFlow(loadAndMigrate(context)) + val state: StateFlow = _state + + private fun loadAndMigrate(context: Context): PersistedAuthState { + val legacyPrefs = ExternalPrefs.get(context, STORE_NAME) + + // Migrate auth credentials if not yet in secure storage + val hasSecureAuth = sharedPrefs.contains(KEY_AUTH) + var migratedState: PersistedAuthState? = null + if (!hasSecureAuth) { + val legacyRaw = legacyPrefs.getString(KEY_AUTH, null) + if (!legacyRaw.isNullOrBlank()) { + val parsed = runCatching { OpenNowJson.decodeFromString(legacyRaw) }.getOrNull() + if (parsed != null) { + val secureCommitSuccess = sharedPrefs.edit().putString(KEY_AUTH, legacyRaw).commit() + if (secureCommitSuccess) { + migratedState = parsed + legacyPrefs.edit().remove(KEY_AUTH).commit() + } + } + } + } + + // Migrate device ID independently — always run even if auth was already migrated, + // since hasSecureAuth being true does not guarantee KEY_DEVICE_ID is in secure storage. + if (!sharedPrefs.contains(KEY_DEVICE_ID)) { + val legacyDeviceId = legacyPrefs.getString(KEY_DEVICE_ID, null) + if (!legacyDeviceId.isNullOrBlank()) { + val secureCommitSuccess = sharedPrefs.edit().putString(KEY_DEVICE_ID, legacyDeviceId).commit() + if (secureCommitSuccess) { + legacyPrefs.edit().remove(KEY_DEVICE_ID).commit() + } + } + } + + if (migratedState != null) { + return migratedState + } + return load() + } + + private fun load(): PersistedAuthState { + val raw = sharedPrefs.getString(KEY_AUTH, null) ?: return PersistedAuthState() + return runCatching { OpenNowJson.decodeFromString(raw) }.getOrElse { PersistedAuthState() } + } + + fun reload(): PersistedAuthState = synchronized(AUTH_STORE_LOCK) { + load().also { latest -> _state.value = latest } + } + + fun save(next: PersistedAuthState) = synchronized(AUTH_STORE_LOCK) { + sharedPrefs.edit().putString(KEY_AUTH, OpenNowJson.encodeToString(next)).commit() + _state.value = next + } + + fun activeSession(): AuthSession? = synchronized(AUTH_STORE_LOCK) { + val state = _state.value + state.sessions.firstOrNull { it.user.userId == state.activeUserId } ?: state.sessions.firstOrNull() + } + + fun setActiveSession(userId: String) = synchronized(AUTH_STORE_LOCK) { + val current = _state.value + val session = current.sessions.firstOrNull { it.user.userId == userId } ?: return@synchronized + save(current.copy(activeUserId = session.user.userId, selectedProvider = session.provider)) + } + + fun upsertSession(session: AuthSession) = synchronized(AUTH_STORE_LOCK) { + val current = _state.value + val sessions = buildList { + add(session) + addAll(current.sessions.filterNot { it.user.userId == session.user.userId }) + } + save( + current.copy( + sessions = sessions, + activeUserId = session.user.userId, + selectedProvider = session.provider, + ), + ) + } + + fun updateSessionIfUnchanged(expected: AuthSession, updated: AuthSession): Boolean = synchronized(AUTH_STORE_LOCK) { + val current = _state.value + val existing = current.sessions.firstOrNull { it.user.userId == expected.user.userId } + if (existing != expected) return@synchronized false + val sessions = current.sessions.map { session -> + if (session.user.userId == expected.user.userId) updated else session + } + save(current.copy(sessions = sessions)) + true + } + + fun removeSession(userId: String) = synchronized(AUTH_STORE_LOCK) { + val current = _state.value + val sessions = current.sessions.filterNot { it.user.userId == userId } + save(current.copy(sessions = sessions, activeUserId = sessions.firstOrNull()?.user?.userId)) + } + + fun clear() = synchronized(AUTH_STORE_LOCK) { + save(PersistedAuthState()) + } + + fun stableDeviceId(): String { + val existing = sharedPrefs.getString(KEY_DEVICE_ID, null) + if (!existing.isNullOrBlank()) return existing + val next = UUID.randomUUID().toString() + sharedPrefs.edit().putString(KEY_DEVICE_ID, next).commit() + return next + } +} + +private fun List.boundedForCache(): List = + if (size <= MAX_CACHED_CATALOG_GAMES) this else take(MAX_CACHED_CATALOG_GAMES) + +/** + * The on-disk shape of a cache entry. Named rather than hand-built so reads and writes cannot + * drift, and so decoding never has to go through an intermediate `JsonElement` tree. + */ +@kotlinx.serialization.Serializable +private data class CachedCatalogEntry(val expiresAt: Long, val data: T) + +private fun clearLegacyCatalogCache(context: Context) { + // Older builds stored all catalogue entries in XML maps. Besides dropping oversized entries, + // changing one result rewrote the entire cache file. Remove those disposable formats once. + val legacyPrefs = ExternalPrefs.get(context, STORE_NAME) + val legacyKeys = legacyPrefs.all.keys.filter { it.startsWith(KEY_CATALOG_CACHE_PREFIX) } + if (legacyKeys.isNotEmpty()) { + legacyPrefs.edit().apply { + legacyKeys.forEach(::remove) + }.apply() + } + listOfNotNull( + context.getExternalFilesDir(null)?.let { File(it, "$CATALOG_CACHE_STORE_NAME.xml") }, + File(context.filesDir, "$CATALOG_CACHE_STORE_NAME.xml"), + ).distinct().forEach(File::delete) +} + +class CatalogCacheStore private constructor( + private val cacheDirectory: File, + clearLegacyCache: () -> Unit, +) { + constructor(context: Context) : this( + cacheDirectory = File(context.applicationContext.cacheDir, CATALOG_CACHE_DIRECTORY_NAME), + clearLegacyCache = { clearLegacyCatalogCache(context.applicationContext) }, + ) + + internal constructor(cacheDirectory: File) : this(cacheDirectory, {}) + + init { + clearLegacyCache() + } + + fun loadMainGames(userId: String, providerStreamingBaseUrl: String): List? = + loadGameList(key("main", userId, providerStreamingBaseUrl)) + + fun saveMainGames(userId: String, providerStreamingBaseUrl: String, games: List) { + saveGameList(key("main", userId, providerStreamingBaseUrl), games) + } + + fun loadLibraryGames(userId: String, providerStreamingBaseUrl: String): List? = + loadGameList(key("library", userId, providerStreamingBaseUrl)) + + fun saveLibraryGames(userId: String, providerStreamingBaseUrl: String, games: List) { + saveGameList(key("library", userId, providerStreamingBaseUrl), games) + } + + fun loadCatalog( + userId: String, + providerStreamingBaseUrl: String, + searchQuery: String, + sortId: String, + filterIds: List, + ): CatalogBrowseResult? = + load(key("catalog", userId, providerStreamingBaseUrl, searchQuery, sortId, filterIds.sorted().joinToString(","))) + + fun saveCatalog( + userId: String, + providerStreamingBaseUrl: String, + searchQuery: String, + sortId: String, + filterIds: List, + result: CatalogBrowseResult, + ) { + save( + key("catalog", userId, providerStreamingBaseUrl, searchQuery, sortId, filterIds.sorted().joinToString(",")), + result.copy(games = result.games.boundedForCache()), + ) + } + + @Synchronized + fun clear(): Int { + val files = cacheDirectory.listFiles()?.filter { it.isFile }.orEmpty() + files.forEach(File::delete) + return files.size + } + + private fun loadGameList(key: String): List? = + load(key, ListSerializer(GameInfo.serializer())) + + private fun saveGameList(key: String, games: List) { + save(key, games.boundedForCache(), ListSerializer(GameInfo.serializer())) + } + + private inline fun load(key: String): T? = + load(key, OpenNowJson.serializersModule.serializer()) + + /** + * Streams straight from the stored string into [T]. + * + * The previous version parsed the whole entry into a `JsonElement` tree first and only then + * decoded it, so reading a large catalogue held the string, the tree, and the result at once. + */ + @Synchronized + private fun load(key: String, serializer: kotlinx.serialization.KSerializer): T? { + val file = cacheFile(key) + recoverInterruptedReplacement(file) + if (!file.isFile || file.length() <= 0L) return null + return runCatching { + val raw = GZIPInputStream(file.inputStream().buffered()).bufferedReader(Charsets.UTF_8).use { reader -> + reader.readText() + } + val entry = OpenNowJson.decodeFromString(CachedCatalogEntry.serializer(serializer), raw) + if (System.currentTimeMillis() > entry.expiresAt) { + file.delete() + null + } else { + entry.data + } + }.getOrElse { + file.delete() + null + } + } + + private inline fun save(key: String, data: T) { + save(key, data, OpenNowJson.serializersModule.serializer()) + } + + /** + * Writes an entry to its own compressed file, or drops it when the compressed result is too big. + * + * Two things changed here, both about peak memory rather than disk. Encoding goes straight to a + * string instead of building a `JsonElement` tree and then calling `toString()` on it, which + * used to mean three copies of a multi-megabyte catalogue alive at the same time. And a result + * over [MAX_COMPRESSED_CATALOG_CACHE_BYTES] is now simply not cached. + * + * The size guard is what makes the touch-controls filter safe. `catalogPageLimit` lifts mobile + * from three pages to [MAX_CATALOG_REQUEST_PAGES] when that filter is on, because the + * capability is evaluated locally and every page has to be inspected — so applying it produced + * by far the largest payload the app ever writes, and writing it exhausted the heap on + * low-memory devices. That is why the crash looked like it belonged to filtering. Skipping the + * cache costs one refetch on the next launch; the alternative was an OutOfMemoryError. + * + * The stale entry is removed rather than left in place so a smaller, older result cannot go on + * being served for a query that now returns much more. + */ + @Synchronized + private fun save(key: String, data: T, serializer: kotlinx.serialization.KSerializer) { + cacheDirectory.mkdirs() + val target = cacheFile(key) + recoverInterruptedReplacement(target) + val staged = File(cacheDirectory, "${target.name}.stage") + staged.delete() + val entry = CachedCatalogEntry(System.currentTimeMillis() + CATALOG_CACHE_TTL_MS, data) + val payload = runCatching { + OpenNowJson.encodeToString(CachedCatalogEntry.serializer(serializer), entry) + }.getOrNull() + if (payload == null) { + target.delete() + return + } + val wrote = runCatching { + GZIPOutputStream(staged.outputStream().buffered()).bufferedWriter(Charsets.UTF_8).use { writer -> + writer.write(payload) + } + }.isSuccess + if (!wrote || staged.length() > MAX_COMPRESSED_CATALOG_CACHE_BYTES) { + staged.delete() + target.delete() + return + } + replaceCacheFile(staged, target) + } + + private fun key(vararg parts: String): String = + parts.joinToString("|") { it.trim() } + + private fun cacheFile(key: String): File = File(cacheDirectory, "${storageKey(key)}.json.gz") + + private fun storageKey(key: String): String { + val digest = MessageDigest.getInstance("SHA-256").digest(key.toByteArray()) + return KEY_CATALOG_CACHE_PREFIX + digest.joinToString("") { "%02x".format(it) } + } + + private fun recoverInterruptedReplacement(target: File) { + val backup = File(cacheDirectory, "${target.name}.backup") + if (!target.exists() && backup.isFile) { + backup.renameTo(target) + } else if (target.exists()) { + backup.delete() + } + File(cacheDirectory, "${target.name}.stage").takeIf { it.isFile }?.delete() + } + + private fun replaceCacheFile(staged: File, target: File) { + val backup = File(cacheDirectory, "${target.name}.backup") + backup.delete() + val hadTarget = target.isFile + if (hadTarget && !target.renameTo(backup)) { + staged.delete() + return + } + if (!staged.renameTo(target)) { + if (hadTarget) backup.renameTo(target) + staged.delete() + return + } + backup.delete() + } +} + +class QueuedGameStore(context: Context) { + private val prefs = ExternalPrefs.get(context, STORE_NAME) + + fun load(): List { + val raw = prefs.getString(KEY_QUEUED_GAME_KEYS, null) ?: return emptyList() + return runCatching { OpenNowJson.decodeFromString>(raw) } + .getOrElse { emptyList() } + .map { it.trim() } + .filter { it.isNotBlank() } + .distinct() + .take(QUEUED_GAME_LIMIT) + } + + fun record(gameKey: String): List { + val normalized = gameKey.trim() + if (normalized.isBlank()) return load() + val next = (listOf(normalized) + load().filterNot { it == normalized }) + .take(QUEUED_GAME_LIMIT) + prefs.edit().putString(KEY_QUEUED_GAME_KEYS, OpenNowJson.encodeToString(next)).apply() + return next + } +} + +class AndroidUpdateNoticeStore(context: Context) { + private val prefs = ExternalPrefs.get(context, STORE_NAME) + + fun dismissedKey(): String? = + prefs.getString(KEY_ANDROID_UPDATE_DISMISSED_NOTICE, null)?.takeIf { it.isNotBlank() } + + fun dismiss(key: String) { + prefs.edit().putString(KEY_ANDROID_UPDATE_DISMISSED_NOTICE, key).apply() + } +} diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/PhysicalInputLifecycle.kt b/android/app/src/main/java/com/opencloudgaming/opennow/PhysicalInputLifecycle.kt new file mode 100644 index 000000000..2bfa9949a --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/PhysicalInputLifecycle.kt @@ -0,0 +1,75 @@ +package com.opencloudgaming.opennow + +/** + * Tracks physical key/button presses that the cloud host accepted locally. Android desktop mode + * can move focus before delivering the matching UP event (notably for Alt+Tab), so callers must + * take and release this state when the stream window loses focus. + */ +internal class ForwardedPhysicalInputState { + private data class KeyIdentity( + val deviceId: Int, + val keyCode: Int, + val scanCode: Int, + ) + + data class ReleaseSnapshot( + val keys: List, + val mouseButtons: List, + ) { + val isEmpty: Boolean + get() = keys.isEmpty() && mouseButtons.isEmpty() + } + + private val lock = Any() + private val pressedKeys = linkedMapOf() + private val pressedMouseButtons = linkedSetOf() + + fun recordKey( + deviceId: Int, + keyCode: Int, + scanCode: Int, + payload: InputEncoder.KeyboardPayload, + pressed: Boolean, + sent: Boolean, + ) { + val identity = KeyIdentity(deviceId, keyCode, scanCode) + synchronized(lock) { + if (pressed) { + if (sent) pressedKeys[identity] = payload + } else { + // Forget the local press even if the release raced a closed channel. Carrying it + // into a replacement transport would release input in the wrong cloud session. + pressedKeys.remove(identity) + } + Unit + } + } + + fun recordMouseButton(button: Int, pressed: Boolean, sent: Boolean) { + synchronized(lock) { + if (pressed) { + if (sent) pressedMouseButtons += button + } else { + pressedMouseButtons -= button + } + Unit + } + } + + fun takeReleaseSnapshot(): ReleaseSnapshot = synchronized(lock) { + ReleaseSnapshot( + keys = pressedKeys.values.toList().asReversed(), + mouseButtons = pressedMouseButtons.toList().asReversed(), + ).also { + pressedKeys.clear() + pressedMouseButtons.clear() + } + } + + fun reset() { + synchronized(lock) { + pressedKeys.clear() + pressedMouseButtons.clear() + } + } +} diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/PhysicalMouseDevices.kt b/android/app/src/main/java/com/opencloudgaming/opennow/PhysicalMouseDevices.kt new file mode 100644 index 000000000..26f5a0bf4 --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/PhysicalMouseDevices.kt @@ -0,0 +1,76 @@ +package com.opencloudgaming.opennow + +import android.content.Context +import android.hardware.input.InputManager +import android.view.InputDevice +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext + +internal fun isMouseInputSource(sources: Int): Boolean = + (sources and InputDevice.SOURCE_MOUSE) == InputDevice.SOURCE_MOUSE || + (sources and InputDevice.SOURCE_MOUSE_RELATIVE) == InputDevice.SOURCE_MOUSE_RELATIVE + +internal fun isPhysicalMouseDevice(device: InputDevice?): Boolean = + device != null && !device.isVirtual && isMouseInputSource(device.sources) + +internal fun isKeyboardInputSource(sources: Int, keyboardType: Int): Boolean = + (sources and InputDevice.SOURCE_KEYBOARD) == InputDevice.SOURCE_KEYBOARD && + keyboardType == InputDevice.KEYBOARD_TYPE_ALPHABETIC + +internal fun isPhysicalKeyboardDevice(device: InputDevice?): Boolean = + device != null && + !device.isVirtual && + isKeyboardInputSource(device.sources, device.keyboardType) + +internal data class PhysicalKeyboardMouseConnection( + val mouseConnected: Boolean, + val keyboardConnected: Boolean, +) { + val connected: Boolean + get() = mouseConnected || keyboardConnected +} + +internal fun connectedPhysicalKeyboardMouse(): PhysicalKeyboardMouseConnection = runCatching { + val devices = InputDevice.getDeviceIds().map(InputDevice::getDevice).filterNotNull() + PhysicalKeyboardMouseConnection( + mouseConnected = devices.any(::isPhysicalMouseDevice), + keyboardConnected = devices.any(::isPhysicalKeyboardDevice), + ) +}.getOrDefault(PhysicalKeyboardMouseConnection(mouseConnected = false, keyboardConnected = false)) + +internal fun hasConnectedPhysicalKeyboardOrMouse(): Boolean = connectedPhysicalKeyboardMouse().connected + +@Composable +internal fun rememberPhysicalKeyboardMouseConnection(enabled: Boolean): PhysicalKeyboardMouseConnection { + val context = LocalContext.current.applicationContext + val disconnected = PhysicalKeyboardMouseConnection(mouseConnected = false, keyboardConnected = false) + var connection by remember(enabled) { + mutableStateOf(if (enabled) connectedPhysicalKeyboardMouse() else disconnected) + } + DisposableEffect(context, enabled) { + fun refresh() { + connection = if (enabled) connectedPhysicalKeyboardMouse() else disconnected + } + refresh() + if (!enabled) { + onDispose {} + } else { + val inputManager = context.getSystemService(Context.INPUT_SERVICE) as? InputManager + val listener = object : InputManager.InputDeviceListener { + override fun onInputDeviceAdded(deviceId: Int) = refresh() + override fun onInputDeviceRemoved(deviceId: Int) = refresh() + override fun onInputDeviceChanged(deviceId: Int) = refresh() + } + inputManager?.registerInputDeviceListener(listener, null) + onDispose { + inputManager?.unregisterInputDeviceListener(listener) + } + } + } + return connection +} diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/PrintedWasteZones.kt b/android/app/src/main/java/com/opencloudgaming/opennow/PrintedWasteZones.kt new file mode 100644 index 000000000..c4f1673cf --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/PrintedWasteZones.kt @@ -0,0 +1,179 @@ +package com.opencloudgaming.opennow + +/** + * PrintedWaste zone identity, naming, and grouping. + * + * Zone id handling used to live in three places — the selector in `OpenNowCatalogControls.kt` and + * two private copies in `OpenNowViewModel.kt` — each re-deriving the `NP-` prefix rule and the + * CloudMatch host. This is the one owner; the UI decides presentation, and this decides what a zone + * *is*. + * + * The provider's own mapping (`GFN_SERVERID_TO_REGION_MAPPING`) carries a human `title` and a + * `region` for every server id, and OpenNOW already fetched both and then showed neither. Zone ids + * like `NP-LAX-03` mean nothing to a player, and worse, several ids share one physical location: + * `NP-LAX-02` and `NP-LAX-03` are both Southern California, so the list read as duplicates. Naming + * and combining them is what these functions exist for. + */ + +/** Alliance partner zones (`NPA-`) are not routable through the free queue. */ +internal fun isStandardPrintedWasteZone(zoneId: String): Boolean = + zoneId.startsWith("NP-") && !zoneId.startsWith("NPA-") + +internal fun printedWasteZoneUrl(zoneId: String): String = + "https://${zoneId.lowercase()}.cloudmatchbeta.nvidiagrid.net/" + +/** The GPU a zone advertises, when the mapping says. */ +internal enum class PrintedWasteGpuTier(val label: String) { + Rtx5080("RTX 5080"), + Rtx4080("RTX 4080"), +} + +internal fun printedWasteGpuTier(entry: PrintedWasteServerMappingEntry?): PrintedWasteGpuTier? = when { + entry?.is5080Server == true -> PrintedWasteGpuTier.Rtx5080 + entry?.is4080Server == true -> PrintedWasteGpuTier.Rtx4080 + else -> null +} + +/** + * The name to show for a zone: the mapping's title, or the raw id when the mapping has no entry. + * + * Falling back to the id rather than to something like "Unknown" keeps a newly added server + * selectable and still identifiable the day it appears, before the mapping catches up. + */ +internal fun printedWasteZoneTitle(zoneId: String, entry: PrintedWasteServerMappingEntry?): String = + entry?.title?.trim()?.takeIf { it.isNotEmpty() } ?: zoneId + +/** + * The broad region heading a zone sits under, e.g. `US Southwest`. + * + * Prefers the mapping's `region` over the queue payload's coarser continent code, which is only + * ever `US`, `EU`, `CA`, and so on — too blunt to group by once names are being shown. + */ +internal fun printedWasteZoneRegion(entry: PrintedWasteServerMappingEntry?, queueRegion: String): String = + entry?.region?.trim()?.takeIf { it.isNotEmpty() } ?: printedWasteContinentLabel(queueRegion) + +internal fun printedWasteContinentLabel(region: String): String = when (region.uppercase()) { + "US" -> "North America" + "CA" -> "Canada" + "EU" -> "Europe" + "JP" -> "Japan" + "KR" -> "South Korea" + "THAI" -> "Southeast Asia" + "MY" -> "Malaysia" + else -> region +} + +/** + * One selectable location, standing for every zone id that shares its name. + * + * [primary] is the id a launch actually routes to — the best of the group. [alternateCount] is how + * many others were folded in, shown so the row does not silently hide capacity the player might + * want to know about. + */ +internal data class PrintedWasteLocation( + val title: String, + val region: String, + val primary: PrintedWasteZoneOption, + val alternateCount: Int, + val gpuTier: PrintedWasteGpuTier?, +) + +/** + * Folds zone options into one entry per physical location, best server first within each. + * + * "Best" is the same [printedWasteScore] the recommendation uses, so the id a combined row routes + * to is the one the app would have picked anyway had the list stayed flat. + */ +internal fun printedWasteLocations( + zones: List, + mapping: Map, +): List { + if (zones.isEmpty()) return emptyList() + val maxPing = zones.mapNotNull { it.pingMs }.maxOrNull()?.coerceAtLeast(1L) ?: 1L + val maxQueue = zones.maxOfOrNull { it.zone.QueuePosition }?.coerceAtLeast(1) ?: 1 + return zones + .groupBy { printedWasteZoneTitle(it.zoneId, mapping[it.zoneId]) } + .map { (title, group) -> + val ordered = group.sortedWith( + compareBy { printedWasteScore(it, maxPing, maxQueue) } + .thenBy { it.zoneId }, + ) + val primary = ordered.first() + PrintedWasteLocation( + title = title, + region = printedWasteZoneRegion(mapping[primary.zoneId], primary.zone.Region), + primary = primary, + alternateCount = ordered.size - 1, + // Report the best GPU anywhere in the group: the row stands for the location, and + // a 5080 sitting behind a folded id is still what the player can reach from here. + gpuTier = ordered.firstNotNullOfOrNull { printedWasteGpuTier(mapping[it.zoneId]) }, + ) + } +} + +/** + * Locations grouped under their region heading, both ordered by how good the best option is. + * + * Regions sort by their strongest location rather than alphabetically, so the nearest servers stay + * at the top of a list that is now several headings long. + */ +internal fun printedWasteRegionGroups( + locations: List, + maxPing: Long, + maxQueue: Int, +): List>> = + locations + .groupBy { it.region } + .entries + .map { (region, group) -> + region to group.sortedWith( + compareBy { printedWasteScore(it.primary, maxPing, maxQueue) } + .thenBy { it.title }, + ) + } + .sortedWith( + compareBy>> { + printedWasteScore(it.second.first().primary, maxPing, maxQueue) + }.thenBy { it.first }, + ) + +internal data class PrintedWasteZoneOption( + val zoneId: String, + val zone: PrintedWasteZone, + val routingUrl: String, + val pingMs: Long?, +) + +internal fun recommendedPrintedWasteZone(zones: List): PrintedWasteZoneOption? { + if (zones.isEmpty()) return null + val pool = zones.filter { it.pingMs != null }.ifEmpty { zones } + val maxPing = pool.mapNotNull { it.pingMs }.maxOrNull()?.coerceAtLeast(1L) ?: 1L + val maxQueue = pool.maxOfOrNull { it.zone.QueuePosition }?.coerceAtLeast(1) ?: 1 + val queueAwareRecommendation = pool.minWithOrNull( + compareBy { printedWasteScore(it, maxPing, maxQueue) } + .thenBy { it.pingMs ?: Long.MAX_VALUE } + .thenBy { it.zone.QueuePosition }, + ) + if ((queueAwareRecommendation?.pingMs ?: 0L) <= MAX_QUEUE_AWARE_RECOMMENDED_PING_MS) { + return queueAwareRecommendation + } + + return pool.minWithOrNull( + compareBy { it.pingMs ?: Long.MAX_VALUE } + .thenBy { it.zone.QueuePosition } + .thenBy { it.zoneId }, + ) +} + +internal fun printedWasteScore(zone: PrintedWasteZoneOption, maxPing: Long, maxQueue: Int): Double { + val pingScore = ((zone.pingMs ?: maxPing).toDouble() / maxPing.toDouble()) * 0.75 + val queueScore = (zone.zone.QueuePosition.toDouble() / maxQueue.toDouble()) * 0.25 + return pingScore + queueScore +} + +private const val MAX_QUEUE_AWARE_RECOMMENDED_PING_MS = 100L + +internal fun formatPrintedWasteWait(etaMs: Long): String { + val minutes = ((etaMs + 59_999L) / 60_000L).coerceAtLeast(1L) + return if (minutes < 60L) "${minutes}m" else "${minutes / 60L}h ${minutes % 60L}m" +} diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/ProcessCpuProfiler.kt b/android/app/src/main/java/com/opencloudgaming/opennow/ProcessCpuProfiler.kt new file mode 100644 index 000000000..6a74bcde2 --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/ProcessCpuProfiler.kt @@ -0,0 +1,125 @@ +package com.opencloudgaming.opennow + +import android.os.Process +import android.os.SystemClock +import java.util.Locale + +private const val PROCESS_CPU_PROFILE_MAX_SAMPLES = 180 + +internal data class ProcessCpuUsageSample( + val capturedAtElapsedRealtimeMs: Long, + val windowMs: Long, + val processCpuPercent: Double, + val deviceCpuCapacityPercent: Double, + val logicalCoreCount: Int, +) + +/** + * Samples CPU time consumed by the whole app process, including native/WebRTC threads. + * [processCpuPercent] is expressed in logical-core equivalents and may exceed 100%; + * [deviceCpuCapacityPercent] normalizes the same value across the device's logical cores. + */ +internal class ProcessCpuSampler( + private val processCpuTimeMs: () -> Long = Process::getElapsedCpuTime, + private val elapsedRealtimeMs: () -> Long = SystemClock::elapsedRealtime, + private val logicalCoreCount: Int = Runtime.getRuntime().availableProcessors().coerceAtLeast(1), +) { + private var previousProcessCpuTimeMs: Long? = null + private var previousElapsedRealtimeMs: Long? = null + + @Synchronized + fun reset() { + previousProcessCpuTimeMs = null + previousElapsedRealtimeMs = null + } + + @Synchronized + fun sample(): ProcessCpuUsageSample? { + val currentProcessCpuTimeMs = processCpuTimeMs() + val currentElapsedRealtimeMs = elapsedRealtimeMs() + val previousCpu = previousProcessCpuTimeMs + val previousElapsed = previousElapsedRealtimeMs + previousProcessCpuTimeMs = currentProcessCpuTimeMs + previousElapsedRealtimeMs = currentElapsedRealtimeMs + + if (previousCpu == null || previousElapsed == null) return null + val cpuDeltaMs = currentProcessCpuTimeMs - previousCpu + val elapsedDeltaMs = currentElapsedRealtimeMs - previousElapsed + if (cpuDeltaMs < 0L || elapsedDeltaMs <= 0L) return null + + val maximumProcessPercent = logicalCoreCount * 100.0 + val processPercent = (cpuDeltaMs * 100.0 / elapsedDeltaMs) + .coerceIn(0.0, maximumProcessPercent) + return ProcessCpuUsageSample( + capturedAtElapsedRealtimeMs = currentElapsedRealtimeMs, + windowMs = elapsedDeltaMs, + processCpuPercent = processPercent, + deviceCpuCapacityPercent = (processPercent / logicalCoreCount).coerceIn(0.0, 100.0), + logicalCoreCount = logicalCoreCount, + ) + } +} + +internal class ProcessCpuProfileBuffer( + private val maxSamples: Int = PROCESS_CPU_PROFILE_MAX_SAMPLES, +) { + init { + require(maxSamples > 0) + } + + private val samples = ArrayDeque() + + @Synchronized + fun reset() { + samples.clear() + } + + @Synchronized + fun record(sample: ProcessCpuUsageSample) { + samples.addLast(sample) + while (samples.size > maxSamples) { + samples.removeFirst() + } + } + + @Synchronized + fun snapshot(): String { + if (samples.isEmpty()) return "cpu.profile=empty" + val current = samples.toList() + val averageProcessPercent = current.map { it.processCpuPercent }.average() + val peakProcessPercent = current.maxOf { it.processCpuPercent } + val averageDevicePercent = current.map { it.deviceCpuCapacityPercent }.average() + val peakDevicePercent = current.maxOf { it.deviceCpuCapacityPercent } + return buildString { + appendLine( + "cpu.profile samples=${current.size} cores=${current.last().logicalCoreCount} " + + "processAvgPct=${averageProcessPercent.cpuPercent()} processPeakPct=${peakProcessPercent.cpuPercent()} " + + "deviceCapacityAvgPct=${averageDevicePercent.cpuPercent()} deviceCapacityPeakPct=${peakDevicePercent.cpuPercent()}", + ) + appendLine("cpu.profile.basis=process CPU time divided by wall time; processPct may exceed 100; deviceCapacityPct is normalized by logical cores") + current.forEachIndexed { index, sample -> + appendLine( + "cpu.${index + 1} uptimeMs=${sample.capturedAtElapsedRealtimeMs} windowMs=${sample.windowMs} " + + "processPct=${sample.processCpuPercent.cpuPercent()} " + + "deviceCapacityPct=${sample.deviceCpuCapacityPercent.cpuPercent()}", + ) + } + }.trimEnd() + } +} + +internal object ProcessCpuDiagnostics { + private val profile = ProcessCpuProfileBuffer() + + fun beginStream() { + profile.reset() + } + + fun record(sample: ProcessCpuUsageSample) { + profile.record(sample) + } + + fun snapshot(): String = profile.snapshot() +} + +private fun Double.cpuPercent(): String = "%.1f".format(Locale.US, this) diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/QrCode.kt b/android/app/src/main/java/com/opencloudgaming/opennow/QrCode.kt new file mode 100644 index 000000000..77766f83e --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/QrCode.kt @@ -0,0 +1,228 @@ +package com.opencloudgaming.opennow + +private val QR_DATA_CODEWORDS_LOW = intArrayOf(0, 19, 34, 55, 80, 108, 136, 156, 194, 232) +private val QR_ECC_CODEWORDS_LOW = intArrayOf(0, 7, 10, 15, 20, 26, 18, 20, 24, 30) +private val QR_BLOCKS_LOW = intArrayOf(0, 1, 1, 1, 1, 1, 2, 2, 2, 2) +private val QR_ALIGN_POSITIONS = arrayOf( + intArrayOf(), + intArrayOf(), + intArrayOf(6, 18), + intArrayOf(6, 22), + intArrayOf(6, 26), + intArrayOf(6, 30), + intArrayOf(6, 34), + intArrayOf(6, 22, 38), + intArrayOf(6, 24, 42), + intArrayOf(6, 26, 46), +) + +data class QrCode( + val size: Int, + private val modules: BooleanArray, +) { + fun isDark(x: Int, y: Int): Boolean = + x in 0 until size && y in 0 until size && modules[y * size + x] + + companion object { + fun encodeText(text: String): QrCode? { + val bytes = text.encodeToByteArray() + val version = (1 until QR_DATA_CODEWORDS_LOW.size).firstOrNull { version -> + val capacityBits = QR_DATA_CODEWORDS_LOW[version] * 8 + 4 + 8 + bytes.size * 8 <= capacityBits + } ?: return null + return encodeBytes(bytes, version) + } + + private fun encodeBytes(bytes: ByteArray, version: Int): QrCode { + val dataCodewords = QR_DATA_CODEWORDS_LOW[version] + val bits = ArrayList(dataCodewords * 8) + appendBits(bits, 0x4, 4) + appendBits(bits, bytes.size, 8) + bytes.forEach { appendBits(bits, it.toInt() and 0xff, 8) } + repeat(minOf(4, dataCodewords * 8 - bits.size)) { bits += false } + while (bits.size % 8 != 0) bits += false + val data = ArrayList(dataCodewords) + for (i in bits.indices step 8) { + var value = 0 + for (j in 0 until 8) value = (value shl 1) or if (bits[i + j]) 1 else 0 + data += value + } + var pad = 0xec + while (data.size < dataCodewords) { + data += pad + pad = pad xor 0xfd + } + val allCodewords = addErrorCorrection(data, version) + val builder = QrBuilder(version) + builder.drawFunctionPatterns() + builder.drawFormatBits(mask = 0) + builder.drawCodewords(allCodewords) + builder.drawFormatBits(mask = 0) + return QrCode(builder.size, builder.modules) + } + + private fun appendBits(bits: MutableList, value: Int, count: Int) { + for (i in count - 1 downTo 0) bits += ((value ushr i) and 1) != 0 + } + + private fun addErrorCorrection(data: List, version: Int): List { + val blockCount = QR_BLOCKS_LOW[version] + val eccLen = QR_ECC_CODEWORDS_LOW[version] + val generator = reedSolomonGenerator(eccLen) + val shortBlockLen = data.size / blockCount + val blocks = (0 until blockCount).map { blockIndex -> + val start = blockIndex * shortBlockLen + data.subList(start, start + shortBlockLen) + } + val eccBlocks = blocks.map { reedSolomonRemainder(it, generator) } + val output = ArrayList(data.size + blockCount * eccLen) + for (i in 0 until shortBlockLen) { + blocks.forEach { output += it[i] } + } + for (i in 0 until eccLen) { + eccBlocks.forEach { output += it[i] } + } + return output + } + + private fun reedSolomonGenerator(degree: Int): IntArray { + val result = IntArray(degree) + result[degree - 1] = 1 + var root = 1 + repeat(degree) { + for (i in result.indices) { + result[i] = gfMultiply(result[i], root) + if (i + 1 < result.size) { + result[i] = result[i] xor result[i + 1] + } + } + root = gfMultiply(root, 2) + } + return result + } + + private fun reedSolomonRemainder(data: List, generator: IntArray): IntArray { + val result = IntArray(generator.size) + data.forEach { value -> + val factor = value xor result[0] + for (i in 0 until result.lastIndex) result[i] = result[i + 1] + result[result.lastIndex] = 0 + for (i in generator.indices) result[i] = result[i] xor gfMultiply(generator[i], factor) + } + return result + } + + private fun gfMultiply(x: Int, y: Int): Int { + var a = x + var b = y + var result = 0 + while (b != 0) { + if ((b and 1) != 0) result = result xor a + a = a shl 1 + if ((a and 0x100) != 0) a = a xor 0x11d + b = b ushr 1 + } + return result + } + } +} + +private class QrBuilder(private val version: Int) { + val size = version * 4 + 17 + val modules = BooleanArray(size * size) + private val functionModules = BooleanArray(size * size) + + fun drawFunctionPatterns() { + drawFinder(3, 3) + drawFinder(size - 4, 3) + drawFinder(3, size - 4) + for (i in 8 until size - 8) { + setFunction(6, i, i % 2 == 0) + setFunction(i, 6, i % 2 == 0) + } + val align = QR_ALIGN_POSITIONS[version] + for (x in align) { + for (y in align) { + val overlapsFinder = (x == 6 && y == 6) || (x == 6 && y == size - 7) || (x == size - 7 && y == 6) + if (!overlapsFinder) drawAlignment(x, y) + } + } + setFunction(8, size - 8, true) + } + + fun drawCodewords(codewords: List) { + var bitIndex = 0 + var upward = true + var right = size - 1 + while (right >= 1) { + if (right == 6) right-- + for (vertical in 0 until size) { + val y = if (upward) size - 1 - vertical else vertical + for (dx in 0..1) { + val x = right - dx + if (functionModules[index(x, y)]) continue + val bit = if (bitIndex < codewords.size * 8) { + ((codewords[bitIndex / 8] ushr (7 - bitIndex % 8)) and 1) != 0 + } else { + false + } + val masked = bit xor ((x + y) % 2 == 0) + modules[index(x, y)] = masked + bitIndex++ + } + } + upward = !upward + right -= 2 + } + } + + fun drawFormatBits(mask: Int) { + val bits = formatBits(mask) + for (i in 0..5) setFunction(8, i, bit(bits, i)) + setFunction(8, 7, bit(bits, 6)) + setFunction(8, 8, bit(bits, 7)) + setFunction(7, 8, bit(bits, 8)) + for (i in 9..14) setFunction(14 - i, 8, bit(bits, i)) + for (i in 0..7) setFunction(size - 1 - i, 8, bit(bits, i)) + for (i in 8..14) setFunction(8, size - 15 + i, bit(bits, i)) + setFunction(8, size - 8, true) + } + + private fun drawFinder(cx: Int, cy: Int) { + for (dy in -4..4) { + for (dx in -4..4) { + val x = cx + dx + val y = cy + dy + if (x !in 0 until size || y !in 0 until size) continue + val dist = maxOf(kotlin.math.abs(dx), kotlin.math.abs(dy)) + setFunction(x, y, dist != 2 && dist != 4) + } + } + } + + private fun drawAlignment(cx: Int, cy: Int) { + for (dy in -2..2) { + for (dx in -2..2) { + setFunction(cx + dx, cy + dy, maxOf(kotlin.math.abs(dx), kotlin.math.abs(dy)) != 1) + } + } + } + + private fun setFunction(x: Int, y: Int, dark: Boolean) { + modules[index(x, y)] = dark + functionModules[index(x, y)] = true + } + + private fun index(x: Int, y: Int): Int = y * size + x + + private fun formatBits(mask: Int): Int { + var data = (1 shl 3) or mask + var rem = data + repeat(10) { + rem = (rem shl 1) xor if ((rem and (1 shl 9)) != 0) 0x537 else 0 + } + return ((data shl 10) or rem) xor 0x5412 + } + + private fun bit(value: Int, index: Int): Boolean = ((value ushr index) and 1) != 0 +} diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/QueueLaunchStatus.kt b/android/app/src/main/java/com/opencloudgaming/opennow/QueueLaunchStatus.kt new file mode 100644 index 000000000..248ce21df --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/QueueLaunchStatus.kt @@ -0,0 +1,104 @@ +package com.opencloudgaming.opennow + +import android.content.Context + +internal enum class QueueLaunchStatusKind { + QueuePosition, + WaitingForRig, + ConnectingStream, + ResumingSession, + SettingUpRig, + StartingSession, +} + +internal data class QueueLaunchStatus( + val kind: QueueLaunchStatusKind, + val queuePosition: Int? = null, +) + +internal fun queueLaunchStatus( + state: OpenNowUiState, + queuePosition: Int? = queueDisplayPosition(state), +): QueueLaunchStatus { + val session = state.streamSession + return when { + queuePosition != null -> QueueLaunchStatus(QueueLaunchStatusKind.QueuePosition, queuePosition) + session?.seatSetupStep == 1 -> QueueLaunchStatus(QueueLaunchStatusKind.WaitingForRig) + state.launchPhase.equals("Connecting stream", ignoreCase = true) -> QueueLaunchStatus(QueueLaunchStatusKind.ConnectingStream) + state.launchPhase.equals("Resuming session", ignoreCase = true) -> QueueLaunchStatus(QueueLaunchStatusKind.ResumingSession) + state.launchPhase.equals("Setting up rig", ignoreCase = true) -> QueueLaunchStatus(QueueLaunchStatusKind.SettingUpRig) + else -> QueueLaunchStatus(QueueLaunchStatusKind.StartingSession) + } +} + +internal fun queueLaunchStatusText(state: OpenNowUiState): String { + val status = queueLaunchStatus(state) + return when (status.kind) { + QueueLaunchStatusKind.QueuePosition -> "Queue position ${status.queuePosition}" + QueueLaunchStatusKind.WaitingForRig -> "Waiting for a rig" + QueueLaunchStatusKind.ConnectingStream -> "Connecting stream" + QueueLaunchStatusKind.ResumingSession -> "Resuming session" + QueueLaunchStatusKind.SettingUpRig -> "Setting up rig" + QueueLaunchStatusKind.StartingSession -> "Starting session" + } +} + +internal fun localizedQueueLaunchStatusText(context: Context, state: OpenNowUiState): String { + val localizedContext = localizedAndroidContext(context) + val status = queueLaunchStatus(state) + return when (status.kind) { + QueueLaunchStatusKind.QueuePosition -> localizedContext.getString(R.string.queue_position, status.queuePosition) + QueueLaunchStatusKind.WaitingForRig -> localizedContext.getString(R.string.queue_waiting_for_rig) + QueueLaunchStatusKind.ConnectingStream -> localizedContext.getString(R.string.queue_connecting_stream) + QueueLaunchStatusKind.ResumingSession -> localizedContext.getString(R.string.queue_resuming_session) + QueueLaunchStatusKind.SettingUpRig -> localizedContext.getString(R.string.queue_setting_up_rig) + QueueLaunchStatusKind.StartingSession -> localizedContext.getString(R.string.queue_starting_session) + } +} + +internal fun queueDisplayPosition(state: OpenNowUiState): Int? { + val session = state.streamSession + if (session?.seatSetupStep == 5) return null + return state.queuePosition?.takeIf { it > 0 } ?: queueDisplayPosition(session) +} + +internal fun queueDisplayPosition(session: SessionInfo?): Int? { + if (session?.seatSetupStep == 5) return null + return session?.queuePosition?.takeIf { it > 0 } +} + +internal fun shouldShowQueueLaunchStatus(state: OpenNowUiState): Boolean { + if (state.streamStatus == "idle") return false + val sessionStatus = state.streamSession?.status + return sessionStatus == null || sessionStatus !in setOf(2, 3) +} + +internal fun isActivelyQueued(state: OpenNowUiState): Boolean = + queueDisplayPosition(state) != null || + (state.streamStatus == "queue" && state.launchPhase.equals("Queue", ignoreCase = true)) + +internal class QueueReadyNotificationTracker { + private var queuedSessionId: String? = null + + fun update(state: OpenNowUiState): Boolean { + if (isActivelyQueued(state)) { + state.streamSession?.sessionId?.let { queuedSessionId = it } + return false + } + + if (state.streamStatus == "idle") { + reset() + return false + } + if (state.streamStatus != "connecting") return false + + val currentSessionId = state.streamSession?.sessionId + val completedObservedQueue = currentSessionId != null && currentSessionId == queuedSessionId + reset() + return completedObservedQueue + } + + private fun reset() { + queuedSessionId = null + } +} diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/RapidTapTracker.kt b/android/app/src/main/java/com/opencloudgaming/opennow/RapidTapTracker.kt new file mode 100644 index 000000000..9d2c9496f --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/RapidTapTracker.kt @@ -0,0 +1,24 @@ +package com.opencloudgaming.opennow + +internal class RapidTapTracker( + private val requiredTapCount: Int = 10, + private val windowMs: Long = 8_000L, +) { + private val tapTimes = ArrayDeque() + + init { + require(requiredTapCount > 0) { "Tap count must be positive." } + require(windowMs > 0) { "Tap window must be positive." } + } + + fun recordTap(nowMs: Long): Boolean { + while (tapTimes.firstOrNull()?.let { nowMs - it > windowMs } == true) { + tapTimes.removeFirst() + } + tapTimes.addLast(nowMs) + if (tapTimes.size < requiredTapCount) return false + + tapTimes.clear() + return true + } +} diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/RendererSinkLifecycle.kt b/android/app/src/main/java/com/opencloudgaming/opennow/RendererSinkLifecycle.kt new file mode 100644 index 000000000..a4641d719 --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/RendererSinkLifecycle.kt @@ -0,0 +1,29 @@ +package com.opencloudgaming.opennow + +/** + * Tracks the sink state requested by Android surface callbacks. + * + * Surface callbacks run on the main thread while WebRTC can replace tracks from its own callback + * threads. Keeping this transition atomic prevents duplicate queued add/remove operations without + * making either caller wait for the native renderer lock. + */ +internal class RendererSinkLifecycle { + private var attachRequested = false + + @Synchronized + fun requestAttach(): Boolean { + if (attachRequested) return false + attachRequested = true + return true + } + + @Synchronized + fun requestDetach(): Boolean { + if (!attachRequested) return false + attachRequested = false + return true + } + + @Synchronized + fun isAttachRequested(): Boolean = attachRequested +} diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/SessionAssignment.kt b/android/app/src/main/java/com/opencloudgaming/opennow/SessionAssignment.kt new file mode 100644 index 000000000..8f43af140 --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/SessionAssignment.kt @@ -0,0 +1,31 @@ +package com.opencloudgaming.opennow + +import java.util.Locale + +private val SESSION_ZONE_ID = Regex("^npa?-[a-z0-9]+(?:-[a-z0-9]+)+$") + +/** + * Returns the zone that owns an allocated session, when CloudMatch exposes it in the session + * control hostname. This is deliberately separate from requestStatus.serverId: that field can + * continue to identify the zone that handled the request even when Free Tier assigns the rig in + * another zone. + */ +internal fun assignedSessionZoneFromControlHost(rawHost: String?): String? { + val host = rawHost + ?.trim() + ?.trimEnd('.') + ?.lowercase(Locale.US) + ?.takeIf { it.isNotBlank() } + ?: return null + val isNvidiaSessionHost = host.endsWith(".cloudmatchbeta.nvidiagrid.net") || + host.endsWith(".cloudmatch.nvidiagrid.net") || + host.endsWith(".geforcenow.nvidiagrid.net") + if (!isNvidiaSessionHost) return null + + return host.substringBefore('.') + .takeIf(SESSION_ZONE_ID::matches) + ?.uppercase(Locale.US) +} + +/** The allocated zone when known, with the requested/routing zone retained as a safe fallback. */ +internal fun SessionInfo.reportedServerZone(): String = assignedZone ?: zone diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/SessionReport.kt b/android/app/src/main/java/com/opencloudgaming/opennow/SessionReport.kt new file mode 100644 index 000000000..d6a1d64d2 --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/SessionReport.kt @@ -0,0 +1,643 @@ +package com.opencloudgaming.opennow + +import kotlin.math.roundToInt + +enum class SessionReportRating(val label: String) { + Excellent("Excellent"), + Good("Good"), + Fair("Fair"), + Poor("Needs work"), +} + +enum class SessionReportFindingKind { + Info, + Warning, +} + +data class SessionReportFinding( + val title: String, + val detail: String, + val kind: SessionReportFindingKind = SessionReportFindingKind.Info, +) + +internal data class StreamReportLaunchProfile( + val gameTitle: String, + val selectedSettings: StreamSettings, + val eligibleSettings: StreamSettings, + val initialSettings: StreamSettings, +) + +data class SessionReport( + val gameTitle: String, + val score: Int, + val rating: SessionReportRating, + val durationSeconds: Int, + val sampleCount: Int, + val limitedData: Boolean, + val averagePingMs: Int?, + val peakPingMs: Int?, + val averageBitrateKbps: Int?, + val peakBitrateKbps: Int?, + val packetLossPct: Double?, + val averageJitterMs: Double?, + val averageFps: Double?, + val targetFps: Int, + val averageDecodeMs: Double?, + val requestedResolution: String, + val deliveredResolution: String?, + val requestedCodec: VideoCodec, + val deliveredCodec: String?, + val networkKind: AndroidNetworkKind, + val wifiBand: AndroidWifiBand, + val estimatedLinkDownstreamKbps: Int?, + val downgrades: List, + val recommendations: List, +) + +internal class StreamSessionReportAccumulator( + private val launchProfile: StreamReportLaunchProfile, + private val startedAtMs: Long, +) { + private var sampleCount = 0 + private var pingCount = 0 + private var pingTotal = 0L + private var peakPingMs: Int? = null + private var bitrateCount = 0 + private var bitrateTotal = 0L + private var peakBitrateKbps: Int? = null + private var jitterCount = 0 + private var jitterTotal = 0.0 + private var fpsCount = 0 + private var fpsTotal = 0L + private var receivedFpsCount = 0 + private var receivedFpsTotal = 0L + private var decodedFpsCount = 0 + private var decodedFpsTotal = 0L + private var decodeCount = 0 + private var decodeTotal = 0.0 + private var consecutiveDecoderOverloadSamples = 0 + private var decoderOverloadDetected = false + private var packetLossSampleCount = 0 + private var packetLossSampleTotal = 0.0 + private var packetsLost = 0L + private var packetsReceived = 0L + private var hasPacketDeltas = false + private var lastResolution: String? = null + private var lastCodec: String? = null + private val networkKindCounts = mutableMapOf() + private val wifiBandCounts = mutableMapOf() + private var linkEstimateCount = 0 + private var linkEstimateTotal = 0L + private var lowestNetworkBars: Int? = null + private var finalSettings = launchProfile.initialSettings + private var recoveryReason: String? = null + private var activeMode: ActiveStreamModeStatus? = null + + fun record(stats: StreamRuntimeStats, network: AndroidRuntimeDiagnosticsSnapshot? = null) { + if (stats.hasSessionReportValues()) { + sampleCount += 1 + } + stats.pingMs?.takeIf { it >= 0 }?.let { value -> + pingCount += 1 + pingTotal += value + peakPingMs = maxOf(peakPingMs ?: value, value) + } + stats.bitrateKbps?.takeIf { it >= 0 }?.let { value -> + bitrateCount += 1 + bitrateTotal += value + peakBitrateKbps = maxOf(peakBitrateKbps ?: value, value) + } + stats.jitterMs?.takeIf { it >= 0.0 }?.let { value -> + jitterCount += 1 + jitterTotal += value + } + stats.fps?.takeIf { it > 0 }?.let { value -> + fpsCount += 1 + fpsTotal += value + } + stats.receivedFps?.takeIf { it > 0 }?.let { value -> + receivedFpsCount += 1 + receivedFpsTotal += value + } + stats.decodedFps?.takeIf { it > 0 }?.let { value -> + decodedFpsCount += 1 + decodedFpsTotal += value + } + stats.decodeMs?.takeIf { it >= 0.0 }?.let { value -> + decodeCount += 1 + decodeTotal += value + } + consecutiveDecoderOverloadSamples = if ( + isDecoderOverloadSample(stats, launchProfile.initialSettings.fps) + ) { + consecutiveDecoderOverloadSamples + 1 + } else { + 0 + } + if (consecutiveDecoderOverloadSamples >= SESSION_REPORT_DECODER_OVERLOAD_SAMPLES) { + decoderOverloadDetected = true + } + val lostDelta = stats.packetsLostDelta + val receivedDelta = stats.packetsReceivedDelta + if (lostDelta != null && receivedDelta != null && lostDelta >= 0L && receivedDelta >= 0L) { + hasPacketDeltas = true + packetsLost += lostDelta + packetsReceived += receivedDelta + } else { + stats.packetLossPct?.takeIf { it >= 0.0 }?.let { value -> + packetLossSampleCount += 1 + packetLossSampleTotal += value + } + } + stats.resolution?.takeIf { parseResolutionPixelsOrNull(it) != null }?.let { lastResolution = it } + stats.codec?.takeIf { it.isNotBlank() }?.let { lastCodec = it } + network?.let(::recordNetwork) + } + + fun recordRecovery(reason: String, settings: StreamSettings) { + recoveryReason = reason.trim().takeIf { it.isNotEmpty() } + finalSettings = settings + } + + fun recordActiveMode(status: ActiveStreamModeStatus) { + activeMode = status + if (status.safeVideoRecoveryActive) { + finalSettings = finalSettings.copy(codec = status.transportCodec) + } + } + + fun finish(finishedAtMs: Long): SessionReport? { + if (sampleCount == 0) return null + val averagePingMs = averageLong(pingTotal, pingCount)?.roundToInt() + val averageBitrateKbps = averageLong(bitrateTotal, bitrateCount)?.roundToInt() + val averageJitterMs = averageDouble(jitterTotal, jitterCount) + val averageFps = averageLong(fpsTotal, fpsCount) + val averageReceivedFps = averageLong(receivedFpsTotal, receivedFpsCount) + val averageDecodedFps = averageLong(decodedFpsTotal, decodedFpsCount) + val averageDecodeMs = averageDouble(decodeTotal, decodeCount) + val packetLossPct = if (hasPacketDeltas && packetsLost + packetsReceived > 0L) { + packetsLost.toDouble() / (packetsLost + packetsReceived).toDouble() * 100.0 + } else { + averageDouble(packetLossSampleTotal, packetLossSampleCount) + } + val networkKind = dominantValue(networkKindCounts, AndroidNetworkKind.Unknown) + val wifiBand = dominantValue(wifiBandCounts, AndroidWifiBand.Unknown) + val estimatedLinkDownstreamKbps = averageLong(linkEstimateTotal, linkEstimateCount)?.roundToInt() + val mode = activeMode + val deliveredResolution = mode?.displayedResolution ?: lastResolution + val deliveredCodec = lastCodec ?: finalSettings.codec.name + val score = sessionQualityScore( + averagePingMs = averagePingMs, + packetLossPct = packetLossPct, + averageJitterMs = averageJitterMs, + averageFps = averageFps, + targetFps = launchProfile.initialSettings.fps, + averageDecodeMs = averageDecodeMs, + ) + val downgrades = buildSessionDowngrades( + launchProfile = launchProfile, + finalSettings = finalSettings, + deliveredResolution = deliveredResolution, + deliveredCodec = deliveredCodec, + activeMode = mode, + recoveryReason = recoveryReason, + ) + val recommendations = buildSessionRecommendations( + averagePingMs = averagePingMs, + packetLossPct = packetLossPct, + averageJitterMs = averageJitterMs, + averageFps = averageFps, + averageDecodeMs = averageDecodeMs, + targetFps = launchProfile.initialSettings.fps, + targetBitrateMbps = launchProfile.initialSettings.maxBitrateMbps, + averageBitrateKbps = averageBitrateKbps, + networkKind = networkKind, + wifiBand = wifiBand, + estimatedLinkDownstreamKbps = estimatedLinkDownstreamKbps, + lowestNetworkBars = lowestNetworkBars, + averageReceivedFps = averageReceivedFps, + averageDecodedFps = averageDecodedFps, + decoderOverloadDetected = decoderOverloadDetected, + ) + return SessionReport( + gameTitle = launchProfile.gameTitle.ifBlank { "Cloud session" }, + score = score, + rating = sessionReportRating(score), + durationSeconds = ((finishedAtMs - startedAtMs).coerceAtLeast(0L) / 1000L) + .coerceAtMost(Int.MAX_VALUE.toLong()) + .toInt(), + sampleCount = sampleCount, + limitedData = sampleCount < MIN_CONFIDENT_SESSION_REPORT_SAMPLES, + averagePingMs = averagePingMs, + peakPingMs = peakPingMs, + averageBitrateKbps = averageBitrateKbps, + peakBitrateKbps = peakBitrateKbps, + packetLossPct = packetLossPct, + averageJitterMs = averageJitterMs, + averageFps = averageFps, + targetFps = launchProfile.initialSettings.fps, + averageDecodeMs = averageDecodeMs, + requestedResolution = streamResolutionLabel(launchProfile.initialSettings), + deliveredResolution = deliveredResolution, + requestedCodec = launchProfile.initialSettings.codec, + deliveredCodec = deliveredCodec, + networkKind = networkKind, + wifiBand = wifiBand, + estimatedLinkDownstreamKbps = estimatedLinkDownstreamKbps, + downgrades = downgrades, + recommendations = recommendations, + ) + } + + private fun recordNetwork(network: AndroidRuntimeDiagnosticsSnapshot) { + networkKindCounts[network.networkKind] = (networkKindCounts[network.networkKind] ?: 0) + 1 + if (network.networkKind == AndroidNetworkKind.Wifi) { + wifiBandCounts[network.wifiBand] = (wifiBandCounts[network.wifiBand] ?: 0) + 1 + } + network.networkDownstreamKbps?.takeIf { it > 0 }?.let { value -> + linkEstimateCount += 1 + linkEstimateTotal += value + } + network.networkSignalBars?.let { value -> + lowestNetworkBars = minOf(lowestNetworkBars ?: value, value) + } + } +} + +internal fun sessionQualityScore( + averagePingMs: Int?, + packetLossPct: Double?, + averageJitterMs: Double?, + averageFps: Double?, + targetFps: Int, + averageDecodeMs: Double?, +): Int { + val components = buildList { + averagePingMs?.let { add(weightedScore(latencyScore(it), 35)) } + packetLossPct?.let { add(weightedScore(packetLossScore(it), 30)) } + averageJitterMs?.let { add(weightedScore(jitterScore(it), 15)) } + averageFps?.let { add(weightedScore(frameRateScore(it, targetFps), 15)) } + averageDecodeMs?.let { add(weightedScore(decodeScore(it, targetFps, averageFps), 5)) } + } + if (components.isEmpty()) return 50 + val weightedTotal = components.sumOf { it.first } + val availableWeight = components.sumOf { it.second } + return (weightedTotal / availableWeight.toDouble()).roundToInt().coerceIn(0, 100) +} + +internal fun sessionReportRating(score: Int): SessionReportRating = when { + score >= 90 -> SessionReportRating.Excellent + score >= 75 -> SessionReportRating.Good + score >= 60 -> SessionReportRating.Fair + else -> SessionReportRating.Poor +} + +/** + * A three-step reading of any single metric, coarse enough to drive a colour. + * + * The in-stream stats pill used to carry its own inline thresholds (ping >= 100 red, >= 50 orange; + * loss > 1.0 red) which disagreed with the ladders below — the pill would call a session bad while + * the report that followed it called the same session Good. There is now one opinion, expressed + * once, here. + */ +enum class StreamQualityLevel { Good, Fair, Poor } + +internal fun qualityLevelOf(score: Int): StreamQualityLevel = when { + score >= 85 -> StreamQualityLevel.Good + score >= 55 -> StreamQualityLevel.Fair + else -> StreamQualityLevel.Poor +} + +/** Per-metric quality readings, derived from the same ladders the session score is built from. */ +object StreamQuality { + fun latency(ms: Int): StreamQualityLevel = qualityLevelOf(latencyScore(ms)) + fun packetLoss(pct: Double): StreamQualityLevel = qualityLevelOf(packetLossScore(pct)) + fun jitter(ms: Double): StreamQualityLevel = qualityLevelOf(jitterScore(ms)) + fun decode(ms: Double, targetFps: Int, actualFps: Double? = null): StreamQualityLevel = + qualityLevelOf(decodeScore(ms, targetFps, actualFps)) + fun frameRate(fps: Double, targetFps: Int): StreamQualityLevel = qualityLevelOf(frameRateScore(fps, targetFps)) +} + +internal fun latencyScore(value: Int): Int = when { + value <= 30 -> 100 + value <= 50 -> 92 + value <= 80 -> 80 + value <= 120 -> 60 + value <= 180 -> 35 + else -> 10 +} + +internal fun packetLossScore(value: Double): Int = when { + value <= 0.1 -> 100 + value <= 0.5 -> 90 + value <= 1.0 -> 75 + value <= 2.0 -> 55 + value <= 5.0 -> 25 + else -> 5 +} + +internal fun jitterScore(value: Double): Int = when { + value <= 5.0 -> 100 + value <= 10.0 -> 90 + value <= 20.0 -> 70 + value <= 30.0 -> 50 + value <= 50.0 -> 25 + else -> 5 +} + +internal fun frameRateScore(value: Double, targetFps: Int): Int { + val ratio = value / targetFps.coerceAtLeast(1).toDouble() + return when { + ratio >= 0.98 -> 100 + ratio >= 0.95 -> 95 + ratio >= 0.90 -> 82 + ratio >= 0.80 -> 60 + ratio >= 0.65 -> 35 + else -> 10 + } +} + +internal fun decodeScore(value: Double, targetFps: Int, actualFps: Double? = null): Int { + val frameBudgetMs = 1000.0 / targetFps.coerceAtLeast(1).toDouble() + val ratio = value / frameBudgetMs + val latencyScore = when { + ratio <= 0.50 -> 100 + ratio <= 0.75 -> 90 + ratio <= 1.00 -> 75 + ratio <= 1.50 -> 45 + else -> 15 + } + val throughputRatio = actualFps?.div(targetFps.coerceAtLeast(1).toDouble()) ?: return latencyScore + // totalDecodeTime is input-to-output latency, not serial decoder throughput. Hardware decoders + // can pipeline frames, so latency may exceed one display interval while cadence stays healthy. + return when { + throughputRatio >= 0.98 -> maxOf(latencyScore, 75) + throughputRatio >= 0.90 -> maxOf(latencyScore, 55) + else -> latencyScore + } +} + +private fun buildSessionDowngrades( + launchProfile: StreamReportLaunchProfile, + finalSettings: StreamSettings, + deliveredResolution: String?, + deliveredCodec: String?, + activeMode: ActiveStreamModeStatus?, + recoveryReason: String?, +): List = buildList { + val selected = launchProfile.selectedSettings + val eligible = launchProfile.eligibleSettings + val initial = launchProfile.initialSettings + if ( + selected.resolution != eligible.resolution || + selected.fps != eligible.fps || + selected.hdrEnabled != eligible.hdrEnabled + ) { + add( + SessionReportFinding( + title = "Account or session limit", + detail = "Your saved ${profileSummary(selected)} profile was limited to ${profileSummary(eligible)} before launch based on the features available to this session.", + kind = SessionReportFindingKind.Warning, + ), + ) + } + if (selected.codec != eligible.codec || selected.colorQuality != eligible.colorQuality) { + add( + SessionReportFinding( + title = "Android format compatibility", + detail = "The selected ${selected.codec.name}/${selected.colorQuality.name} format was normalized to ${eligible.codec.name}/${eligible.colorQuality.name} so Android and WebRTC could decode it reliably.", + kind = SessionReportFindingKind.Warning, + ), + ) + } + if (!eligible.hasSameSessionReportProfile(initial)) { + add( + SessionReportFinding( + title = "Device compatibility adjustment", + detail = "The device probe changed ${profileSummary(eligible)} to ${profileSummary(initial)} to stay within the detected decoder and performance limits.", + kind = SessionReportFindingKind.Warning, + ), + ) + } + if (recoveryReason != null || !finalSettings.hasSameSessionReportProfile(initial)) { + add( + SessionReportFinding( + title = "Safe video recovery", + detail = buildString { + append("OpenNOW changed the live transport from ${profileSummary(initial)} to ${profileSummary(finalSettings)} to keep the session connected") + recoveryReason?.let { append(". Reason: ${it.trimEnd('.')}.") } ?: append(".") + }, + kind = SessionReportFindingKind.Warning, + ), + ) + } + val normalizedDeliveredResolution = deliveredResolution?.let(::normalizeResolutionLabel) + val initialResolution = normalizeResolutionLabel(streamResolutionLabel(initial)) + if ( + normalizedDeliveredResolution != null && + normalizedDeliveredResolution != initialResolution && + none { it.title == "Safe video recovery" && finalSettings.resolution != initial.resolution } + ) { + val source = when (activeMode?.resolutionSource) { + StreamResolutionChangeSource.ServerNegotiatedFallback -> "The cloud server negotiated" + StreamResolutionChangeSource.ProviderOrGameModeChange -> "The provider or game switched to" + null -> "The delivered stream used" + } + add( + SessionReportFinding( + title = "Delivered resolution changed", + detail = "$source $normalizedDeliveredResolution instead of the requested $initialResolution. This reflects the cloud/game runtime mode, not a silent change to your saved setting.", + kind = SessionReportFindingKind.Warning, + ), + ) + } + val deliveredCodecName = deliveredCodec?.substringAfterLast('/')?.uppercase(java.util.Locale.US) + if ( + deliveredCodecName != null && + !deliveredCodecName.contains(finalSettings.codec.name) && + recoveryReason == null + ) { + add( + SessionReportFinding( + title = "Delivered codec changed", + detail = "WebRTC reported $deliveredCodec instead of the requested ${finalSettings.codec.name}. The negotiated transport codec determines what the device actually decoded.", + kind = SessionReportFindingKind.Warning, + ), + ) + } +} + +internal fun buildSessionRecommendations( + averagePingMs: Int?, + packetLossPct: Double?, + averageJitterMs: Double?, + averageFps: Double?, + averageDecodeMs: Double?, + targetFps: Int, + targetBitrateMbps: Int, + averageBitrateKbps: Int?, + networkKind: AndroidNetworkKind, + wifiBand: AndroidWifiBand, + estimatedLinkDownstreamKbps: Int?, + lowestNetworkBars: Int?, + averageReceivedFps: Double? = null, + averageDecodedFps: Double? = null, + decoderOverloadDetected: Boolean = false, +): List = buildList { + when { + networkKind == AndroidNetworkKind.Wifi && wifiBand == AndroidWifiBand.TwoPointFourGhz -> add( + SessionReportFinding( + title = "Use 5 GHz or 6 GHz Wi-Fi", + detail = "This session used 2.4 GHz Wi-Fi, which is usually busier and more prone to interference. Use 5/6 GHz when you are near the router; Ethernet is the most consistent option.", + kind = SessionReportFindingKind.Warning, + ), + ) + networkKind == AndroidNetworkKind.Wifi && + wifiBand in setOf(AndroidWifiBand.FiveGhz, AndroidWifiBand.SixGhz) && + lowestNetworkBars != null && lowestNetworkBars <= 2 -> add( + SessionReportFinding( + title = "Move closer to the Wi-Fi access point", + detail = "5/6 GHz can provide lower latency and more capacity, but its range is shorter. The session saw a weak signal, so reducing walls and distance may help.", + kind = SessionReportFindingKind.Warning, + ), + ) + networkKind == AndroidNetworkKind.Wifi && wifiBand == AndroidWifiBand.Unknown && + ((averagePingMs ?: 0) > 60 || (packetLossPct ?: 0.0) > 0.5) -> add( + SessionReportFinding( + title = "Check your Wi-Fi band", + detail = "Android did not expose the current band. When you are near the router, prefer 5 GHz or 6 GHz over 2.4 GHz; use Ethernet for the most predictable latency.", + kind = SessionReportFindingKind.Warning, + ), + ) + networkKind == AndroidNetworkKind.Cellular -> add( + SessionReportFinding( + title = "Prefer Wi-Fi or Ethernet", + detail = "Cellular latency and capacity can change quickly as signal and tower load vary. Stable 5/6 GHz Wi-Fi or Ethernet is usually better for cloud gaming.", + kind = SessionReportFindingKind.Warning, + ), + ) + } + if ((packetLossPct ?: 0.0) > 1.0) { + add( + SessionReportFinding( + title = "Reduce packet loss", + detail = "Packet loss above 1% can cause blur, stutter, or recovery events. Pause competing uploads, reduce wireless interference, or try Ethernet.", + kind = SessionReportFindingKind.Warning, + ), + ) + } + if ((averagePingMs ?: 0) > 80 || (averageJitterMs ?: 0.0) > 20.0) { + add( + SessionReportFinding( + title = "Stabilize latency", + detail = "Choose the closest available server, disable VPN routing, and pause background downloads. Consistent latency matters as much as raw download speed.", + kind = SessionReportFindingKind.Warning, + ), + ) + } + if ( + estimatedLinkDownstreamKbps != null && + estimatedLinkDownstreamKbps < targetBitrateMbps * STREAM_NETWORK_HEADROOM_KBPS_PER_MBPS + ) { + val actual = averageBitrateKbps?.let { " The stream averaged ${formatMbps(it)} Mbps." }.orEmpty() + add( + SessionReportFinding( + title = "Lower the maximum bitrate", + detail = "Android estimated about ${formatMbps(estimatedLinkDownstreamKbps)} Mbps of link capacity for a $targetBitrateMbps Mbps profile.$actual Leave headroom for network variation.", + kind = SessionReportFindingKind.Warning, + ), + ) + } + val frameBudgetMs = 1000.0 / targetFps.coerceAtLeast(1) + val averageDecoderDeficit = averageReceivedFps != null && + averageDecodedFps != null && + averageReceivedFps >= targetFps * 0.85 && + averageDecodedFps <= averageReceivedFps * 0.80 + if ( + decoderOverloadDetected || + averageDecoderDeficit || + ( + (averageFps != null && averageFps < targetFps * 0.85) && + (averageDecodeMs ?: 0.0) > frameBudgetMs * 0.85 + ) + ) { + add( + SessionReportFinding( + title = "Decoder could not keep up", + detail = buildString { + append("OpenNOW detected a sustained local decoder bottleneck") + if (averageReceivedFps != null && averageDecodedFps != null) { + append( + ": the stream delivered ${"%.1f".format(java.util.Locale.US, averageReceivedFps)} FPS " + + "while the decoder produced ${"%.1f".format(java.util.Locale.US, averageDecodedFps)} FPS", + ) + } + append(". Use H264 or the Recommended profile; this is device decode load, not the cloud game's frame rate.") + }, + kind = SessionReportFindingKind.Warning, + ), + ) + } + if (isEmpty()) { + add( + SessionReportFinding( + title = "Connection looked healthy", + detail = "No specific network or decoder issue crossed the report thresholds. Keep the same server and network setup for similarly consistent sessions.", + ), + ) + } +}.sortedBy { finding -> + if (finding.title == "Decoder could not keep up") 0 else 1 +}.take(MAX_SESSION_REPORT_RECOMMENDATIONS) + +private const val SESSION_REPORT_DECODER_OVERLOAD_SAMPLES = 3 + +private fun StreamRuntimeStats.hasSessionReportValues(): Boolean = + pingMs != null || + bitrateKbps != null || + fps != null || + decodeMs != null || + jitterMs != null || + packetLossPct != null + +private fun weightedScore(score: Int, weight: Int): Pair = score * weight.toDouble() to weight + +private fun averageLong(total: Long, count: Int): Double? = + if (count > 0) total.toDouble() / count.toDouble() else null + +private fun averageDouble(total: Double, count: Int): Double? = + if (count > 0) total / count.toDouble() else null + +private fun dominantValue(counts: Map, fallback: T): T = + counts.maxByOrNull { it.value }?.key ?: fallback + +private fun streamResolutionLabel(settings: StreamSettings): String { + val pixels = streamResolutionPixels(settings) + return "${pixels.first}x${pixels.second}" +} + +private fun normalizeResolutionLabel(value: String): String = + parseResolutionPixelsOrNull(value)?.let { "${it.first}x${it.second}" } ?: value + +private fun profileSummary(settings: StreamSettings): String = + buildString { + append("${streamResolutionLabel(settings)}@${settings.fps} ${settings.codec.name}/${settings.colorQuality.name}") + append(" ${settings.maxBitrateMbps} Mbps") + if (settings.hdrEnabled) append(" HDR") + } + +private fun StreamSettings.hasSameSessionReportProfile(other: StreamSettings): Boolean = + resolution == other.resolution && + aspectRatio == other.aspectRatio && + fps == other.fps && + maxBitrateMbps == other.maxBitrateMbps && + codec == other.codec && + colorQuality == other.colorQuality && + hdrEnabled == other.hdrEnabled + +private fun formatMbps(kbps: Int): String = + "%.1f".format(java.util.Locale.US, kbps.coerceAtLeast(0) / 1000.0) + +private const val MIN_CONFIDENT_SESSION_REPORT_SAMPLES = 10 +private const val MAX_SESSION_REPORT_RECOMMENDATIONS = 4 +private const val STREAM_NETWORK_HEADROOM_KBPS_PER_MBPS = 1_200 diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/SessionTimerAnchorStore.kt b/android/app/src/main/java/com/opencloudgaming/opennow/SessionTimerAnchorStore.kt new file mode 100644 index 000000000..60442bfd9 --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/SessionTimerAnchorStore.kt @@ -0,0 +1,61 @@ +package com.opencloudgaming.opennow + +import android.content.Context + +private const val SESSION_TIMER_STORE_NAME = "opennow_session_timer" +private const val KEY_SESSION_ID = "session_id" +private const val KEY_STARTED_AT_MS = "started_at_ms" + +internal fun resolveSessionTimerStartedAtMs( + sessionId: String, + persistedSessionId: String?, + persistedStartedAtMs: Long, + preferredStartedAtMs: Long?, + nowMs: Long, +): Long { + val persistedIsValid = + persistedSessionId == sessionId && persistedStartedAtMs > 0L && persistedStartedAtMs <= nowMs + if (persistedIsValid) return persistedStartedAtMs + + return preferredStartedAtMs + ?.takeIf { it > 0L && it <= nowMs } + ?: nowMs +} + +internal class SessionTimerAnchorStore(context: Context) { + private val prefs = context.applicationContext.getSharedPreferences( + SESSION_TIMER_STORE_NAME, + Context.MODE_PRIVATE, + ) + private val lock = Any() + + fun startedAtMsFor( + sessionId: String, + preferredStartedAtMs: Long? = null, + nowMs: Long = System.currentTimeMillis(), + ): Long = synchronized(lock) { + val startedAtMs = resolveSessionTimerStartedAtMs( + sessionId = sessionId, + persistedSessionId = prefs.getString(KEY_SESSION_ID, null), + persistedStartedAtMs = prefs.getLong(KEY_STARTED_AT_MS, 0L), + preferredStartedAtMs = preferredStartedAtMs, + nowMs = nowMs, + ) + prefs.edit() + .putString(KEY_SESSION_ID, sessionId) + .putLong(KEY_STARTED_AT_MS, startedAtMs) + .commit() + startedAtMs + } + + fun clear(sessionId: String) { + synchronized(lock) { + if (prefs.getString(KEY_SESSION_ID, null) == sessionId) { + prefs.edit() + .remove(KEY_SESSION_ID) + .remove(KEY_STARTED_AT_MS) + .commit() + } + } + } +} diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/StreamCodec.kt b/android/app/src/main/java/com/opencloudgaming/opennow/StreamCodec.kt new file mode 100644 index 000000000..335587701 --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/StreamCodec.kt @@ -0,0 +1,635 @@ +package com.opencloudgaming.opennow + +import android.app.ActivityManager +import android.content.Context +import android.content.res.Configuration +import android.media.MediaCodecInfo +import android.media.MediaCodecList +import android.os.Build +import kotlinx.coroutines.launch +import kotlinx.serialization.json.JsonObject +import org.webrtc.DefaultVideoDecoderFactory +import org.webrtc.EglBase +import org.webrtc.HardwareVideoDecoderFactory +import org.webrtc.IceCandidate +import org.webrtc.PeerConnection +import org.webrtc.PeerConnectionFactory +import org.webrtc.Predicate +import org.webrtc.RtpCapabilities +import org.webrtc.VideoCodecInfo +import org.webrtc.VideoDecoder +import org.webrtc.VideoDecoderFactory +import java.util.Locale +import java.util.concurrent.ConcurrentHashMap +import kotlin.math.max + +object NativeCodecProbe { + init { + runCatching { System.loadLibrary("opennow_native") } + } + + external fun nativeRuntimeSummary(): String + external fun nativeDecoderAvailable(mimeType: String): Boolean +} + +internal object WebRtcRuntime { + @Volatile + private var initialized = false + + fun ensureInitialized(context: Context) { + if (initialized) return + synchronized(this) { + if (initialized) return + PeerConnectionFactory.initialize( + PeerConnectionFactory.InitializationOptions.builder(context.applicationContext) + .setEnableInternalTracer(false) + .createInitializationOptions(), + ) + initialized = true + } + } +} + +object CodecProbe { + private data class DecoderLimits( + val maxSupportedWidth: Int?, + val maxSupportedHeight: Int?, + ) + + fun report(context: Context): RuntimeCodecReport { + WebRtcRuntime.ensureInitialized(context) + val isTv = isAndroidTvProfile(context) + val renderer = listOf(Build.HARDWARE, Build.BOARD, Build.DEVICE, Build.MODEL, Build.MANUFACTURER) + .joinToString(" ") + .lowercase(Locale.US) + val memoryInfo = ActivityManager.MemoryInfo() + val totalMemoryBytes = runCatching { + (context.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager) + ?.getMemoryInfo(memoryInfo) + memoryInfo.totalMem.takeIf { it > 0L } + }.getOrNull() + val is64BitRuntime = android.os.Process.is64Bit() + val constrainedRuntime = isConstrainedStreamingRuntime( + androidTvProfile = isTv, + is64BitRuntime = is64BitRuntime, + totalMemoryBytes = totalMemoryBytes, + ) + val lowPower = isLowPowerStreamingProfile( + androidTvProfile = isTv, + renderer = renderer, + totalMemoryBytes = totalMemoryBytes, + is64BitRuntime = is64BitRuntime, + ) + val webRtcDecoders = probeWebRtcDecoders() + val capabilities = VideoCodec.entries.map { codec -> + val mime = codec.mimeType() + val decoders = codecInfos(mime, encoder = false) + val encoders = codecInfos(mime, encoder = true) + val webRtc = webRtcDecoders[codec] + val nativeDecoderAvailable = runCatching { NativeCodecProbe.nativeDecoderAvailable(mime) }.getOrNull() + val preferredDecoder = decoders.firstOrNull(::isHardwareCodec) ?: decoders.firstOrNull() + val preferredEncoder = encoders.firstOrNull(::isHardwareCodec) ?: encoders.firstOrNull() + val decoderLimits = decoderLimits(mime, decoders) + CodecCapability( + codec = codec, + decoderAvailable = decoders.isNotEmpty(), + encoderAvailable = encoders.isNotEmpty(), + hardwareDecoder = decoders.any(::isHardwareCodec), + hardwareEncoder = encoders.any(::isHardwareCodec), + decoderName = preferredDecoder?.name, + encoderName = preferredEncoder?.name, + realtimeSafe = decoders.any { isRealtimeSafeDecoder(codec, it) }, + nativeDecoderAvailable = nativeDecoderAvailable, + webRtcDecoderAvailable = webRtc?.decoderAvailable, + webRtcHardwareDecoderAvailable = webRtc?.hardwareDecoderAvailable, + webRtcDecoderName = webRtc?.decoderName, + webRtcCodecProfiles = webRtc?.profiles.orEmpty(), + maxSupportedWidth = decoderLimits.maxSupportedWidth, + maxSupportedHeight = decoderLimits.maxSupportedHeight, + ) + } + return RuntimeCodecReport( + capabilities = capabilities, + nativeRuntimeSummary = runCatching { NativeCodecProbe.nativeRuntimeSummary() }.getOrElse { "{\"nativeLibrary\":\"unavailable\"}" }, + androidTvProfile = isTv, + lowPowerGpuProfile = lowPower, + constrainedRuntimeProfile = constrainedRuntime, + ).also { report -> + NativeInputDiagnostics.add( + "codec probe device=${Build.MANUFACTURER}/${Build.MODEL} hardware=${Build.HARDWARE} tv=$isTv lowPower=$lowPower " + + "constrained=$constrainedRuntime runtimeBits=${if (is64BitRuntime) 64 else 32} " + + "memoryMiB=${totalMemoryBytes?.div(BYTES_PER_MEBIBYTE) ?: 0L}", + ) + report.capabilities.forEach { capability -> + NativeInputDiagnostics.add( + "codec probe codec=${capability.codec} platform=${capability.decoderName ?: "none"} " + + "platformHw=${capability.hardwareDecoder} native=${capability.nativeDecoderAvailable} " + + "webrtc=${capability.webRtcDecoderName ?: "none"} webrtcHw=${capability.webRtcHardwareDecoderAvailable} " + + "profiles=${capability.webRtcCodecProfiles.joinToString("|").ifBlank { "none" }} " + + "max=${capability.maxSupportedWidth ?: 0}x${capability.maxSupportedHeight ?: 0} " + + "launch=${capability.streamingDecoderUsableForLaunch()}", + ) + } + } + } + + private fun decoderLimits(mime: String, decoders: List): DecoderLimits { + val candidates = decoders.filter(::isHardwareCodec).ifEmpty { decoders } + if (candidates.isEmpty()) return DecoderLimits(null, null) + val knownResolutions = streamAspectRatioOptions() + .flatMap(::streamResolutionOptionsForAspect) + .distinct() + val supportedPixels = mutableListOf>() + for (resolution in knownResolutions) { + val (width, height) = parseResolutionPixelsOrNull(resolution) ?: continue + val sizeSupported = candidates.any { decoder -> + runCatching { + decoder.getCapabilitiesForType(mime).videoCapabilities?.isSizeSupported(width, height) == true + }.getOrDefault(false) + } + if (sizeSupported) supportedPixels += width to height + } + return DecoderLimits( + maxSupportedWidth = supportedPixels.maxOfOrNull { it.first }, + maxSupportedHeight = supportedPixels.maxOfOrNull { it.second }, + ) + } + + private fun codecInfos(mime: String, encoder: Boolean): List { + val list = if (Build.VERSION.SDK_INT >= 21) { + MediaCodecList(MediaCodecList.ALL_CODECS).codecInfos.toList() + } else { + emptyList() + } + return list.filter { info -> + info.isEncoder == encoder && info.supportedTypes.any { it.equals(mime, ignoreCase = true) } + } + } + + private data class WebRtcCodecProbe( + val decoderAvailable: Boolean, + val hardwareDecoderAvailable: Boolean, + val decoderName: String?, + val profiles: List, + ) + + private fun probeWebRtcDecoders(): Map { + val eglBase = runCatching { EglBase.create() }.getOrNull() ?: return emptyMap() + return try { + val streamingFactory = OpenNowVideoDecoderFactory(eglBase.eglBaseContext) + val hardwareFactory = openNowHardwareVideoDecoderFactory(eglBase.eglBaseContext) + val streamingSupported = streamingFactory.supportedCodecsByVideoCodec() + val hardwareSupported = hardwareFactory.supportedCodecsByVideoCodec() + VideoCodec.entries.associateWith { codec -> + val defaultInfos = streamingSupported[codec].orEmpty() + val hardwareInfos = hardwareSupported[codec].orEmpty() + val decoderName = streamingFactory.firstDecoderName(defaultInfos) + WebRtcCodecProbe( + decoderAvailable = decoderName != null, + hardwareDecoderAvailable = hardwareFactory.firstDecoderName(hardwareInfos) != null, + decoderName = decoderName, + profiles = defaultInfos.map(::formatWebRtcCodecInfo).distinct(), + ) + } + } catch (_: Throwable) { + emptyMap() + } finally { + eglBase.release() + } + } + + private fun VideoDecoderFactory.supportedCodecsByVideoCodec(): Map> = + getSupportedCodecs() + .groupBy { info -> info.name.toVideoCodec() } + .mapNotNull { (codec, infos) -> codec?.let { it to infos } } + .toMap() + + private fun VideoDecoderFactory.firstDecoderName(infos: List): String? { + for (info in infos) { + val decoder = runCatching { createDecoder(info) }.getOrNull() ?: continue + return try { + decoder.getImplementationName() + } finally { + runCatching { decoder.release() } + } + } + return null + } + + private fun String.toVideoCodec(): VideoCodec? = + when (uppercase(Locale.US)) { + "AVC", "H264", "H.264" -> VideoCodec.H264 + "HEVC", "H265", "H.265" -> VideoCodec.H265 + "AV01", "AV1" -> VideoCodec.AV1 + else -> null + } + + private fun formatWebRtcCodecInfo(info: VideoCodecInfo): String { + val profile = info.params["profile-level-id"] + val packetization = info.params["packetization-mode"] + return listOfNotNull(info.name.toVideoCodec()?.name ?: info.name, profile?.let { "profile=$it" }, packetization?.let { "packet=$it" }) + .joinToString(" ") + } + + private fun isHardwareCodec(info: MediaCodecInfo): Boolean { + val name = info.name.lowercase(Locale.US) + if (name.contains("google") || name.contains("sw") || name.contains("software")) return false + return if (Build.VERSION.SDK_INT >= 29) { + info.isHardwareAccelerated + } else { + true + } + } + + internal fun isOpenNowHardwareDecoderAllowed(info: MediaCodecInfo): Boolean { + if (!isHardwareCodec(info)) return false + val name = info.name.lowercase(Locale.US) + if (name.contains("google") || name.contains("software") || name.contains("sw")) return false + if (name.contains("exynos")) { + val hevcProfiles = runCatching { + info.getCapabilitiesForType(HEVC_MIME_TYPE) + .profileLevels + .map { it.profile } + }.getOrDefault(emptyList()) + return isSupportedExynosHevcDecoder( + codecName = info.name, + sdkInt = Build.VERSION.SDK_INT, + supportedTypes = info.supportedTypes.toList(), + hevcProfiles = hevcProfiles, + ) + } + return true + } + + private fun isRealtimeSafeDecoder(codec: VideoCodec, info: MediaCodecInfo): Boolean { + if (!isHardwareCodec(info)) return false + val name = info.name.lowercase(Locale.US) + return when (codec) { + VideoCodec.H264 -> true + VideoCodec.H265 -> !name.contains("exynos") || isOpenNowHardwareDecoderAllowed(info) + VideoCodec.AV1 -> !name.contains("google") + } + } + + private fun VideoCodec.mimeType(): String = + when (this) { + VideoCodec.H264 -> "video/avc" + VideoCodec.H265 -> "video/hevc" + VideoCodec.AV1 -> "video/av01" + } +} + +internal fun isSupportedExynosHevcDecoder( + codecName: String, + sdkInt: Int, + supportedTypes: Collection, + hevcProfiles: Collection, +): Boolean { + if (!codecName.contains("exynos", ignoreCase = true)) return false + if (sdkInt < MIN_EXYNOS_HEVC_SDK) return false + if (supportedTypes.none { it.equals(HEVC_MIME_TYPE, ignoreCase = true) }) return false + return hevcProfiles.any(SUPPORTED_HEVC_STREAM_PROFILES::contains) +} + +private const val MIN_EXYNOS_HEVC_SDK = 36 +private const val HEVC_MIME_TYPE = "video/hevc" +private val SUPPORTED_HEVC_STREAM_PROFILES = setOf( + MediaCodecInfo.CodecProfileLevel.HEVCProfileMain, + MediaCodecInfo.CodecProfileLevel.HEVCProfileMain10, + MediaCodecInfo.CodecProfileLevel.HEVCProfileMain10HDR10, + MediaCodecInfo.CodecProfileLevel.HEVCProfileMain10HDR10Plus, +) + +internal fun isAndroidTvProfile(context: Context): Boolean = + context.packageManager.hasSystemFeature("android.software.leanback") || + context.resources.configuration.uiMode and Configuration.UI_MODE_TYPE_MASK == Configuration.UI_MODE_TYPE_TELEVISION + +internal fun isLowPowerStreamingProfile( + androidTvProfile: Boolean, + renderer: String, + totalMemoryBytes: Long?, + is64BitRuntime: Boolean = true, +): Boolean { + val normalizedRenderer = renderer.lowercase(Locale.US) + val knownLowPowerGpu = + normalizedRenderer.contains("powervr") || + normalizedRenderer.contains("ge8320") || + normalizedRenderer.contains("ge83") + return knownLowPowerGpu || isConstrainedStreamingRuntime( + androidTvProfile = androidTvProfile, + is64BitRuntime = is64BitRuntime, + totalMemoryBytes = totalMemoryBytes, + ) +} + +internal fun isConstrainedStreamingRuntime( + androidTvProfile: Boolean, + is64BitRuntime: Boolean, + totalMemoryBytes: Long?, +): Boolean { + val constrainedTvMemory = androidTvProfile && + totalMemoryBytes != null && + totalMemoryBytes in 1..LOW_POWER_TV_MEMORY_LIMIT_BYTES + return !is64BitRuntime || constrainedTvMemory +} + +private fun openNowHardwareVideoDecoderFactory(sharedContext: EglBase.Context): VideoDecoderFactory = + HardwareVideoDecoderFactory( + sharedContext, + Predicate { info -> CodecProbe.isOpenNowHardwareDecoderAllowed(info) }, + ) + +internal class OpenNowVideoDecoderFactory( + sharedContext: EglBase.Context, + private val nativeLowLatencyDecoderEnabled: Boolean = false, + private val requestedFps: () -> Int = { 60 }, +) : VideoDecoderFactory { + private val defaultFactory = DefaultVideoDecoderFactory(sharedContext) + private val hardwareFactory = openNowHardwareVideoDecoderFactory(sharedContext) + + override fun createDecoder(info: VideoCodecInfo): VideoDecoder? { + val codec = info.name.toOpenNowVideoCodec() + val hardwareDecoder = if (codec != null) hardwareFactory.createDecoder(info) else null + val decoder = when (codec) { + VideoCodec.H264 -> hardwareDecoder ?: defaultFactory.createDecoder(info) + VideoCodec.H265, + VideoCodec.AV1, + -> hardwareDecoder + null -> defaultFactory.createDecoder(info) + } + val exactRequestedFps = requestedFps().coerceAtLeast(1) + val hardwareDecoderImplementation = hardwareDecoder?.getImplementationName() + val standardLowLatencyAdvertised = codec?.let { selectedCodec -> + supportsStandardLowLatencyDecoder( + codecName = hardwareDecoderImplementation, + mimeType = selectedCodec.mediaMimeType(), + ) + } == true + val standardLowLatencyEnabled = shouldEnableMediaTekStandardLowLatency( + decoderImplementationName = hardwareDecoderImplementation, + requestedFps = exactRequestedFps, + featureAdvertised = standardLowLatencyAdvertised, + ) + val bypassDecoderPerformanceTuning = shouldBypassMediaCodecPerformanceTuning( + codec = codec, + decoderImplementationName = hardwareDecoderImplementation, + requestedFps = exactRequestedFps, + lowLatencyEnabled = nativeLowLatencyDecoderEnabled, + ) + val tuneDecoderPerformance = + mediaCodecPerformanceTargetFps(exactRequestedFps) != null && !bypassDecoderPerformanceTuning + val tuneSelectedDecoder = shouldUseMediaCodecDecoderTuning( + selectedDecoder = decoder, + approvedHardwareDecoder = hardwareDecoder, + requestedFps = exactRequestedFps, + lowLatencyEnabled = nativeLowLatencyDecoderEnabled, + codec = codec, + decoderImplementationName = hardwareDecoderImplementation, + ) + if (codec != null && hardwareDecoder != null) { + NativeInputDiagnostics.add( + "native MediaCodec decoder selected codec=${codec.name} " + + "implementation=$hardwareDecoderImplementation requestedFps=$exactRequestedFps " + + "performanceTuning=$tuneDecoderPerformance lowLatency=$nativeLowLatencyDecoderEnabled " + + "standardLowLatencyAdvertised=$standardLowLatencyAdvertised " + + "standardLowLatency=$standardLowLatencyEnabled " + + "qualcommH264Guard=$bypassDecoderPerformanceTuning", + ) + } else if (codec != null && decoder != null && (nativeLowLatencyDecoderEnabled || tuneDecoderPerformance)) { + NativeInputDiagnostics.add( + "MediaCodec tuning skipped codec=${codec.name} decoder=${decoder.javaClass.name} " + + "reason=non-approved-hardware-decoder", + ) + } + return if (decoder != null && tuneSelectedDecoder) { + LowLatencyVideoDecoder( + delegate = decoder, + requestedFps = exactRequestedFps, + lowLatencyEnabled = nativeLowLatencyDecoderEnabled, + standardLowLatencyEnabled = standardLowLatencyEnabled, + ) + } else { + decoder + } + } + + override fun getSupportedCodecs(): Array { + val defaultCodecs = defaultFactory.getSupportedCodecs() + .filterNot { it.name.toOpenNowVideoCodec() in ADVANCED_STREAM_CODECS } + val nativeAdvancedCodecs = hardwareFactory.getSupportedCodecs() + .filter { it.name.toOpenNowVideoCodec() in ADVANCED_STREAM_CODECS } + return (defaultCodecs + nativeAdvancedCodecs) + .distinctBy { it.stableKey() } + .toTypedArray() + } + + private fun VideoCodecInfo.stableKey(): String = + "${name.uppercase(Locale.US)}:${params.toSortedMap()}" + + private companion object { + private val ADVANCED_STREAM_CODECS = setOf(VideoCodec.H265, VideoCodec.AV1) + } +} + +private fun String.toOpenNowVideoCodec(): VideoCodec? = + when (uppercase(Locale.US)) { + "AVC", "H264", "H.264" -> VideoCodec.H264 + "HEVC", "H265", "H.265" -> VideoCodec.H265 + "AV01", "AV1" -> VideoCodec.AV1 + else -> null + } + +private fun VideoCodec.mediaMimeType(): String = when (this) { + VideoCodec.H264 -> "video/avc" + VideoCodec.H265 -> "video/hevc" + VideoCodec.AV1 -> "video/av01" +} + +private fun supportsStandardLowLatencyDecoder(codecName: String?, mimeType: String): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R || codecName.isNullOrBlank()) return false + val cacheKey = "${codecName.lowercase(Locale.US)}|${mimeType.lowercase(Locale.US)}" + return standardLowLatencySupportCache.getOrPut(cacheKey) { + val codecInfo = runCatching { + MediaCodecList(MediaCodecList.ALL_CODECS).codecInfos.firstOrNull { + it.name.equals(codecName, ignoreCase = true) + } + }.getOrNull() ?: return@getOrPut false + runCatching { + codecInfo.getCapabilitiesForType(mimeType) + .isFeatureSupported(MediaCodecInfo.CodecCapabilities.FEATURE_LowLatency) + }.getOrDefault(false) + } +} + +private val standardLowLatencySupportCache = ConcurrentHashMap() + +internal fun isMediaTekMediaCodecDecoder(codecName: String?): Boolean { + val normalized = codecName?.lowercase(Locale.US).orEmpty() + return normalized.contains("mtk") || normalized.contains("mediatek") +} + +internal fun shouldEnableMediaTekStandardLowLatency( + decoderImplementationName: String?, + requestedFps: Int, + featureAdvertised: Boolean, +): Boolean = + requestedFps >= 60 && + featureAdvertised && + isMediaTekMediaCodecDecoder(decoderImplementationName) + +internal fun VideoCodec.webRtcCodecName(): String = + when (this) { + VideoCodec.H264 -> "H264" + VideoCodec.H265 -> "H265" + VideoCodec.AV1 -> "AV1" + } + +internal fun RtpCapabilities.CodecCapability.openNowCodecName(): String? { + val fromMime = mimeType + ?.substringAfter("/", "") + ?.takeIf { it.isNotBlank() } + ?.toOpenNowVideoCodec() + ?.webRtcCodecName() + if (fromMime != null) return fromMime + return name?.toOpenNowVideoCodec()?.webRtcCodecName() ?: name?.uppercase(Locale.US) +} + +internal fun RtpCapabilities.CodecCapability.codecParameterInt(name: String): Int? = + parameters + ?.entries + ?.firstOrNull { it.key.equals(name, ignoreCase = true) } + ?.value + ?.toIntOrNull() + +internal fun RtpCapabilities.CodecCapability.h265ProfilePriority(preferTenBit: Boolean): Int { + val profile = codecParameterInt("profile-id") + return if (preferTenBit) { + when (profile) { + 2 -> 0 + 1 -> 1 + else -> 2 + } + } else { + when (profile) { + 1 -> 0 + null -> 1 + 2 -> 2 + else -> 3 + } + } +} + +internal fun RtpCapabilities.CodecCapability.preferenceKey(): String = + "${openNowCodecName().orEmpty()}:${parameters.orEmpty().toSortedMap()}" + +internal fun StreamSettings.prefersTenBitVideo(): Boolean = + hdrEnabled || + colorQuality == ColorQuality.TenBit420 || + colorQuality == ColorQuality.TenBit444 + +internal val WEBRTC_AUXILIARY_VIDEO_CODECS = setOf("RTX", "RED", "ULPFEC", "FLEXFEC-03") + +internal fun streamDiagnosticId(value: String?): String { + val cleaned = value.orEmpty().trim() + if (cleaned.isBlank()) return "-" + return if (cleaned.length <= 12) cleaned else "${cleaned.take(4)}...${cleaned.takeLast(6)}" +} + +internal fun signalingUrlForDiagnostics(url: String, sessionId: String): String = + redactDiagnosticUrl(url).replace(sessionId, streamDiagnosticId(sessionId)) + +internal enum class SignalingFailureDisposition { + RetryTransport, + RetrySignaling, + RecoverSession, + SessionEnded, +} + +internal fun signalingFailureDisposition(message: String): SignalingFailureDisposition = when { + message.contains("http=410", ignoreCase = true) -> SignalingFailureDisposition.SessionEnded + message.contains("http=404", ignoreCase = true) || + message.contains("Not Found", ignoreCase = true) -> SignalingFailureDisposition.RecoverSession + isTransientSignalingServiceFailure(message) -> SignalingFailureDisposition.RetrySignaling + else -> SignalingFailureDisposition.RetryTransport +} + +internal fun isTransientSignalingServiceFailure(message: String): Boolean = + TRANSIENT_SIGNALING_HTTP_STATUS.containsMatchIn(message) || + message.contains("Service Unavailable", ignoreCase = true) + +internal fun transientSignalingRetryDelayMs(failureCount: Int): Long? = when (failureCount) { + 1 -> 1_000L + 2 -> 2_000L + 3 -> 4_000L + else -> null +} + +internal fun shouldPreserveMediaAfterSignalingFailure( + disposition: SignalingFailureDisposition, + iceState: PeerConnection.IceConnectionState?, +): Boolean { + if ( + disposition != SignalingFailureDisposition.RetryTransport && + disposition != SignalingFailureDisposition.RetrySignaling + ) { + return false + } + return when (iceState) { + PeerConnection.IceConnectionState.CHECKING, + PeerConnection.IceConnectionState.CONNECTED, + PeerConnection.IceConnectionState.COMPLETED, + -> true + else -> false + } +} + +private val TRANSIENT_SIGNALING_HTTP_STATUS = + Regex("""http=(?:429|500|502|503|504)\b""", RegexOption.IGNORE_CASE) + +internal fun signalingHeartbeatReply(message: JsonObject): String? = + if (message["hb"] != null) """{"hb":1}""" else null + +internal fun IceCandidate.diagnosticSummary(): String { + val raw = sdp + val protocol = Regex("""\s(udp|tcp)\s""", RegexOption.IGNORE_CASE) + .find(raw) + ?.value + ?.trim() + ?.lowercase(Locale.US) + ?: "unknown" + val type = Regex("""\styp\s+([a-z0-9]+)""", RegexOption.IGNORE_CASE) + .find(raw) + ?.groupValues + ?.getOrNull(1) + ?.lowercase(Locale.US) + ?: "unknown" + val address = Regex("""candidate:\S+\s+\d+\s+\S+\s+\d+\s+([^\s]+)\s+(\d+)""") + .find(raw) + ?.let { match -> "${match.groupValues[1]}:${match.groupValues[2]}" } + ?: "unknown" + return "mid=${sdpMid.orEmpty()} line=$sdpMLineIndex type=$type protocol=$protocol address=$address raw=${raw.take(240)}" +} + +internal fun sdpDiagnosticSummary(label: String, sdp: String): String { + val lines = sdp.split(Regex("\\r?\\n")).filter { it.isNotBlank() } + val media = lines.filter { it.startsWith("m=") }.joinToString("|").take(180) + val candidateEndpoints = lines + .filter { it.startsWith("a=candidate:") } + .mapNotNull { line -> + Regex("""a=candidate:\S+\s+\d+\s+\S+\s+\d+\s+([^\s]+)\s+(\d+)""") + .find(line) + ?.let { match -> "${match.groupValues[1]}:${match.groupValues[2]}" } + } + .distinct() + .joinToString(limit = 6) + val codecs = lines + .filter { it.startsWith("a=rtpmap:") } + .mapNotNull { line -> line.substringAfter(' ', "").substringBefore('/').takeIf { it.isNotBlank() } } + .distinct() + .take(12) + .joinToString(",") + val candidates = lines.count { it.startsWith("a=candidate:") } + val hasIce = lines.any { it.startsWith("a=ice-ufrag:") } && lines.any { it.startsWith("a=ice-pwd:") } + val hasFingerprint = lines.any { it.startsWith("a=fingerprint:") } + return "$label lines=${lines.size} media=$media codecs=$codecs candidates=$candidates endpoints=$candidateEndpoints ice=$hasIce fingerprint=$hasFingerprint" +} diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/StreamDecoderRecoveryGate.kt b/android/app/src/main/java/com/opencloudgaming/opennow/StreamDecoderRecoveryGate.kt new file mode 100644 index 000000000..7a0432b7a --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/StreamDecoderRecoveryGate.kt @@ -0,0 +1,78 @@ +package com.opencloudgaming.opennow + +/** + * Detects a sustained local decoder bottleneck without confusing it with a slow cloud game or a + * network drop. The transport must keep delivering near the requested frame rate while decoder + * output falls materially behind and consumes more than one frame budget. + */ +internal class StreamDecoderRecoveryGate( + private val badSamplesBeforeRecovery: Int = DEFAULT_BAD_SAMPLES_BEFORE_RECOVERY, + private val minimumReceivedRatio: Double = DEFAULT_MINIMUM_RECEIVED_RATIO, + private val maximumDecodedRatio: Double = DEFAULT_MAXIMUM_DECODED_RATIO, + private val minimumDecodeBudgetRatio: Double = DEFAULT_MINIMUM_DECODE_BUDGET_RATIO, +) { + private var badSamples = 0 + private var recoveryIssued = false + + init { + require(badSamplesBeforeRecovery > 0) + require(minimumReceivedRatio in 0.0..1.0) + require(maximumDecodedRatio in 0.0..1.0) + require(minimumDecodeBudgetRatio > 0.0) + } + + fun reset() { + badSamples = 0 + recoveryIssued = false + } + + fun observe( + stats: StreamRuntimeStats, + requestedFps: Int, + advancedCodecActive: Boolean, + recoveryEligible: Boolean, + ): Boolean { + if (!advancedCodecActive || !recoveryEligible || recoveryIssued) { + badSamples = 0 + return false + } + + val overloaded = isDecoderOverloadSample( + stats = stats, + requestedFps = requestedFps, + minimumReceivedRatio = minimumReceivedRatio, + maximumDecodedRatio = maximumDecodedRatio, + minimumDecodeBudgetRatio = minimumDecodeBudgetRatio, + ) + + badSamples = if (overloaded) badSamples + 1 else 0 + if (badSamples < badSamplesBeforeRecovery) return false + + badSamples = 0 + recoveryIssued = true + return true + } + + private companion object { + const val DEFAULT_BAD_SAMPLES_BEFORE_RECOVERY = 5 + const val DEFAULT_MINIMUM_RECEIVED_RATIO = 0.85 + const val DEFAULT_MAXIMUM_DECODED_RATIO = 0.80 + const val DEFAULT_MINIMUM_DECODE_BUDGET_RATIO = 1.10 + } +} + +internal fun isDecoderOverloadSample( + stats: StreamRuntimeStats, + requestedFps: Int, + minimumReceivedRatio: Double = 0.85, + maximumDecodedRatio: Double = 0.80, + minimumDecodeBudgetRatio: Double = 1.10, +): Boolean { + val receivedFps = stats.receivedFps ?: return false + val decodedFps = stats.decodedFps ?: return false + val decodeMs = stats.decodeMs ?: return false + val frameBudgetMs = 1_000.0 / requestedFps.coerceAtLeast(1) + return receivedFps >= requestedFps * minimumReceivedRatio && + decodedFps <= receivedFps * maximumDecodedRatio && + decodeMs >= frameBudgetMs * minimumDecodeBudgetRatio +} diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/StreamInputEncoder.kt b/android/app/src/main/java/com/opencloudgaming/opennow/StreamInputEncoder.kt new file mode 100644 index 000000000..2a88df369 --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/StreamInputEncoder.kt @@ -0,0 +1,674 @@ +package com.opencloudgaming.opennow + +import android.os.SystemClock +import android.view.KeyEvent +import kotlinx.coroutines.cancel +import org.webrtc.AudioTrack +import org.webrtc.RtpSender +import java.nio.ByteBuffer +import java.nio.ByteOrder + +class InputEncoder { + private var protocolVersion = 3 + private val gamepadSequences = mutableMapOf() + + fun setProtocolVersion(version: Int) { + protocolVersion = version.coerceAtLeast(1) + } + + fun resetGamepadSequences() { + gamepadSequences.clear() + } + + fun encodeHeartbeat(): ByteArray = ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN).putInt(INPUT_HEARTBEAT).array() + + fun encodeKeyDown(key: KeyboardPayload): ByteArray = encodeKey(INPUT_KEY_DOWN, key) + fun encodeKeyUp(key: KeyboardPayload): ByteArray = encodeKey(INPUT_KEY_UP, key) + + fun encodeMouseMove(dx: Int, dy: Int): ByteArray { + val bytes = ByteArray(22) + ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN).putInt(INPUT_MOUSE_REL) + ByteBuffer.wrap(bytes).order(ByteOrder.BIG_ENDIAN) + .putShort(4, dx.coerceIn(-32768, 32767).toShort()) + .putShort(6, dy.coerceIn(-32768, 32767).toShort()) + .putShort(8, 0.toShort()) + .putInt(10, 0) + .putLong(14, timestampUs()) + return wrapMouseMove(bytes) + } + + /** Absolute host cursor position, matching the desktop GFN local-cursor encoder. */ + fun encodeMouseAbsolute(x: Int, y: Int, width: Int, height: Int): ByteArray { + val bytes = ByteArray(26) + ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN).putInt(INPUT_MOUSE_ABS) + ByteBuffer.wrap(bytes).order(ByteOrder.BIG_ENDIAN) + .putShort(4, x.coerceIn(0, 65535).toShort()) + .putShort(6, y.coerceIn(0, 65535).toShort()) + .putShort(8, 0.toShort()) + .putShort(10, width.coerceIn(1, 65535).toShort()) + .putShort(12, height.coerceIn(1, 65535).toShort()) + .putInt(14, 0) + .putLong(18, timestampUs()) + return wrapMouseMove(bytes) + } + + fun encodeMouseButton(type: Int, button: Int): ByteArray { + val bytes = ByteArray(18) + ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN).putInt(type) + bytes[4] = button.coerceIn(1, 5).toByte() + bytes[5] = 0 + ByteBuffer.wrap(bytes).order(ByteOrder.BIG_ENDIAN).putInt(6, 0).putLong(10, timestampUs()) + return wrapSingle(bytes) + } + + fun encodeMouseWheel(delta: Int): ByteArray { + val bytes = ByteArray(22) + ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN).putInt(INPUT_MOUSE_WHEEL) + ByteBuffer.wrap(bytes).order(ByteOrder.BIG_ENDIAN) + .putShort(4, 0.toShort()) + .putShort(6, delta.coerceIn(-32768, 32767).toShort()) + .putShort(8, 0.toShort()) + .putInt(10, 0) + .putLong(14, timestampUs()) + return wrapSingle(bytes) + } + + /** + * A batch of finger updates, one packet per input event. + * + * Layout, taken from the official web client's encoder. Note the opcode is little-endian while + * everything after it is big-endian — the same split every other packet here uses. + * + * ``` + * 0..3 opcode 24 uint32 LE + * 4..5 payload size uint16 BE = 8 + 16 * count + * 6..7 count uint16 BE + * 8+ records, 16 bytes each: + * +0 slot uint8 + * +1 phase uint8 1=down 2=up 4=move 8=cancel + * +2..3 x uint16 BE 0..65535 across the video area + * +4..5 y uint16 BE + * +6 radiusX uint8 + * +7 radiusY uint8 + * +8..15 timestamp int64 BE microseconds + * ``` + * + * Returns null for an empty batch so callers cannot send a header describing nothing. + */ + internal fun encodeTouchBatch(touches: List, nowUs: Long = timestampUs()): ByteArray? { + if (touches.isEmpty()) return null + val count = minOf(touches.size, MAX_TOUCH_RECORDS_PER_BATCH) + val payloadSize = 8 + 16 * count + val bytes = ByteArray(payloadSize) + ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN).putInt(INPUT_TOUCH) + val be = ByteBuffer.wrap(bytes).order(ByteOrder.BIG_ENDIAN) + be.putShort(4, payloadSize.toShort()) + be.putShort(6, count.toShort()) + for (index in 0 until count) { + val touch = touches[index] + val offset = 8 + 16 * index + bytes[offset] = touch.slot.toByte() + bytes[offset + 1] = touch.phase.toByte() + be.putShort(offset + 2, touch.x.coerceIn(0, TOUCH_COORDINATE_MAX).toShort()) + be.putShort(offset + 4, touch.y.coerceIn(0, TOUCH_COORDINATE_MAX).toShort()) + bytes[offset + 6] = touch.radiusX.coerceIn(0, 255).toByte() + bytes[offset + 7] = touch.radiusY.coerceIn(0, 255).toByte() + // 0 means "stamp it here", so the router does not have to reach for the same clock + // every other packet in this encoder uses. + be.putLong(offset + 8, if (touch.timestampUs != 0L) touch.timestampUs else nowUs) + } + return wrapSingle(bytes, nowUs) + } + + fun encodeHapticsEnabled(enabled: Boolean): ByteArray { + val bytes = ByteArray(6) + ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN).putInt(INPUT_HAPTICS_ENABLED) + ByteBuffer.wrap(bytes).order(ByteOrder.BIG_ENDIAN).putShort(4, (if (enabled) 1 else 0).toShort()) + return wrapSingle(bytes) + } + + /** Official GFN SendUnicode framing. These packets are already single-message framed. */ + fun encodeTextInput(text: String): List { + val utf8 = text.toByteArray(Charsets.UTF_8) + val chunks = mutableListOf() + var offset = 0 + while (offset < utf8.size) { + val chunkLength = textInputChunkLength(utf8, offset) + if (chunkLength <= 0) break + val bytes = ByteArray(TEXT_INPUT_HEADER_BYTES + chunkLength) + bytes[0] = 0x22 + ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN).putInt(1, INPUT_TEXT) + utf8.copyInto( + destination = bytes, + destinationOffset = TEXT_INPUT_HEADER_BYTES, + startIndex = offset, + endIndex = offset + chunkLength, + ) + chunks += bytes + offset += chunkLength + } + return chunks + } + + fun encodeGamepadState( + controllerId: Int, + buttons: Int, + leftTrigger: Int, + rightTrigger: Int, + leftStickX: Int, + leftStickY: Int, + rightStickX: Int, + rightStickY: Int, + bitmap: Int, + partiallyReliable: Boolean, + timestampUs: Long = timestampUs(), + ): ByteArray { + val bytes = ByteArray(38) + val le = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN) + le.putInt(0, INPUT_GAMEPAD) + le.putShort(4, 26.toShort()) + le.putShort(6, (controllerId and 0x03).toShort()) + le.putShort(8, bitmap.toShort()) + le.putShort(10, 20.toShort()) + le.putShort(12, buttons.toShort()) + le.putShort(14, ((leftTrigger and 0xff) or ((rightTrigger and 0xff) shl 8)).toShort()) + le.putShort(16, leftStickX.toShort()) + le.putShort(18, leftStickY.toShort()) + le.putShort(20, rightStickX.toShort()) + le.putShort(22, rightStickY.toShort()) + le.putShort(24, 0.toShort()) + le.putShort(26, 85.toShort()) + le.putShort(28, 0.toShort()) + le.putLong(30, timestampUs) + return if (partiallyReliable) wrapGamepadPartiallyReliable(bytes, controllerId) else wrapGamepadReliable(bytes) + } + + private fun encodeKey(type: Int, key: KeyboardPayload): ByteArray { + val bytes = ByteArray(18) + ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN).putInt(type) + ByteBuffer.wrap(bytes).order(ByteOrder.BIG_ENDIAN) + .putShort(4, key.keycode.toShort()) + .putShort(6, key.modifiers.toShort()) + .putShort(8, key.scancode.toShort()) + .putLong(10, key.timestampUs) + return wrapSingle(bytes) + } + + private fun wrapSingle(payload: ByteArray, nowUs: Long = timestampUs()): ByteArray { + if (protocolVersion <= 2) return payload + return ByteArray(10 + payload.size).also { + it[0] = 0x23 + ByteBuffer.wrap(it).order(ByteOrder.BIG_ENDIAN).putLong(1, nowUs) + it[9] = 0x22 + payload.copyInto(it, 10) + } + } + + private fun wrapMouseMove(payload: ByteArray): ByteArray { + if (protocolVersion <= 2) return payload + return ByteArray(12 + payload.size).also { + it[0] = 0x23 + ByteBuffer.wrap(it).order(ByteOrder.BIG_ENDIAN).putLong(1, timestampUs()) + it[9] = 0x21 + ByteBuffer.wrap(it).order(ByteOrder.BIG_ENDIAN).putShort(10, payload.size.toShort()) + payload.copyInto(it, 12) + } + } + + private fun wrapGamepadReliable(payload: ByteArray): ByteArray { + if (protocolVersion <= 2) return payload + return ByteArray(12 + payload.size).also { + it[0] = 0x23 + ByteBuffer.wrap(it).order(ByteOrder.BIG_ENDIAN).putLong(1, timestampUs()) + it[9] = 0x21 + ByteBuffer.wrap(it).order(ByteOrder.BIG_ENDIAN).putShort(10, payload.size.toShort()) + payload.copyInto(it, 12) + } + } + + private fun wrapGamepadPartiallyReliable(payload: ByteArray, index: Int): ByteArray { + if (protocolVersion <= 2) return payload + val seq = gamepadSequences[index] ?: 1 + gamepadSequences[index] = (seq + 1) and 0xffff + return ByteArray(16 + payload.size).also { + it[0] = 0x23 + val be = ByteBuffer.wrap(it).order(ByteOrder.BIG_ENDIAN) + be.putLong(1, timestampUs()) + it[9] = 0x26 + it[10] = (index and 0xff).toByte() + be.putShort(11, seq.toShort()) + it[13] = 0x21 + be.putShort(14, payload.size.toShort()) + payload.copyInto(it, 16) + } + } + + data class KeyboardPayload( + val keycode: Int, + val scancode: Int, + val modifiers: Int, + val timestampUs: Long = timestampUs(), + ) + + data class TextKeySpec( + val keycode: Int, + val scancode: Int, + val shift: Boolean = false, + ) { + fun toKeyboardPayload(modifiers: Int): KeyboardPayload = + KeyboardPayload(keycode, scancode, modifiers) + } + + companion object { + const val INPUT_HEARTBEAT = 2 + const val INPUT_KEY_DOWN = 3 + const val INPUT_KEY_UP = 4 + const val INPUT_MOUSE_ABS = 5 + const val INPUT_MOUSE_REL = 7 + const val INPUT_MOUSE_BUTTON_DOWN = 8 + const val INPUT_MOUSE_BUTTON_UP = 9 + const val INPUT_MOUSE_WHEEL = 10 + const val INPUT_GAMEPAD = 12 + const val INPUT_HAPTICS_ENABLED = 13 + const val INPUT_TEXT = 23 + + /** + * Native multi-touch. The host turns these into a Windows digitizer, which is what makes + * touch-aware games switch to their mobile UI on their own. + */ + const val INPUT_TOUCH = 24 + + fun mapKeyEvent(event: KeyEvent): KeyboardPayload? { + if (event.action != KeyEvent.ACTION_DOWN && event.action != KeyEvent.ACTION_UP) return null + return mapKeyboardPayload( + keyCode = event.keyCode, + unicode = event.unicodeChar, + scanCode = event.scanCode, + shift = event.isShiftPressed, + ctrl = event.isCtrlPressed, + alt = event.isAltPressed, + meta = event.isMetaPressed, + capsLock = event.isCapsLockOn, + numLock = event.isNumLockOn, + ) + } + + internal fun mapKeyboardPayload( + keyCode: Int, + unicode: Int, + scanCode: Int, + shift: Boolean = false, + ctrl: Boolean = false, + alt: Boolean = false, + meta: Boolean = false, + capsLock: Boolean = false, + numLock: Boolean = false, + timestampUs: Long = timestampUs(), + ): KeyboardPayload? { + val vk = virtualKey(keyCode, unicode) + val resolvedScanCode = if (scanCode > 0) scanCode else fallbackScanCode(keyCode) + if (vk == null || resolvedScanCode == null) return null + var modifiers = 0 + if (shift) modifiers = modifiers or 0x01 + if (ctrl) modifiers = modifiers or 0x02 + if (alt) modifiers = modifiers or 0x04 + if (meta) modifiers = modifiers or 0x08 + if (capsLock) modifiers = modifiers or 0x10 + if (numLock) modifiers = modifiers or 0x20 + return KeyboardPayload(vk, resolvedScanCode, modifiers, timestampUs) + } + + /** + * The character to send as text because the key path cannot reproduce it faithfully. + * + * Two cases, both of which silently lost symbols while letters kept working: + * + * 1. **No mapping.** Android emits dedicated keycodes for some symbols — `KEYCODE_AT`, + * `KEYCODE_POUND`, `KEYCODE_STAR`, `KEYCODE_PLUS`, the numpad operators — and none of + * them have a [fallbackScanCode]. With no hardware scancode to fall back on, + * [mapKeyboardPayload] returned null and `dispatchKey` swallowed the key. + * + * 2. **An AltGr layer.** [virtualKey] translates a keycode to its *US-layout* virtual key + * and ignores the character the reader actually typed. On a layout where `@` is AltGr+Q + * that reaches the host as Ctrl+Alt+Q. Letters survive because they sit at the same + * keycode on every Latin layout; symbols do not, which is exactly the shape of the bug. + * + * A US layout is unaffected: Android resolves no printable character while Ctrl is held, so + * game shortcuts like Ctrl+Alt+F keep going down the key path untouched. + */ + internal fun keyboardTextFallbackChar( + unicodeChar: Int, + baseUnicodeChar: Int, + mapped: Boolean, + altGraph: Boolean, + ): Char? { + // Control characters have their own keycodes (Enter, Tab, Backspace) and must never be + // re-sent as text — the host would type a literal control byte instead of pressing them. + if (unicodeChar < 0x20 || unicodeChar == 0x7f) return null + if (unicodeChar > Char.MAX_VALUE.code) return null + val char = unicodeChar.toChar() + if (!mapped) return char + // A key that maps *and* whose character is unchanged by AltGr is already correct. + return char.takeIf { altGraph && unicodeChar != baseUnicodeChar } + } + + internal fun mapTextCharToKeySpec(char: Char): TextKeySpec? { + val mapped = when (char) { + in 'a'..'z' -> textKeySpecFromAndroidKeyCode(KeyEvent.KEYCODE_A + (char - 'a')) + in 'A'..'Z' -> textKeySpecFromAndroidKeyCode(KeyEvent.KEYCODE_A + (char - 'A'), shift = true) + in '0'..'9' -> textKeySpecFromAndroidKeyCode(KeyEvent.KEYCODE_0 + (char - '0')) + '\n', '\r' -> textKeySpecFromAndroidKeyCode(KeyEvent.KEYCODE_ENTER) + else -> textBaseKeyCodes[char]?.let(::textKeySpecFromAndroidKeyCode) + ?: textShiftedKeyCodes[char]?.let { textKeySpecFromAndroidKeyCode(it, shift = true) } + } + return mapped + } + + internal fun shiftLeftPayload(modifiers: Int): KeyboardPayload = + KeyboardPayload(0xa0, fallbackScanCode(KeyEvent.KEYCODE_SHIFT_LEFT) ?: 0x002a, modifiers) + + private fun textKeySpecFromAndroidKeyCode(keyCode: Int, shift: Boolean = false): TextKeySpec? { + val payload = mapKeyboardPayload( + keyCode = keyCode, + unicode = 0, + scanCode = 0, + shift = shift, + timestampUs = 0L, + ) ?: return null + return TextKeySpec(payload.keycode, payload.scancode, shift) + } + + private val textBaseKeyCodes = mapOf( + ' ' to KeyEvent.KEYCODE_SPACE, + '-' to KeyEvent.KEYCODE_MINUS, + '=' to KeyEvent.KEYCODE_EQUALS, + '[' to KeyEvent.KEYCODE_LEFT_BRACKET, + ']' to KeyEvent.KEYCODE_RIGHT_BRACKET, + '\\' to KeyEvent.KEYCODE_BACKSLASH, + ';' to KeyEvent.KEYCODE_SEMICOLON, + '\'' to KeyEvent.KEYCODE_APOSTROPHE, + ',' to KeyEvent.KEYCODE_COMMA, + '.' to KeyEvent.KEYCODE_PERIOD, + '/' to KeyEvent.KEYCODE_SLASH, + '`' to KeyEvent.KEYCODE_GRAVE, + ) + + private val textShiftedKeyCodes = mapOf( + '!' to KeyEvent.KEYCODE_1, + '@' to KeyEvent.KEYCODE_2, + '#' to KeyEvent.KEYCODE_3, + '$' to KeyEvent.KEYCODE_4, + '%' to KeyEvent.KEYCODE_5, + '^' to KeyEvent.KEYCODE_6, + '&' to KeyEvent.KEYCODE_7, + '*' to KeyEvent.KEYCODE_8, + '(' to KeyEvent.KEYCODE_9, + ')' to KeyEvent.KEYCODE_0, + '_' to KeyEvent.KEYCODE_MINUS, + '+' to KeyEvent.KEYCODE_EQUALS, + '{' to KeyEvent.KEYCODE_LEFT_BRACKET, + '}' to KeyEvent.KEYCODE_RIGHT_BRACKET, + '|' to KeyEvent.KEYCODE_BACKSLASH, + ':' to KeyEvent.KEYCODE_SEMICOLON, + '"' to KeyEvent.KEYCODE_APOSTROPHE, + '<' to KeyEvent.KEYCODE_COMMA, + '>' to KeyEvent.KEYCODE_PERIOD, + '?' to KeyEvent.KEYCODE_SLASH, + '~' to KeyEvent.KEYCODE_GRAVE, + ) + + private fun textInputChunkLength(bytes: ByteArray, offset: Int): Int { + val remaining = bytes.size - offset + if (remaining <= TEXT_INPUT_CHUNK_MAX_BYTES) return remaining + + var end = offset + TEXT_INPUT_CHUNK_MAX_BYTES + repeat(4) { + if ((bytes[end].toInt() and 0xc0) != 0x80) return end - offset + end -= 1 + } + return 0 + } + + private const val TEXT_INPUT_CHUNK_MAX_BYTES = 1016 + private const val TEXT_INPUT_HEADER_BYTES = 5 + + private fun virtualKey(keyCode: Int, unicode: Int): Int? = + when (keyCode) { + KeyEvent.KEYCODE_ENTER -> 0x0d + KeyEvent.KEYCODE_ESCAPE -> 0x1b + KeyEvent.KEYCODE_DEL -> 0x08 + KeyEvent.KEYCODE_TAB -> 0x09 + KeyEvent.KEYCODE_SPACE -> 0x20 + KeyEvent.KEYCODE_DPAD_LEFT -> 0x25 + KeyEvent.KEYCODE_DPAD_UP -> 0x26 + KeyEvent.KEYCODE_DPAD_RIGHT -> 0x27 + KeyEvent.KEYCODE_DPAD_DOWN -> 0x28 + KeyEvent.KEYCODE_PAGE_UP -> 0x21 + KeyEvent.KEYCODE_PAGE_DOWN -> 0x22 + KeyEvent.KEYCODE_FORWARD_DEL -> 0x2e + KeyEvent.KEYCODE_INSERT -> 0x2d + KeyEvent.KEYCODE_MOVE_HOME -> 0x24 + KeyEvent.KEYCODE_MOVE_END -> 0x23 + KeyEvent.KEYCODE_SHIFT_LEFT, + KeyEvent.KEYCODE_SHIFT_RIGHT, + -> 0x10 + KeyEvent.KEYCODE_CTRL_LEFT, + KeyEvent.KEYCODE_CTRL_RIGHT, + -> 0x11 + KeyEvent.KEYCODE_ALT_LEFT, + KeyEvent.KEYCODE_ALT_RIGHT, + -> 0x12 + KeyEvent.KEYCODE_CAPS_LOCK -> 0x14 + KeyEvent.KEYCODE_NUM_LOCK -> 0x90 + KeyEvent.KEYCODE_SCROLL_LOCK -> 0x91 + KeyEvent.KEYCODE_MINUS -> 0xbd + KeyEvent.KEYCODE_EQUALS -> 0xbb + KeyEvent.KEYCODE_LEFT_BRACKET -> 0xdb + KeyEvent.KEYCODE_RIGHT_BRACKET -> 0xdd + KeyEvent.KEYCODE_BACKSLASH -> 0xdc + KeyEvent.KEYCODE_SEMICOLON -> 0xba + KeyEvent.KEYCODE_APOSTROPHE -> 0xde + KeyEvent.KEYCODE_COMMA -> 0xbc + KeyEvent.KEYCODE_PERIOD -> 0xbe + KeyEvent.KEYCODE_SLASH -> 0xbf + KeyEvent.KEYCODE_GRAVE -> 0xc0 + in KeyEvent.KEYCODE_A..KeyEvent.KEYCODE_Z -> 0x41 + (keyCode - KeyEvent.KEYCODE_A) + in KeyEvent.KEYCODE_0..KeyEvent.KEYCODE_9 -> 0x30 + (keyCode - KeyEvent.KEYCODE_0) + in KeyEvent.KEYCODE_NUMPAD_0..KeyEvent.KEYCODE_NUMPAD_9 -> 0x60 + (keyCode - KeyEvent.KEYCODE_NUMPAD_0) + in KeyEvent.KEYCODE_F1..KeyEvent.KEYCODE_F12 -> 0x70 + (keyCode - KeyEvent.KEYCODE_F1) + else -> unicode.takeIf { it in 1..255 }?.let { Character.toUpperCase(it.toChar()).code } + } + + private fun fallbackScanCode(keyCode: Int): Int? = + when (keyCode) { + KeyEvent.KEYCODE_A -> 0x001e + KeyEvent.KEYCODE_B -> 0x0030 + KeyEvent.KEYCODE_C -> 0x002e + KeyEvent.KEYCODE_D -> 0x0020 + KeyEvent.KEYCODE_E -> 0x0012 + KeyEvent.KEYCODE_F -> 0x0021 + KeyEvent.KEYCODE_G -> 0x0022 + KeyEvent.KEYCODE_H -> 0x0023 + KeyEvent.KEYCODE_I -> 0x0017 + KeyEvent.KEYCODE_J -> 0x0024 + KeyEvent.KEYCODE_K -> 0x0025 + KeyEvent.KEYCODE_L -> 0x0026 + KeyEvent.KEYCODE_M -> 0x0032 + KeyEvent.KEYCODE_N -> 0x0031 + KeyEvent.KEYCODE_O -> 0x0018 + KeyEvent.KEYCODE_P -> 0x0019 + KeyEvent.KEYCODE_Q -> 0x0010 + KeyEvent.KEYCODE_R -> 0x0013 + KeyEvent.KEYCODE_S -> 0x001f + KeyEvent.KEYCODE_T -> 0x0014 + KeyEvent.KEYCODE_U -> 0x0016 + KeyEvent.KEYCODE_V -> 0x002f + KeyEvent.KEYCODE_W -> 0x0011 + KeyEvent.KEYCODE_X -> 0x002d + KeyEvent.KEYCODE_Y -> 0x0015 + KeyEvent.KEYCODE_Z -> 0x002c + KeyEvent.KEYCODE_1 -> 0x0002 + KeyEvent.KEYCODE_2 -> 0x0003 + KeyEvent.KEYCODE_3 -> 0x0004 + KeyEvent.KEYCODE_4 -> 0x0005 + KeyEvent.KEYCODE_5 -> 0x0006 + KeyEvent.KEYCODE_6 -> 0x0007 + KeyEvent.KEYCODE_7 -> 0x0008 + KeyEvent.KEYCODE_8 -> 0x0009 + KeyEvent.KEYCODE_9 -> 0x000a + KeyEvent.KEYCODE_0 -> 0x000b + KeyEvent.KEYCODE_NUMPAD_7 -> 0x0047 + KeyEvent.KEYCODE_NUMPAD_8 -> 0x0048 + KeyEvent.KEYCODE_NUMPAD_9 -> 0x0049 + KeyEvent.KEYCODE_NUMPAD_4 -> 0x004b + KeyEvent.KEYCODE_NUMPAD_5 -> 0x004c + KeyEvent.KEYCODE_NUMPAD_6 -> 0x004d + KeyEvent.KEYCODE_NUMPAD_1 -> 0x004f + KeyEvent.KEYCODE_NUMPAD_2 -> 0x0050 + KeyEvent.KEYCODE_NUMPAD_3 -> 0x0051 + KeyEvent.KEYCODE_NUMPAD_0 -> 0x0052 + KeyEvent.KEYCODE_ENTER -> 0x001c + KeyEvent.KEYCODE_NUMPAD_ENTER -> 0x011c + KeyEvent.KEYCODE_ESCAPE -> 0x0001 + KeyEvent.KEYCODE_SPACE -> 0x0039 + KeyEvent.KEYCODE_TAB -> 0x000f + KeyEvent.KEYCODE_DEL -> 0x000e + KeyEvent.KEYCODE_DPAD_LEFT -> 0x014b + KeyEvent.KEYCODE_DPAD_UP -> 0x0148 + KeyEvent.KEYCODE_DPAD_RIGHT -> 0x014d + KeyEvent.KEYCODE_DPAD_DOWN -> 0x0150 + KeyEvent.KEYCODE_PAGE_UP -> 0x0149 + KeyEvent.KEYCODE_PAGE_DOWN -> 0x0151 + KeyEvent.KEYCODE_FORWARD_DEL -> 0x0153 + KeyEvent.KEYCODE_INSERT -> 0x0152 + KeyEvent.KEYCODE_MOVE_HOME -> 0x0147 + KeyEvent.KEYCODE_MOVE_END -> 0x014f + KeyEvent.KEYCODE_SHIFT_LEFT -> 0x002a + KeyEvent.KEYCODE_SHIFT_RIGHT -> 0x0036 + KeyEvent.KEYCODE_CTRL_LEFT -> 0x001d + KeyEvent.KEYCODE_CTRL_RIGHT -> 0x011d + KeyEvent.KEYCODE_ALT_LEFT -> 0x0038 + KeyEvent.KEYCODE_ALT_RIGHT -> 0x0138 + KeyEvent.KEYCODE_CAPS_LOCK -> 0x003a + KeyEvent.KEYCODE_NUM_LOCK -> 0x0145 + KeyEvent.KEYCODE_SCROLL_LOCK -> 0x0046 + KeyEvent.KEYCODE_MINUS -> 0x000c + KeyEvent.KEYCODE_EQUALS -> 0x000d + KeyEvent.KEYCODE_LEFT_BRACKET -> 0x001a + KeyEvent.KEYCODE_RIGHT_BRACKET -> 0x001b + KeyEvent.KEYCODE_BACKSLASH -> 0x002b + KeyEvent.KEYCODE_SEMICOLON -> 0x0027 + KeyEvent.KEYCODE_APOSTROPHE -> 0x0028 + KeyEvent.KEYCODE_COMMA -> 0x0033 + KeyEvent.KEYCODE_PERIOD -> 0x0034 + KeyEvent.KEYCODE_SLASH -> 0x0035 + KeyEvent.KEYCODE_GRAVE -> 0x0029 + else -> null + } + } +} + +internal class InputSessionClock( + private val elapsedRealtimeNanos: () -> Long = SystemClock::elapsedRealtimeNanos, +) { + @Volatile + private var startedAtNanos = 0L + + fun start() { + startedAtNanos = elapsedRealtimeNanos() + } + + fun timestampUs(): Long { + val startedAt = startedAtNanos + if (startedAt == 0L) return 0L + return (elapsedRealtimeNanos() - startedAt).coerceAtLeast(0L) / 1_000L + } +} + +private val inputSessionClock = InputSessionClock() + +/** Reset the input clock when the host handshake completes, matching the desktop GFN client. */ +internal fun startInputSessionClock() { + inputSessionClock.start() +} + +internal fun timestampUs(): Long = inputSessionClock.timestampUs() + +/** Protocol v3 outer headers carry the send-time session clock, not device uptime. */ +internal fun restampProtocolV3OuterTimestamp(packet: ByteArray, nowUs: Long = timestampUs()): Boolean { + if (packet.size < 9 || packet[0] != 0x23.toByte()) return false + ByteBuffer.wrap(packet).order(ByteOrder.BIG_ENDIAN).putLong(1, nowUs.coerceAtLeast(0L)) + return true +} + +/** + * WebRTC's low-latency AudioTrack path can race teardown and dereference a released AudioTrack. + * Stable buffering is preferable to a process crash on both handheld and TV devices. + */ +internal fun shouldUseLowLatencyStreamAudio( + @Suppress("UNUSED_PARAMETER") androidTvProfile: Boolean, +): Boolean = false + +internal fun shouldRunControllerMouseLoop( + controllerMouseAssistActive: Boolean, + controllerMouseEmulationActive: Boolean, +): Boolean = controllerMouseAssistActive || controllerMouseEmulationActive + +internal fun shouldCaptureMicrophone( + mode: MicrophoneMode, + permissionGranted: Boolean, +): Boolean = mode != MicrophoneMode.Disabled && permissionGranted + +internal fun isDisposedRtpSenderFailure(error: IllegalStateException): Boolean = + error.message == "RtpSender has been disposed." + +internal fun advancedCodecRestartSettleDelayMs(codec: VideoCodec, hadStableMedia: Boolean): Long = + if (hadStableMedia && codec != VideoCodec.H264) ANDROID_CODEC_RESTART_SETTLE_MS else 0L + +internal const val GFN_MICROPHONE_MID = "3" +internal const val MICROPHONE_STREAM_ID = "mic" +internal const val MICROPHONE_TRACK_ID = "mic" +internal const val DEFAULT_INPUT_PROTOCOL_VERSION = 2 +internal const val INPUT_HANDSHAKE_MARKER = 0x0e +internal const val INPUT_HANDSHAKE_MAGIC_WORD = 526 +internal const val ICE_DISCONNECTED_GRACE_MS = 3500L +internal const val ICE_FAILED_RECONNECT_DELAY_MS = 250L +internal const val SIGNALING_RECONNECT_DELAY_MS = 1000L +private const val ANDROID_CODEC_RESTART_SETTLE_MS = 180L +internal const val MAX_TRANSPORT_RECONNECT_ATTEMPTS = 3 +internal const val MAX_TRANSIENT_SIGNALING_RETRIES = 3 +internal const val OFFER_TIMEOUT_MS = 12_000L +internal const val MEDIA_STALL_KEYFRAME_AFTER_MS = 5_000L +internal const val MEDIA_STALL_KEYFRAME_INTERVAL_MS = 2_500L +internal const val MEDIA_STALL_RESTART_AFTER_MS = 10_000L +// Low-power TV MediaCodec implementations can open an advanced decoder several +// seconds before they produce their first frame. Keep the pre-TV-optimization +// startup window so a slow H.265/AV1 decoder is not mistaken for a dead one and +// immediately replaced by the safe-codec profile. +internal const val TV_MEDIA_STALL_KEYFRAME_AFTER_MS = 5_000L +internal const val TV_MEDIA_STALL_KEYFRAME_INTERVAL_MS = 2_500L +internal const val TV_MEDIA_STALL_RESTART_AFTER_MS = 14_000L +internal const val FIRST_VIDEO_FRAME_TIMEOUT_MS = 8_000L +internal const val STABLE_TRANSPORT_PROGRESS_SAMPLES = 3 +internal const val GAMEPAD_GUIDE_AUTO_RELEASE_MS = 160L +internal const val STEAM_MENU_MODIFIER_DELAY_MS = 40L +internal const val STREAM_TEXT_SEND_MAX_CHARS = 4096 +internal const val STREAM_TEXT_SEND_ATTEMPTS = 3 +internal const val STREAM_TEXT_PACKET_DELAY_MS = 4L +internal const val STREAM_TEXT_RETRY_DELAY_MS = 16L +internal const val BYTES_PER_MEBIBYTE = 1024L * 1024L +internal const val LOW_POWER_TV_MEMORY_LIMIT_BYTES = 3L * 1024L * BYTES_PER_MEBIBYTE + +internal fun Any?.statsDouble(): Double? = + when (this) { + is Number -> toDouble() + is String -> toDoubleOrNull() + else -> null + } + +internal fun Any?.statsLong(): Long? = + when (this) { + is Number -> toLong() + is String -> toLongOrNull() + else -> null + } diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/StreamInputModeChoice.kt b/android/app/src/main/java/com/opencloudgaming/opennow/StreamInputModeChoice.kt new file mode 100644 index 000000000..757d8ce66 --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/StreamInputModeChoice.kt @@ -0,0 +1,37 @@ +package com.opencloudgaming.opennow + +enum class StreamInputMode { + NativeTouch, + KeyboardMouse, +} + +internal enum class StreamInputModePrompt { + SwitchToKeyboardMouse, + SwitchToNativeTouch, +} + +/** + * A device already attached when streaming starts is an explicit enough signal to start in + * keyboard/mouse mode. Later connection changes require confirmation and stay session-local. + */ +internal fun streamInputModeAtStart( + nativeTouchAvailable: Boolean, + keyboardMouseConnected: Boolean, +): StreamInputMode = if (nativeTouchAvailable && !keyboardMouseConnected) { + StreamInputMode.NativeTouch +} else { + StreamInputMode.KeyboardMouse +} + +internal fun streamInputModePromptForConnectionChange( + currentMode: StreamInputMode, + keyboardMouseConnected: Boolean, + nativeTouchProvisionedForSession: Boolean, +): StreamInputModePrompt? = when { + keyboardMouseConnected && currentMode == StreamInputMode.NativeTouch -> + StreamInputModePrompt.SwitchToKeyboardMouse + !keyboardMouseConnected && + currentMode == StreamInputMode.KeyboardMouse && + nativeTouchProvisionedForSession -> StreamInputModePrompt.SwitchToNativeTouch + else -> null +} diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/StreamInputRouting.kt b/android/app/src/main/java/com/opencloudgaming/opennow/StreamInputRouting.kt new file mode 100644 index 000000000..6e2c5a2e7 --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/StreamInputRouting.kt @@ -0,0 +1,1452 @@ +package com.opencloudgaming.opennow + +import android.view.InputDevice +import android.view.KeyEvent +import android.view.MotionEvent +import kotlinx.coroutines.cancel +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.util.Locale +import kotlin.math.abs +import kotlin.math.roundToInt +import kotlin.math.sqrt + +internal class NativeUiTouchRoutingState { + @Volatile + private var streamChromeBounds: TouchPassthroughBounds? = null + @Volatile + private var streamPanelBounds: TouchPassthroughBounds? = null + @Volatile + private var overlayBounds: Map = emptyMap() + @Volatile + private var touchControllerBounds: Map = emptyMap() + @Volatile + private var touchControllerVisible = false + + private val trackedPointerIds = mutableSetOf() + private val ownedPointerIds = mutableSetOf() + + @Volatile + var passthroughActive: Boolean = false + private set + + fun setStreamChromeBounds(left: Int, top: Int, right: Int, bottom: Int) { + streamChromeBounds = TouchPassthroughBounds(left, top, right, bottom) + } + + fun clearStreamChromeBounds() { + streamChromeBounds = null + } + + fun setOverlayBound(id: String, left: Int, top: Int, right: Int, bottom: Int) { + overlayBounds = overlayBounds.toMutableMap().also { + it[id] = TouchPassthroughBounds(left, top, right, bottom) + } + } + + fun clearOverlayBound(id: String) { + if (id !in overlayBounds) return + overlayBounds = overlayBounds.toMutableMap().also { it.remove(id) } + } + + fun setStreamPanelBounds(left: Int, top: Int, right: Int, bottom: Int) { + streamPanelBounds = TouchPassthroughBounds(left, top, right, bottom) + } + + fun clearStreamPanelBounds() { + streamPanelBounds = null + } + + fun setTouchControllerBounds(left: Int, top: Int, right: Int, bottom: Int) { + touchControllerBounds = mapOf("default" to TouchPassthroughBounds(left, top, right, bottom)) + } + + fun setTouchControllerBound(id: String, left: Int, top: Int, right: Int, bottom: Int) { + touchControllerBounds = touchControllerBounds.toMutableMap().also { + it[id] = TouchPassthroughBounds(left, top, right, bottom) + } + } + + fun clearTouchControllerBound(id: String) { + if (id !in touchControllerBounds) return + touchControllerBounds = touchControllerBounds.toMutableMap().also { it.remove(id) } + } + + fun setTouchControllerVisible(visible: Boolean) { + touchControllerVisible = visible + if (!visible) touchControllerBounds = emptyMap() + } + + fun clearTouchControllerBounds() { + touchControllerBounds = emptyMap() + touchControllerVisible = false + } + + fun routesTouchMouseThroughCompose(): Boolean = touchControllerVisible + + fun touchesRegisteredUi(x: Float, y: Float, width: Int, height: Int): Boolean { + if (streamChromeBounds?.contains(x, y) == true) return true + if (streamPanelBounds?.contains(x, y) == true) return true + if (overlayBounds.values.any { it.contains(x, y) }) return true + if (touchControllerBounds.values.any { it.contains(x, y) }) return true + // Before Compose has measured the controller there are no precise bounds to protect, so + // retain the lower-screen fallback for that short startup window. Once even one control + // has registered, use the measured bounds exclusively. Keeping the fallback active after + // layout makes the entire lower half unavailable to Finger Mouse even in empty space. + return touchControllerVisible && + touchControllerBounds.isEmpty() && + width > 0 && + height > 0 && + y >= height * TOUCH_CONTROLLER_FALLBACK_TOP_RATIO + } + + fun beginPointerGesture(pointerId: Int, touchesUi: Boolean) { + trackedPointerIds.clear() + ownedPointerIds.clear() + trackedPointerIds += pointerId + if (touchesUi) ownedPointerIds += pointerId + syncPassthroughActive() + } + + fun addPointer(pointerId: Int, touchesUi: Boolean) { + trackedPointerIds += pointerId + if (touchesUi) ownedPointerIds += pointerId + syncPassthroughActive() + } + + fun ownsPointer(pointerId: Int): Boolean = pointerId in ownedPointerIds + + fun classifiesPointerAsUi(pointerId: Int, touchesUiNow: Boolean): Boolean = + if (pointerId in trackedPointerIds) ownsPointer(pointerId) else touchesUiNow + + fun hasOwnedPointer(): Boolean = ownedPointerIds.isNotEmpty() + + fun ownedPointers(): Set = ownedPointerIds + + fun releasePointer(pointerId: Int) { + trackedPointerIds.remove(pointerId) + ownedPointerIds.remove(pointerId) + syncPassthroughActive() + } + + fun endPointerGesture() { + trackedPointerIds.clear() + ownedPointerIds.clear() + syncPassthroughActive() + } + + fun setLegacyPassthroughActive(active: Boolean) { + passthroughActive = active + } + + private fun syncPassthroughActive() { + passthroughActive = ownedPointerIds.isNotEmpty() + } + + private data class TouchPassthroughBounds( + val left: Int, + val top: Int, + val right: Int, + val bottom: Int, + ) { + fun contains(x: Float, y: Float): Boolean = + x >= left - EDGE_SLOP_PX && + x <= right + EDGE_SLOP_PX && + y >= top - EDGE_SLOP_PX && + y <= bottom + EDGE_SLOP_PX + + companion object { + private const val EDGE_SLOP_PX = 24 + } + } + + private companion object { + const val TOUCH_CONTROLLER_FALLBACK_TOP_RATIO = 0.52f + } +} + +internal fun shouldConsumeNativeUiTransitionTouch( + streamUiActive: Boolean, + hasOwnedPointer: Boolean, +): Boolean = streamUiActive && hasOwnedPointer + +object NativeStreamInputRouter { + private data class PresentationTransform( + val zoomScale: Float = 1f, + val translationX: Float = 0f, + val translationY: Float = 0f, + ) + + @Volatile + private var client: NativeStreamClient? = null + @Volatile + private var androidTvProfile = false + fun setAndroidTvProfile(enabled: Boolean) { + androidTvProfile = enabled + } + @Volatile + private var externalMousePointerCaptureEnabled = false + + fun setExternalMousePointerCaptureEnabled(enabled: Boolean) { + externalMousePointerCaptureEnabled = enabled + } + + fun isExternalMousePointerCaptureEnabled(): Boolean = + externalMousePointerCaptureEnabled + + @Volatile + private var touchMouseEnabled = false + @Volatile + private var mouseDirectClick = false + @Volatile + private var stretchToFit = false + @Volatile + private var renderingAspectRatio = 0f + @Volatile + private var presentationTransform = PresentationTransform() + @Volatile + private var decodedStreamResolution = 0 to 0 + @Volatile + private var captureAllTouch = false + @Volatile + private var systemMenuHandler: (() -> Unit)? = null + + @Volatile + private var systemBackHandler: (() -> Unit)? = null + @Volatile + private var streamUiActive = false + private val nativeUiTouchRouting = NativeUiTouchRoutingState() + private val touchMouseState = TouchMouseState() + + /** + * When set, fingers are forwarded to the host as real touch instead of being turned into a + * cursor. Mutually exclusive with the touch-mouse and direct-click paths by construction: + * [dispatchTouch] takes this branch first and returns. + */ + @Volatile + private var nativeTouchEnabled = false + private val touchSlots = TouchSlotAllocator() + /** + * Tracks the initial DOWN position per pointer ID for jitter guard in native touch mode. + * Entries are added on DOWN and removed on UP/CANCEL. + */ + private val nativeTouchDownPoints = mutableMapOf>() + /** Native touch settings synced from [AndroidTouchSettings]. */ + @Volatile private var nativeTouchScrollScale: Float = 1.0f + @Volatile private var nativeTouchJitterThresholdPx: Float = 0f + + fun attach(next: NativeStreamClient) { + client = next + // Never carry an in-progress drag position into a different host session. + touchMouseState.forgetCursorPosition() + decodedStreamResolution = 0 to 0 + resetPresentationTransform() + } + + fun detach(next: NativeStreamClient) { + if (client === next) { + releaseInputForLifecycle("stream-detached") + client = null + touchMouseState.forgetCursorPosition() + decodedStreamResolution = 0 to 0 + resetPresentationTransform() + } + } + + /** Releases held touch, mouse, and keyboard input when focus or lifecycle changes. */ + fun releaseInputForLifecycle(reason: String) { + client?.releasePhysicalInputForLifecycle(reason) + touchMouseState.reset(client) + releaseAllNativeTouches() + nativeTouchDownPoints.clear() + nativeUiTouchRouting.endPointerGesture() + } + + fun setTouchMouseEnabled(enabled: Boolean) { + touchMouseEnabled = enabled + if (!enabled) { + touchMouseState.reset(client) + } + } + + fun setMouseDirectClick(enabled: Boolean) { + mouseDirectClick = enabled + touchMouseState.reset(client) + } + + fun setNativeTouchEnabled(enabled: Boolean) { + if (nativeTouchEnabled == enabled) return + nativeTouchEnabled = enabled + // Leaving the mode mid-gesture would otherwise strand whatever fingers are down. + releaseAllNativeTouches() + nativeTouchDownPoints.clear() + nativeUiTouchRouting.endPointerGesture() + touchMouseState.reset(client) + } + + fun setNativeTouchSettings(scrollScale: Float, jitterThresholdDp: Float) { + val density = android.content.res.Resources.getSystem().displayMetrics.density + nativeTouchScrollScale = scrollScale + nativeTouchJitterThresholdPx = jitterThresholdDp * density + } + + fun setStretchToFit(enabled: Boolean) { + if (stretchToFit != enabled) { + stretchToFit = enabled + touchMouseState.reset(client) + } + } + + fun setRenderingAspectRatio(ratio: Float) { + if (renderingAspectRatio != ratio) { + renderingAspectRatio = ratio + touchMouseState.reset(client) + } + } + + /** + * Mirrors the uniform scale and translation applied by the Compose stream surface. Input is + * mapped through the inverse transform before letterbox/stretch mapping, so direct click keeps + * targeting the pixel visibly under the finger after pinch zoom or pan. + */ + fun setPresentationTransform(zoomScale: Float, translationX: Float, translationY: Float) { + val safeScale = zoomScale.takeIf { it.isFinite() }?.coerceIn(1f, 3f) ?: 1f + val safeTranslationX = translationX.takeIf { it.isFinite() } ?: 0f + val safeTranslationY = translationY.takeIf { it.isFinite() } ?: 0f + val next = PresentationTransform(safeScale, safeTranslationX, safeTranslationY) + if (presentationTransform == next) return + touchMouseState.reset(client) + presentationTransform = next + } + + private fun resetPresentationTransform() { + presentationTransform = PresentationTransform() + } + + fun setDecodedStreamResolution(width: Int, height: Int) { + if (width > 0 && height > 0) { + val next = width to height + if (decodedStreamResolution != next) { + // A new decoded geometry changes the visible content bounds even though the + // selected viewport remains fixed. End in-flight gestures before switching + // coordinate spaces so a held pointer cannot jump across the host screen. + touchMouseState.reset(client) + releaseAllNativeTouches() + } + decodedStreamResolution = next + } + } + + private fun inputContentAspectRatio(decodedResolution: Pair): Float = + if (decodedResolution.first > 0 && decodedResolution.second > 0) { + decodedResolution.first.toFloat() / decodedResolution.second.toFloat() + } else { + renderingAspectRatio + } + + fun setCaptureAllTouch(enabled: Boolean) { + captureAllTouch = enabled + } + + fun setSystemMenuHandler(handler: (() -> Unit)?) { + systemMenuHandler = handler + } + + fun setSystemBackHandler(handler: (() -> Unit)?) { + systemBackHandler = handler + } + + fun dispatchSystemBack(): Boolean { + val handler = systemBackHandler ?: return false + handler() + return true + } + + + fun setStreamUiActive(active: Boolean) { + if (active && !streamUiActive) { + // A system/menu action can open app UI while a native game touch is still held. The + // host will not receive that finger's eventual UP once UI routing takes over, so cancel + // it at the transition instead of leaving a stuck press in the game. + releaseAllNativeTouches() + nativeTouchDownPoints.clear() + touchMouseState.reset(client) + client?.releasePhysicalInputForLifecycle("stream-ui-opened") + } + streamUiActive = active + } + + fun normalizedStreamUiKeyCode(event: KeyEvent): Int? { + if (!streamUiActive) return null + return when (event.keyCode) { + KeyEvent.KEYCODE_BUTTON_A -> KeyEvent.KEYCODE_DPAD_CENTER + KeyEvent.KEYCODE_BUTTON_B, + KeyEvent.KEYCODE_BUTTON_SELECT -> KeyEvent.KEYCODE_BACK + else -> null + } + } + + fun normalizedAppUiKeyCode(event: KeyEvent): Int? { + return normalizedAppUiKeyCode(event.keyCode, streamUiActive) + } + + fun normalizedAppUiKeyCode(keyCode: Int, streamUiActive: Boolean): Int? { + if (streamUiActive) return null + return when (keyCode) { + KeyEvent.KEYCODE_BUTTON_A -> KeyEvent.KEYCODE_DPAD_CENTER + else -> null + } + } + + fun isControllerAppBackKey(event: KeyEvent): Boolean = + isControllerAppBackKey( + keyCode = event.keyCode, + controllerSource = event.isControllerInputDevice(), + streamUiActive = streamUiActive, + ) + + fun isControllerAppBackKey(keyCode: Int, controllerSource: Boolean, streamUiActive: Boolean): Boolean = + !streamUiActive && + (keyCode == KeyEvent.KEYCODE_BUTTON_B || + (keyCode == KeyEvent.KEYCODE_BACK && controllerSource)) + + fun setUiTouchPassthroughBounds(left: Int, top: Int, right: Int, bottom: Int) { + nativeUiTouchRouting.setStreamChromeBounds(left, top, right, bottom) + } + + fun clearUiTouchPassthroughBounds() { + nativeUiTouchRouting.clearStreamChromeBounds() + } + + fun setOverlayTouchPassthroughBound(id: String, left: Int, top: Int, right: Int, bottom: Int) { + nativeUiTouchRouting.setOverlayBound(id, left, top, right, bottom) + } + + fun clearOverlayTouchPassthroughBound(id: String) { + nativeUiTouchRouting.clearOverlayBound(id) + } + + fun setStreamPanelTouchPassthroughBounds(left: Int, top: Int, right: Int, bottom: Int) { + nativeUiTouchRouting.setStreamPanelBounds(left, top, right, bottom) + } + + fun clearStreamPanelTouchPassthroughBounds() { + nativeUiTouchRouting.clearStreamPanelBounds() + } + + fun setTouchControllerPassthroughBounds(left: Int, top: Int, right: Int, bottom: Int) { + nativeUiTouchRouting.setTouchControllerBounds(left, top, right, bottom) + } + + fun setTouchControllerPassthroughBound(id: String, left: Int, top: Int, right: Int, bottom: Int) { + nativeUiTouchRouting.setTouchControllerBound(id, left, top, right, bottom) + } + + fun clearTouchControllerPassthroughBound(id: String) { + nativeUiTouchRouting.clearTouchControllerBound(id) + } + + fun setTouchControllerVisible(visible: Boolean) { + nativeUiTouchRouting.setTouchControllerVisible(visible) + } + + fun clearTouchControllerPassthroughBounds() { + nativeUiTouchRouting.clearTouchControllerBounds() + } + + fun cancelTouchMouse() { + touchMouseState.reset(client) + } + + fun isNativeUiTouchGestureActive(): Boolean = + nativeUiTouchRouting.hasOwnedPointer() + + /** + * If app UI was opened on DOWN, consume the remainder of that launcher gesture before Android + * can retarget its UP to a control at the same coordinates in the newly mounted panel. + */ + fun shouldConsumeUiTransitionTouchBeforeViews(event: MotionEvent): Boolean = + event.isFingerTouchEvent() && + shouldConsumeNativeUiTransitionTouch( + streamUiActive = streamUiActive, + hasOwnedPointer = nativeUiTouchRouting.hasOwnedPointer(), + ) + + fun shouldForwardTouchBeforeViews(event: MotionEvent, width: Int, height: Int): Boolean { + val isDirectClick = mouseDirectClick && event.isExternalMousePointerEvent() + val isNativeTouch = nativeTouchEnabled && event.isFingerTouchEvent() + if ( + client == null || + streamUiActive || + !(touchMouseEnabled || isDirectClick || isNativeTouch) || + !captureAllTouch || + width <= 0 || + height <= 0 || + !(event.isFingerTouchEvent() || isDirectClick) + ) { + return false + } + updateNativeUiTouchPointers(event, width, height) + if ( + touchMouseEnabled && + event.isFingerTouchEvent() && + !isNativeTouch && + nativeUiTouchRouting.routesTouchMouseThroughCompose() + ) { + return false + } + if (!eventHasStreamTouchPointer(event, width, height)) return false + // The single-pointer restriction below belongs to the cursor paths, where only one finger + // can drive the pointer. Native touch forwards every finger by definition, so a two-finger + // gesture reaching this point is the normal case rather than something to hand to the views. + if (isNativeTouch) return true + return event.pointerCount == 1 || nativeUiTouchRouting.hasOwnedPointer() + } + + fun shouldCaptureTouchBeforeViews(event: MotionEvent, width: Int, height: Int): Boolean = + shouldForwardTouchBeforeViews(event, width, height) && + !nativeUiTouchRouting.hasOwnedPointer() + + fun dispatchTouch(event: MotionEvent, width: Int, height: Int): Boolean { + val current = client ?: return false + if (streamUiActive) return false + // Direct click supports both external mouse/touchpad events AND finger touch events, + // as long as the user has enabled mouseDirectClick in settings. + val isDirectClick = mouseDirectClick && (event.isExternalMousePointerEvent() || event.isFingerTouchEvent()) + if (!event.isFingerTouchEvent() && !isDirectClick) return false + updateNativeUiTouchPointers(event, width, height) + if (nativeTouchEnabled && event.isFingerTouchEvent() && width > 0 && height > 0) { + return dispatchNativeTouch(event, current, width, height) + } + val decodedResolution = decodedStreamResolution + val transform = presentationTransform + return touchMouseState.handle( + event = event, + enabled = (touchMouseEnabled || isDirectClick) && width > 0 && height > 0, + client = current, + ignoredPointerIds = nativeUiTouchRouting.ownedPointers(), + directClick = mouseDirectClick, + width = width, + height = height, + stretchToFit = stretchToFit, + renderingAspectRatio = inputContentAspectRatio(decodedResolution), + presentationZoomScale = transform.zoomScale, + presentationTranslationX = transform.translationX, + presentationTranslationY = transform.translationY, + decodedStreamWidth = decodedResolution.first, + decodedStreamHeight = decodedResolution.second, + ) + } + + /** + * Forwards every finger as native touch, so the host presents a digitizer and touch-aware games + * switch to their own mobile UI. Unlike the cursor paths this keeps no per-gesture state of its + * own — the only thing carried across events is the pointer-id to slot mapping. + */ + private fun dispatchNativeTouch( + event: MotionEvent, + client: NativeStreamClient, + width: Int, + height: Int, + ): Boolean { + val phase = when (event.actionMasked) { + MotionEvent.ACTION_DOWN, MotionEvent.ACTION_POINTER_DOWN -> TouchPhase.DOWN + MotionEvent.ACTION_MOVE -> TouchPhase.MOVE + MotionEvent.ACTION_UP, MotionEvent.ACTION_POINTER_UP -> TouchPhase.UP + MotionEvent.ACTION_CANCEL -> TouchPhase.CANCEL + else -> return false + } + + // Android reports which pointer changed only for the down/up actions; a MOVE carries fresh + // positions for every finger at once, and all of them belong in the batch. + val indices = if (phase == TouchPhase.MOVE || event.actionMasked == MotionEvent.ACTION_CANCEL) { + 0 until event.pointerCount + } else { + val index = if (event.actionMasked == MotionEvent.ACTION_DOWN) 0 else event.actionIndex + index..index + } + + val scrollScale = nativeTouchScrollScale.coerceIn(0.25f, 2.0f) + val jitterThresholdPx = nativeTouchJitterThresholdPx.coerceAtLeast(0f) + + val pointers = indices.mapNotNull { index -> + if (index !in 0 until event.pointerCount) return@mapNotNull null + val pointerId = event.getPointerId(index) + // Fingers on our own chrome belong to the overlay, not the game. + if (nativeUiTouchRouting.ownsPointer(pointerId)) return@mapNotNull null + + val rawX = event.getX(index) + val rawY = event.getY(index) + + when (phase) { + TouchPhase.DOWN -> { + // Record the starting position for jitter guard. + nativeTouchDownPoints[pointerId] = rawX to rawY + } + TouchPhase.MOVE -> { + val down = nativeTouchDownPoints[pointerId] + if (down != null && jitterThresholdPx > 0f) { + val dx = rawX - down.first + val dy = rawY - down.second + val distance = kotlin.math.sqrt(dx * dx + dy * dy) + // Suppress MOVE events until the finger has moved far enough from its + // initial touch point. This eliminates sensor jitter being interpreted + // as a micro-swipe that triggers a double-click or unintended scroll. + if (distance < jitterThresholdPx) return@mapNotNull null + } + } + TouchPhase.UP, TouchPhase.CANCEL -> { + nativeTouchDownPoints.remove(pointerId) + } + } + + // Apply scroll-scale to MOVE positions by interpolating from the DOWN point. + // This scales the apparent velocity of gesture without clamping coordinates. + val scaledX: Float + val scaledY: Float + if (phase == TouchPhase.MOVE && scrollScale != 1.0f) { + val down = nativeTouchDownPoints[pointerId] + if (down != null) { + scaledX = down.first + (rawX - down.first) * scrollScale + scaledY = down.second + (rawY - down.second) * scrollScale + } else { + scaledX = rawX + scaledY = rawY + } + } else { + scaledX = rawX + scaledY = rawY + } + + TouchPointerSample( + pointerId = pointerId, + x = scaledX, + y = scaledY, + radiusX = event.getTouchMajor(index) / 2f, + radiusY = event.getTouchMinor(index) / 2f, + ) + } + if (pointers.isEmpty()) return false + + val settingsResolution = streamResolutionPixels(client.settings) + val decodedResolution = decodedStreamResolution + val transform = presentationTransform + val streamWidth = decodedResolution.first.takeIf { it > 0 } ?: settingsResolution.first + val streamHeight = decodedResolution.second.takeIf { it > 0 } ?: settingsResolution.second + val records = buildTouchBatch( + allocator = touchSlots, + phase = phase, + pointers = pointers, + viewWidth = width, + viewHeight = height, + streamWidth = streamWidth, + streamHeight = streamHeight, + stretchToFit = stretchToFit, + renderingAspectRatio = inputContentAspectRatio(decodedResolution), + presentationZoomScale = transform.zoomScale, + presentationTranslationX = transform.translationX, + presentationTranslationY = transform.translationY, + ) + if (records.isEmpty()) return false + return client.sendNativeTouch(records) + } + + /** + * Lifts every finger the host still believes is down. Called when touch is turned off or the + * session is interrupted, because in neither case will the platform deliver the missing UP. + */ + private fun releaseAllNativeTouches() { + val current = client + val pointerIds = touchSlots.activePointerIds() + if (current != null && pointerIds.isNotEmpty()) { + val records = pointerIds.mapNotNull { pointerId -> + touchSlots.release(pointerId)?.let { slot -> + TouchRecord(slot = slot, phase = TouchPhase.CANCEL, x = 0, y = 0) + } + } + if (records.isNotEmpty()) current.sendNativeTouch(records) + } + touchSlots.clear() + } + + fun dispatchExternalMouseTouch(event: MotionEvent, width: Int, height: Int): Boolean { + if (streamUiActive) return false + if (!event.isExternalMousePointerEvent()) return false + if (mouseDirectClick) return false // Handled in dispatchTouch instead + if (shouldPassTouchToNativeUi(event, width, height)) return false + return client?.dispatchMotion(event) == true + } + + fun dispatchKey(event: KeyEvent): Boolean { + if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0 && event.isStreamSystemMenuKey()) { + systemMenuHandler?.invoke() + return systemMenuHandler != null + } + if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0 && event.isStreamControlsShortcutKey()) { + systemMenuHandler?.invoke() + return systemMenuHandler != null + } + val streamExitShortcut = event.isStreamExitShortcutKey() + if ( + androidTvProfile && + event.action == KeyEvent.ACTION_DOWN && + event.repeatCount == 0 && + (event.keyCode == KeyEvent.KEYCODE_BACK || event.keyCode == KeyEvent.KEYCODE_BUTTON_B) + ) { + NativeInputDiagnostics.add( + "tv back key key=${event.keyCode} source=${event.source} device=${event.deviceId}:${event.device?.name.orEmpty()} " + + "controller=${event.isControllerInputDevice()} dpad=${event.isDpadSource()} " + + "route=${if (streamExitShortcut) "stream_overlay" else "cloud_input"}", + ) + } + if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0 && streamExitShortcut) { + return dispatchSystemBack() + } + if (event.action == KeyEvent.ACTION_UP && streamExitShortcut) { + return systemBackHandler != null + } + if (streamUiActive) return false + val current = client ?: return false + if (event.shouldConsumeAsStreamKeyboard()) { + return current.dispatchKey(event) + } + return current.dispatchKey(event) + } + + fun dispatchMotion(event: MotionEvent): Boolean { + if (streamUiActive && event.isExternalMousePointerEvent()) { + return false + } + if (streamUiActive && event.isNativeUiNavigationMotion()) { + return false + } + return client?.dispatchMotion(event) == true + } + + private fun KeyEvent.isStreamSystemMenuKey(): Boolean = + shouldOpenStreamSystemMenuKey( + keyCode = keyCode, + controllerInputDevice = isControllerInputDevice(), + androidTvProfile = androidTvProfile, + ) + + private fun KeyEvent.isStreamControlsShortcutKey(): Boolean = + !streamUiActive && + !isControllerInputDevice() && + !isHardwareKeyboardSource() && + isDpadSource() && + (keyCode == KeyEvent.KEYCODE_ENTER || + keyCode == KeyEvent.KEYCODE_NUMPAD_ENTER || + keyCode == KeyEvent.KEYCODE_DPAD_CENTER) + + private fun KeyEvent.isStreamExitShortcutKey(): Boolean = + shouldHandleStreamExitKey( + keyCode = keyCode, + controllerInputDevice = isControllerInputDevice(), + hardwareKeyboardSource = isHardwareKeyboardSource(), + androidTvProfile = androidTvProfile, + dpadSource = isDpadSource(), + ) + + fun shouldOpenStreamSystemMenuKey( + keyCode: Int, + controllerInputDevice: Boolean, + androidTvProfile: Boolean = false, + ): Boolean = + (keyCode == KeyEvent.KEYCODE_MENU && !controllerInputDevice) || + // Android TV remotes usually have no MENU key and many are reported as controller + // devices; the Guide button is the only dedicated "open menu" affordance there. + (androidTvProfile && keyCode == KeyEvent.KEYCODE_BUTTON_MODE) + + fun shouldHandleStreamExitKey( + keyCode: Int, + controllerInputDevice: Boolean, + hardwareKeyboardSource: Boolean, + androidTvProfile: Boolean = false, + dpadSource: Boolean = false, + ): Boolean = + (keyCode == KeyEvent.KEYCODE_BACK && !controllerInputDevice) || + (androidTvProfile && + dpadSource && + keyCode == KeyEvent.KEYCODE_BUTTON_B && + !controllerInputDevice) || + (keyCode == KeyEvent.KEYCODE_ESCAPE && !hardwareKeyboardSource) + + private fun KeyEvent.isControllerInputDevice(): Boolean = + AndroidControllerInput.isControllerEvent(source, deviceId) + + private fun KeyEvent.isDpadSource(): Boolean = + (source and InputDevice.SOURCE_DPAD) == InputDevice.SOURCE_DPAD + + private fun KeyEvent.isHardwareKeyboardSource(): Boolean = + !isControllerInputDevice() && + ((source and InputDevice.SOURCE_KEYBOARD) == InputDevice.SOURCE_KEYBOARD || + InputDevice.getDevice(deviceId)?.keyboardType == InputDevice.KEYBOARD_TYPE_ALPHABETIC) + + private fun KeyEvent.shouldConsumeAsStreamKeyboard(): Boolean = + (action == KeyEvent.ACTION_DOWN || action == KeyEvent.ACTION_UP) && + !isControllerInputDevice() && + !isAndroidSystemKey() && + (isHardwareKeyboardSource() || keyCode.isTextEntryKeyCode()) + + private fun KeyEvent.isAndroidSystemKey(): Boolean = + keyCode == KeyEvent.KEYCODE_VOLUME_UP || + keyCode == KeyEvent.KEYCODE_VOLUME_DOWN || + keyCode == KeyEvent.KEYCODE_VOLUME_MUTE || + keyCode == KeyEvent.KEYCODE_POWER || + keyCode == KeyEvent.KEYCODE_HOME + + private fun Int.isKeyboardLikeKeyCode(): Boolean = + this == KeyEvent.KEYCODE_ENTER || + this == KeyEvent.KEYCODE_NUMPAD_ENTER || + this == KeyEvent.KEYCODE_ESCAPE || + this == KeyEvent.KEYCODE_DEL || + this == KeyEvent.KEYCODE_TAB || + this == KeyEvent.KEYCODE_SPACE || + this == KeyEvent.KEYCODE_DPAD_LEFT || + this == KeyEvent.KEYCODE_DPAD_UP || + this == KeyEvent.KEYCODE_DPAD_RIGHT || + this == KeyEvent.KEYCODE_DPAD_DOWN || + this == KeyEvent.KEYCODE_PAGE_UP || + this == KeyEvent.KEYCODE_PAGE_DOWN || + this == KeyEvent.KEYCODE_FORWARD_DEL || + this == KeyEvent.KEYCODE_INSERT || + this == KeyEvent.KEYCODE_MOVE_HOME || + this == KeyEvent.KEYCODE_MOVE_END || + this == KeyEvent.KEYCODE_SHIFT_LEFT || + this == KeyEvent.KEYCODE_SHIFT_RIGHT || + this == KeyEvent.KEYCODE_CTRL_LEFT || + this == KeyEvent.KEYCODE_CTRL_RIGHT || + this == KeyEvent.KEYCODE_ALT_LEFT || + this == KeyEvent.KEYCODE_ALT_RIGHT || + this == KeyEvent.KEYCODE_CAPS_LOCK || + this == KeyEvent.KEYCODE_NUM_LOCK || + this == KeyEvent.KEYCODE_SCROLL_LOCK || + this == KeyEvent.KEYCODE_MINUS || + this == KeyEvent.KEYCODE_EQUALS || + this == KeyEvent.KEYCODE_LEFT_BRACKET || + this == KeyEvent.KEYCODE_RIGHT_BRACKET || + this == KeyEvent.KEYCODE_BACKSLASH || + this == KeyEvent.KEYCODE_SEMICOLON || + this == KeyEvent.KEYCODE_APOSTROPHE || + this == KeyEvent.KEYCODE_COMMA || + this == KeyEvent.KEYCODE_PERIOD || + this == KeyEvent.KEYCODE_SLASH || + this == KeyEvent.KEYCODE_GRAVE || + this in KeyEvent.KEYCODE_A..KeyEvent.KEYCODE_Z || + this in KeyEvent.KEYCODE_0..KeyEvent.KEYCODE_9 || + this in KeyEvent.KEYCODE_NUMPAD_0..KeyEvent.KEYCODE_NUMPAD_9 || + this in KeyEvent.KEYCODE_F1..KeyEvent.KEYCODE_F12 + + private fun Int.isTextEntryKeyCode(): Boolean = + this == KeyEvent.KEYCODE_ENTER || + this == KeyEvent.KEYCODE_NUMPAD_ENTER || + this == KeyEvent.KEYCODE_ESCAPE || + this == KeyEvent.KEYCODE_DEL || + this == KeyEvent.KEYCODE_TAB || + this == KeyEvent.KEYCODE_SPACE || + this == KeyEvent.KEYCODE_FORWARD_DEL || + this == KeyEvent.KEYCODE_SHIFT_LEFT || + this == KeyEvent.KEYCODE_SHIFT_RIGHT || + this == KeyEvent.KEYCODE_CTRL_LEFT || + this == KeyEvent.KEYCODE_CTRL_RIGHT || + this == KeyEvent.KEYCODE_ALT_LEFT || + this == KeyEvent.KEYCODE_ALT_RIGHT || + this == KeyEvent.KEYCODE_CAPS_LOCK || + this == KeyEvent.KEYCODE_MINUS || + this == KeyEvent.KEYCODE_EQUALS || + this == KeyEvent.KEYCODE_LEFT_BRACKET || + this == KeyEvent.KEYCODE_RIGHT_BRACKET || + this == KeyEvent.KEYCODE_BACKSLASH || + this == KeyEvent.KEYCODE_SEMICOLON || + this == KeyEvent.KEYCODE_APOSTROPHE || + this == KeyEvent.KEYCODE_COMMA || + this == KeyEvent.KEYCODE_PERIOD || + this == KeyEvent.KEYCODE_SLASH || + this == KeyEvent.KEYCODE_GRAVE || + this in KeyEvent.KEYCODE_A..KeyEvent.KEYCODE_Z || + this in KeyEvent.KEYCODE_0..KeyEvent.KEYCODE_9 || + this in KeyEvent.KEYCODE_NUMPAD_0..KeyEvent.KEYCODE_NUMPAD_9 || + this in KeyEvent.KEYCODE_F1..KeyEvent.KEYCODE_F12 + + private fun MotionEvent.isNativeUiNavigationMotion(): Boolean = + isFromSource(InputDevice.SOURCE_JOYSTICK) || + isFromSource(InputDevice.SOURCE_GAMEPAD) || + AndroidControllerInput.isControllerEvent(source, deviceId) + + private fun MotionEvent.isFromSource(source: Int): Boolean = (this.source and source) == source + + private fun MotionEvent.isFingerTouchEvent(): Boolean = + isFromSource(InputDevice.SOURCE_TOUCHSCREEN) && + !isFromSource(InputDevice.SOURCE_MOUSE) && + !isFromSource(InputDevice.SOURCE_MOUSE_RELATIVE) + + private fun MotionEvent.isExternalMousePointerEvent(): Boolean { + val controllerSource = isFromSource(InputDevice.SOURCE_JOYSTICK) || isFromSource(InputDevice.SOURCE_GAMEPAD) + return isFromSource(InputDevice.SOURCE_MOUSE) || + isFromSource(InputDevice.SOURCE_MOUSE_RELATIVE) || + (isFromSource(InputDevice.SOURCE_TOUCHPAD) && !controllerSource) + } + + private fun shouldPassTouchToNativeUi(event: MotionEvent, width: Int, height: Int): Boolean { + if (event.isFingerTouchEvent()) { + updateNativeUiTouchPointers(event, width, height) + return eventHasNativeUiTouchPointer(event, width, height) && + !eventHasStreamTouchPointer(event, width, height) + } + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + nativeUiTouchRouting.setLegacyPassthroughActive( + pointerTouchesNativeUi(event, 0, width, height), + ) + return nativeUiTouchRouting.passthroughActive + } + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + val wasActive = nativeUiTouchRouting.passthroughActive + nativeUiTouchRouting.setLegacyPassthroughActive(false) + return wasActive + } + else -> if (nativeUiTouchRouting.passthroughActive) { + return true + } + } + return false + } + + private fun updateNativeUiTouchPointers(event: MotionEvent, width: Int, height: Int) { + if (!event.isFingerTouchEvent()) return + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + nativeUiTouchRouting.beginPointerGesture( + pointerId = event.getPointerId(0), + touchesUi = pointerTouchesNativeUi(event, 0, width, height), + ) + } + MotionEvent.ACTION_POINTER_DOWN -> { + val index = event.actionIndex + if (index in 0 until event.pointerCount) { + nativeUiTouchRouting.addPointer( + pointerId = event.getPointerId(index), + touchesUi = pointerTouchesNativeUi(event, index, width, height), + ) + } + } + } + } + + fun postDispatchTouch(event: MotionEvent) { + if (!event.isFingerTouchEvent()) return + when (event.actionMasked) { + MotionEvent.ACTION_POINTER_UP -> { + val index = event.actionIndex + if (index in 0 until event.pointerCount) { + nativeUiTouchRouting.releasePointer(event.getPointerId(index)) + } + } + MotionEvent.ACTION_UP, + MotionEvent.ACTION_CANCEL -> { + nativeUiTouchRouting.endPointerGesture() + } + } + } + + private fun eventHasNativeUiTouchPointer(event: MotionEvent, width: Int, height: Int): Boolean = + (0 until event.pointerCount).any { index -> + isNativeUiTouchPointer(event, index, width, height) + } + + private fun eventHasStreamTouchPointer(event: MotionEvent, width: Int, height: Int): Boolean = + (0 until event.pointerCount).any { index -> + !isNativeUiTouchPointer(event, index, width, height) + } + + private fun isNativeUiTouchPointer(event: MotionEvent, index: Int, width: Int, height: Int): Boolean = + nativeUiTouchRouting.classifiesPointerAsUi( + pointerId = event.getPointerId(index), + touchesUiNow = pointerTouchesNativeUi(event, index, width, height), + ) + + private fun pointerTouchesNativeUi(event: MotionEvent, index: Int, width: Int, height: Int): Boolean { + if (index !in 0 until event.pointerCount) return false + val x = event.getX(index) + val y = event.getY(index) + + // Narrow edge exclusion zone: 12dp is enough to capture system back-swipe gestures while + // still allowing game UI elements placed near the screen edges to be reached. + val density = android.content.res.Resources.getSystem().displayMetrics.density + val edgeWidthPx = 12f * density + val isNearEdge = !androidTvProfile && width > 0 && (x < edgeWidthPx || x > width - edgeWidthPx) + if (isNearEdge) return true + + return nativeUiTouchRouting.touchesRegisteredUi(x, y, width, height) + } +} + +enum class InputDataChannelRole { + Reliable, + PartiallyReliable, + Other, +} + +object InputDataChannelLabels { + fun classify(label: String): InputDataChannelRole = + when (label.lowercase(Locale.US)) { + "input_channel_v1", + "input_channel", + -> InputDataChannelRole.Reliable + "input_channel_partially_reliable", + "input_channel_pr", + -> InputDataChannelRole.PartiallyReliable + else -> InputDataChannelRole.Other + } +} + +internal enum class AndroidControllerFamily { + Google, + Xbox, + PlayStation, + Nintendo, + Generic, +} + +internal fun androidGamepadConnectionBitmap( + controllerId: Int, + connected: Boolean, + physicalControllerFamily: AndroidControllerFamily?, + playStationRumbleCompatibility: Boolean = false, +): Int { + if (!connected) return 0 + val id = controllerId.coerceIn(0, 3) + val connectedBit = 1 shl id + // The host protocol uses bit (slot + 8) to distinguish an Xbox/XInput-style pad from the + // PlayStation-style identity used by its native controller mapping. Unknown controllers and + // OpenNOW's virtual pad retain the established XInput fallback for compatibility. + val xinputStyleBit = if ( + physicalControllerFamily == AndroidControllerFamily.PlayStation && + !playStationRumbleCompatibility + ) { + 0 + } else { + 1 shl (id + 8) + } + return connectedBit or xinputStyleBit +} + +internal object AndroidControllerInput { + fun hasControllerSource(source: Int): Boolean = + source.hasSource(InputDevice.SOURCE_GAMEPAD) || + source.hasSource(InputDevice.SOURCE_JOYSTICK) + + fun isControllerDevice(device: InputDevice?): Boolean = + device != null && isControllerDevice(device.sources, device.name) + + fun isControllerDevice(source: Int, deviceName: String?): Boolean { + val knownController = isKnownControllerName(deviceName) + // Some OEM inputs and Bluetooth/USB receivers expose stray GAMEPAD or JOYSTICK source + // bits. Advertising those idle interfaces as an XInput pad makes games switch away from + // mouse/keyboard even though no controller exists. + if (!knownController && isClearlyNotController(deviceName)) return false + return hasControllerSource(source) || + (source.hasSource(InputDevice.SOURCE_DPAD) && knownController) + } + + fun isControllerEvent(source: Int, deviceId: Int): Boolean { + val device = InputDevice.getDevice(deviceId) + return if (device != null) { + isControllerDevice(device.sources or source, device.name) + } else { + hasControllerSource(source) + } + } + + fun isKnownControllerName(name: String?): Boolean { + val normalized = name.orEmpty().lowercase(Locale.US) + return normalized.contains("stadia controller") || + normalized == "stadia" || + normalized.contains("google stadia") || + normalized.contains("dualsense") || + normalized.contains("dualshock") || + normalized.contains("wireless controller") || + normalized.contains("xbox") || + normalized.contains("x-input") || + normalized.contains("xinput") || + normalized.contains("8bitdo") || + normalized.contains("gamesir") || + normalized.contains("backbone") || + normalized.contains("razer kishi") || + normalized.contains("switch pro") || + normalized.contains("gamepad") + } + + private fun isClearlyNotController(name: String?): Boolean { + val normalized = name.orEmpty().lowercase(Locale.US) + return normalized.contains("keyboard") || + normalized.contains("mouse") || + normalized.contains("touchpad") || + normalized.contains("trackpad") || + normalized.contains("uinput-goodix") || + normalized.contains("fingerprint") || + normalized.contains("uinput-fpc") + } + + fun controllerFamily(device: InputDevice?): AndroidControllerFamily? = + device + ?.takeIf(::isControllerDevice) + ?.let { controllerFamily(it.name, it.vendorId) } + + internal fun controllerFamily(name: String?, vendorId: Int = 0): AndroidControllerFamily { + val normalized = name.orEmpty().lowercase(Locale.US) + return when { + vendorId == SONY_VENDOR_ID -> AndroidControllerFamily.PlayStation + normalized.contains("stadia") || + normalized.contains("google") || + normalized.contains("chromecast") -> AndroidControllerFamily.Google + normalized.contains("xbox") || + normalized.contains("x-input") || + normalized.contains("xinput") -> AndroidControllerFamily.Xbox + normalized.contains("dualsense") || + normalized.contains("dualshock") || + normalized.contains("playstation") || + normalized.contains("wireless controller") -> AndroidControllerFamily.PlayStation + normalized.contains("switch") || normalized.contains("nintendo") -> AndroidControllerFamily.Nintendo + else -> AndroidControllerFamily.Generic + } + } + + fun isPrimaryActivationKey(keyCode: Int): Boolean = + keyCode == KeyEvent.KEYCODE_DPAD_CENTER || + keyCode == KeyEvent.KEYCODE_ENTER || + keyCode == KeyEvent.KEYCODE_NUMPAD_ENTER + + private fun Int.hasSource(source: Int): Boolean = (this and source) == source + + private const val SONY_VENDOR_ID = 0x054c +} + +internal data class AndroidControllerSlotAssignment( + val slot: Int, + val removedDevices: Map, +) + +internal object AndroidControllerSlotRegistry { + fun retainConnected( + controllerSlots: MutableMap, + connectedDeviceIds: Set, + ): Map { + val removedDevices = controllerSlots.filterKeys { it !in connectedDeviceIds } + removedDevices.keys.forEach(controllerSlots::remove) + return removedDevices + } + + fun assign( + controllerSlots: MutableMap, + deviceId: Int, + connectedDeviceIds: Set, + maxControllers: Int, + ): AndroidControllerSlotAssignment { + // Some Android controller stacks deliver synthetic events with deviceId=-1 even though + // the real InputDevice remains connected. Bind those events to a live controller ID so + // the periodic connection scan does not delete the synthetic slot every second. + val stableDeviceId = when { + deviceId >= 0 -> deviceId + else -> controllerSlots.entries + .filter { it.key in connectedDeviceIds } + .minByOrNull { it.value } + ?.key + ?: connectedDeviceIds.minOrNull() + ?: deviceId + } + val removedDevices = retainConnected( + controllerSlots = controllerSlots, + connectedDeviceIds = connectedDeviceIds + stableDeviceId, + ) + val existingSlot = controllerSlots[stableDeviceId] + if (existingSlot != null) { + return AndroidControllerSlotAssignment(existingSlot, removedDevices) + } + val usedSlots = controllerSlots.values.toSet() + val slot = (0 until maxControllers).firstOrNull { it !in usedSlots } ?: 0 + controllerSlots[stableDeviceId] = slot + return AndroidControllerSlotAssignment(slot, removedDevices) + } +} + +internal data class ControllerMouseDelta( + val dx: Int, + val dy: Int, +) + +internal fun shouldSendGamepadKeepalive( + hasControllerState: Boolean, + hasActiveControllerInput: Boolean, + touchMouseEnabled: Boolean, +): Boolean = hasControllerState && (!touchMouseEnabled || hasActiveControllerInput) + +internal object AndroidControllerMouseAssist { + fun mouseDelta(stickX: Float, stickY: Float): ControllerMouseDelta? { + if (!stickX.isFinite() || !stickY.isFinite()) return null + val x = stickX.coerceIn(-1f, 1f) + val y = stickY.coerceIn(-1f, 1f) + val magnitude = sqrt(x * x + y * y).coerceIn(0f, 1f) + if (magnitude < 0.001f) return null + val speed = CONTROLLER_MOUSE_BASE_DELTA_PX + CONTROLLER_MOUSE_ACCEL_DELTA_PX * magnitude * magnitude + val dx = (x * speed).roundToInt() + val dy = (y * speed).roundToInt() + return if (dx != 0 || dy != 0) ControllerMouseDelta(dx, dy) else null + } + + fun scrollNotches(stickY: Float, scrollSensitivity: Int, accumulator: Float): Pair { + if (!stickY.isFinite() || !accumulator.isFinite()) return Pair(0, 0f) + val y = stickY.coerceIn(-1f, 1f) + if (abs(y) < 0.1f) return Pair(0, accumulator) + + val sensitivity = scrollSensitivity.toFloat().coerceIn(10f, 100f) + val factor = 6.0f / sensitivity + val nextAccumulator = accumulator + -y * factor + val notches = nextAccumulator.toInt() + return Pair(notches, nextAccumulator - notches) + } + + fun mouseButtonForGamepad(buttonMask: Int): Int? = + when (buttonMask) { + GamepadButtonMapping.A -> 1 + GamepadButtonMapping.B -> 3 + else -> null + } + + fun mouseButtonForTrigger(left: Boolean): Int? = null + + private const val CONTROLLER_MOUSE_BASE_DELTA_PX = 7f + private const val CONTROLLER_MOUSE_ACCEL_DELTA_PX = 34f +} + +internal data class AndroidGamepadRawAxes( + val x: Float = 0f, + val y: Float = 0f, + val z: Float = 0f, + val rz: Float = 0f, + val rx: Float = 0f, + val ry: Float = 0f, + val hatX: Float = 0f, + val hatY: Float = 0f, +) + +internal data class AndroidGamepadAxisAvailability( + val x: Boolean = true, + val y: Boolean = true, + val z: Boolean = true, + val rz: Boolean = true, + val rx: Boolean = true, + val ry: Boolean = true, + val hatX: Boolean = true, + val hatY: Boolean = true, +) { + fun hasLeftStickPair(): Boolean = x && y + fun hasHatPair(): Boolean = hatX && hatY +} + +internal data class AndroidGamepadResolvedAxes( + val leftX: Float, + val leftY: Float, + val rightX: Float, + val rightY: Float, + val leftSource: String, + val rightSource: String, + val hatUsedAsLeftStick: Boolean, +) + +internal object AndroidGamepadAxisMapping { + fun resolve(raw: AndroidGamepadRawAxes, available: AndroidGamepadAxisAvailability = AndroidGamepadAxisAvailability()): AndroidGamepadResolvedAxes { + val rightUsesZRz = axisPairActive(raw.z, raw.rz) || !axisPairActive(raw.rx, raw.ry) + val rightX = if (rightUsesZRz) raw.z else raw.rx + val rightY = if (rightUsesZRz) raw.rz else raw.ry + val rightSource = if (rightUsesZRz) "z/rz" else "rx/ry" + + val useHatForLeft = + !available.hasLeftStickPair() && + available.hasHatPair() && + axisPairActive(raw.hatX, raw.hatY) + val leftX = if (useHatForLeft) raw.hatX else raw.x + val leftY = if (useHatForLeft) raw.hatY else raw.y + return AndroidGamepadResolvedAxes( + leftX = leftX, + leftY = leftY, + rightX = rightX, + rightY = rightY, + leftSource = if (useHatForLeft) "hat" else "x/y", + rightSource = rightSource, + hatUsedAsLeftStick = useHatForLeft, + ) + } + + private fun axisPairActive(x: Float, y: Float): Boolean = + abs(x) > AXIS_NOISE || abs(y) > AXIS_NOISE + + private const val AXIS_NOISE = 0.001f +} + +internal data class GamepadRumbleCommand( + val controllerId: Int, + val weakMagnitude: Int, + val strongMagnitude: Int, +) + +internal object HapticsPacketParser { + fun parse(bytes: ByteArray): GamepadRumbleCommand? { + if (bytes.size < 2) return null + val view = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN) + val firstWord = view.getShort(0).toInt() and 0xffff + if (firstWord == LEGACY_HAPTIC_SUBMESSAGE_TYPE) { + return parseLegacy(view, 2) + } + + return when (firstWord and 0xff) { + WRAPPER_SINGLE_EVENT -> parseSubMessage(view, 1) + WRAPPER_BATCHED_EVENT, + WRAPPER_LEGACY_INPUT, + WRAPPER_TIMESTAMPED_SINGLE, + WRAPPER_TIMESTAMPED_BATCHED, + WRAPPER_RESERVED, + -> null + else -> parseLegacy(view, 0) + } + } + + private fun parseSubMessage(view: ByteBuffer, offset: Int): GamepadRumbleCommand? { + if (offset < 0 || offset + 4 > view.limit()) return null + val type = view.getInt(offset) + return when (type) { + LEGACY_HAPTIC_SUBMESSAGE_TYPE -> parseLegacy(view, offset + 4) + OC_HAPTIC_SUBMESSAGE_TYPE -> parseOc(view, offset + 4) + else -> null + } + } + + private fun parseLegacy(view: ByteBuffer, offset: Int): GamepadRumbleCommand? { + if (offset < 0 || offset + 10 > view.limit()) return null + val kind = view.getShort(offset).toInt() and 0xffff + if (kind != 1) return null + val length = view.getShort(offset + 2).toInt() and 0xffff + if (length < 6) return null + return GamepadRumbleCommand( + controllerId = view.getShort(offset + 4).toInt() and 0xffff, + weakMagnitude = view.getShort(offset + 6).toInt() and 0xffff, + strongMagnitude = view.getShort(offset + 8).toInt() and 0xffff, + ) + } + + private fun parseOc(view: ByteBuffer, offset: Int): GamepadRumbleCommand? { + if (offset < 0 || offset + 9 > view.limit()) return null + val controllerByte = view.get(offset).toInt() and 0xff + if (controllerByte !in 6 until 10) return null + val reportKind = view.get(offset + 3).toInt() and 0xff + val flags = view.get(offset + 4).toInt() and 0xff + if (reportKind != 5 || (flags and 0xfe) != 0) return null + return GamepadRumbleCommand( + controllerId = controllerByte - 6, + weakMagnitude = (view.get(offset + 7).toInt() and 0xff) shl 8, + strongMagnitude = (view.get(offset + 8).toInt() and 0xff) shl 8, + ) + } + + private const val LEGACY_HAPTIC_SUBMESSAGE_TYPE = 267 + private const val OC_HAPTIC_SUBMESSAGE_TYPE = 17 + private const val WRAPPER_BATCHED_EVENT = 32 + private const val WRAPPER_LEGACY_INPUT = 33 + private const val WRAPPER_SINGLE_EVENT = 34 + private const val WRAPPER_TIMESTAMPED_SINGLE = 35 + private const val WRAPPER_TIMESTAMPED_BATCHED = 36 + private const val WRAPPER_RESERVED = 255 +} + +internal object GamepadButtonMapping { + const val DPAD_UP = 0x0001 + const val DPAD_DOWN = 0x0002 + const val DPAD_LEFT = 0x0004 + const val DPAD_RIGHT = 0x0008 + const val START = 0x0010 + const val BACK = 0x0020 + const val LEFT_THUMB = 0x0040 + const val RIGHT_THUMB = 0x0080 + const val LEFT_SHOULDER = 0x0100 + const val RIGHT_SHOULDER = 0x0200 + const val GUIDE = 0x0400 + const val A = 0x1000 + const val B = 0x2000 + const val X = 0x4000 + const val Y = 0x8000 + + fun maskForKeyCode(keyCode: Int, controllerActivation: Boolean = false): Int? = when (keyCode) { + KeyEvent.KEYCODE_MENU -> if (controllerActivation) START else null + KeyEvent.KEYCODE_BACK -> if (controllerActivation) BACK else null + KeyEvent.KEYCODE_DPAD_UP -> DPAD_UP + KeyEvent.KEYCODE_DPAD_DOWN -> DPAD_DOWN + KeyEvent.KEYCODE_DPAD_LEFT -> DPAD_LEFT + KeyEvent.KEYCODE_DPAD_RIGHT -> DPAD_RIGHT + KeyEvent.KEYCODE_DPAD_CENTER, + KeyEvent.KEYCODE_ENTER, + KeyEvent.KEYCODE_NUMPAD_ENTER, + -> if (controllerActivation) A else null + KeyEvent.KEYCODE_BUTTON_START -> START + KeyEvent.KEYCODE_BUTTON_SELECT -> BACK + KeyEvent.KEYCODE_BUTTON_THUMBL -> LEFT_THUMB + KeyEvent.KEYCODE_BUTTON_THUMBR -> RIGHT_THUMB + KeyEvent.KEYCODE_BUTTON_L1 -> LEFT_SHOULDER + KeyEvent.KEYCODE_BUTTON_R1 -> RIGHT_SHOULDER + KeyEvent.KEYCODE_BUTTON_MODE -> GUIDE + KeyEvent.KEYCODE_BUTTON_A -> A + KeyEvent.KEYCODE_BUTTON_B -> B + KeyEvent.KEYCODE_BUTTON_X -> X + KeyEvent.KEYCODE_BUTTON_Y -> Y + else -> null + } + + fun isControllerButtonKeyCode(keyCode: Int): Boolean = + keyCode == KeyEvent.KEYCODE_BUTTON_L2 || + keyCode == KeyEvent.KEYCODE_BUTTON_R2 || + keyCode in KeyEvent.KEYCODE_BUTTON_A..KeyEvent.KEYCODE_BUTTON_MODE +} + +internal object SteamMenuChord { + // Send only the Guide (Home) button. The GUIDE+A chord previously used caused unintended + // A-button input during gameplay; a plain Guide press is sufficient to open Steam overlay. + fun buttons(aPressed: Boolean): Int = GamepadButtonMapping.GUIDE +} + +internal class SteamOverlayChordState { + private var latched = false + private var chordPressed = false + + fun update(rawButtons: Int): Boolean { + val topButtons = rawButtons and TOP_BUTTONS + val activated = !latched && topButtons == TOP_BUTTONS + if (activated) { + latched = true + chordPressed = true + } else if (latched && topButtons == 0) { + latched = false + chordPressed = false + } + return activated + } + + fun effectiveButtons(rawButtons: Int): Int { + val withoutTopButtons = if (latched) rawButtons and TOP_BUTTONS.inv() else rawButtons + return if (chordPressed) withoutTopButtons or SteamMenuChord.buttons(aPressed = true) else withoutTopButtons + } + + fun releaseChord(): Boolean { + if (!chordPressed) return false + chordPressed = false + return true + } + + fun reset() { + latched = false + chordPressed = false + } + + private companion object { + const val TOP_BUTTONS = 0x0030 + } +} diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/StreamInteraction.kt b/android/app/src/main/java/com/opencloudgaming/opennow/StreamInteraction.kt new file mode 100644 index 000000000..e3033032b --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/StreamInteraction.kt @@ -0,0 +1,1361 @@ +package com.opencloudgaming.opennow + +import android.opengl.GLES11Ext +import android.opengl.GLES20 +import android.view.MotionEvent +import org.webrtc.GlRectDrawer +import org.webrtc.GlShader +import org.webrtc.GlUtil +import org.webrtc.RendererCommon +import java.nio.FloatBuffer +import kotlin.math.abs +import kotlin.math.min +import kotlin.math.roundToInt +import kotlin.math.sqrt + +internal fun streamSharpnessShaderStrength(enabled: Boolean, amount: Float): Float = + if (enabled) amount.coerceIn(0f, 1f) * STREAM_SHARPNESS_MAX_SHADER_STRENGTH else 0f + +private const val STREAM_SHARPNESS_MAX_SHADER_STRENGTH = 0.28f + +internal class StreamSharpnessGlDrawer : RendererCommon.GlDrawer { + @Volatile + var amount: Float = 0f + + // Keep disabled sharpening on WebRTC's native pass-through path. Enabling the setting still + // takes effect on the next frame without recreating the SurfaceViewRenderer. + private val passthroughDrawer = GlRectDrawer() + private val vertexBuffer: FloatBuffer = GlUtil.createFloatBuffer( + floatArrayOf( + -1f, -1f, + 1f, -1f, + -1f, 1f, + 1f, 1f, + ), + ) + private val textureBuffer: FloatBuffer = GlUtil.createFloatBuffer( + floatArrayOf( + 0f, 0f, + 1f, 0f, + 0f, 1f, + 1f, 1f, + ), + ) + + private var oesProgram: SharpnessProgram? = null + private var rgbProgram: SharpnessProgram? = null + private var yuvProgram: SharpnessProgram? = null + + override fun drawOes( + oesTextureId: Int, + texMatrix: FloatArray, + frameWidth: Int, + frameHeight: Int, + viewportX: Int, + viewportY: Int, + viewportWidth: Int, + viewportHeight: Int, + ) { + val strength = amount + if (!streamSharpnessShaderActive(strength)) { + passthroughDrawer.drawOes( + oesTextureId, + texMatrix, + frameWidth, + frameHeight, + viewportX, + viewportY, + viewportWidth, + viewportHeight, + ) + return + } + val program = oesProgram ?: SharpnessProgram(SHARPEN_OES_FRAGMENT_SHADER, TextureMode.Oes).also { oesProgram = it } + program.draw( + textureIds = intArrayOf(oesTextureId), + textureTarget = GLES11Ext.GL_TEXTURE_EXTERNAL_OES, + texMatrix = texMatrix, + frameWidth = frameWidth, + frameHeight = frameHeight, + viewportX = viewportX, + viewportY = viewportY, + viewportWidth = viewportWidth, + viewportHeight = viewportHeight, + amount = strength, + vertexBuffer = vertexBuffer, + textureBuffer = textureBuffer, + ) + } + + override fun drawRgb( + textureId: Int, + texMatrix: FloatArray, + frameWidth: Int, + frameHeight: Int, + viewportX: Int, + viewportY: Int, + viewportWidth: Int, + viewportHeight: Int, + ) { + val strength = amount + if (!streamSharpnessShaderActive(strength)) { + passthroughDrawer.drawRgb( + textureId, + texMatrix, + frameWidth, + frameHeight, + viewportX, + viewportY, + viewportWidth, + viewportHeight, + ) + return + } + val program = rgbProgram ?: SharpnessProgram(SHARPEN_RGB_FRAGMENT_SHADER, TextureMode.Rgb).also { rgbProgram = it } + program.draw( + textureIds = intArrayOf(textureId), + textureTarget = GLES20.GL_TEXTURE_2D, + texMatrix = texMatrix, + frameWidth = frameWidth, + frameHeight = frameHeight, + viewportX = viewportX, + viewportY = viewportY, + viewportWidth = viewportWidth, + viewportHeight = viewportHeight, + amount = strength, + vertexBuffer = vertexBuffer, + textureBuffer = textureBuffer, + ) + } + + override fun drawYuv( + yuvTextures: IntArray, + texMatrix: FloatArray, + frameWidth: Int, + frameHeight: Int, + viewportX: Int, + viewportY: Int, + viewportWidth: Int, + viewportHeight: Int, + ) { + val strength = amount + if (!streamSharpnessShaderActive(strength)) { + passthroughDrawer.drawYuv( + yuvTextures, + texMatrix, + frameWidth, + frameHeight, + viewportX, + viewportY, + viewportWidth, + viewportHeight, + ) + return + } + val program = yuvProgram ?: SharpnessProgram(SHARPEN_YUV_FRAGMENT_SHADER, TextureMode.Yuv).also { yuvProgram = it } + program.draw( + textureIds = yuvTextures, + textureTarget = GLES20.GL_TEXTURE_2D, + texMatrix = texMatrix, + frameWidth = frameWidth, + frameHeight = frameHeight, + viewportX = viewportX, + viewportY = viewportY, + viewportWidth = viewportWidth, + viewportHeight = viewportHeight, + amount = strength, + vertexBuffer = vertexBuffer, + textureBuffer = textureBuffer, + ) + } + + override fun release() { + passthroughDrawer.release() + oesProgram?.release() + rgbProgram?.release() + yuvProgram?.release() + oesProgram = null + rgbProgram = null + yuvProgram = null + } + + private class SharpnessProgram(fragmentShader: String, private val mode: TextureMode) { + private val shader = GlShader(SHARPEN_VERTEX_SHADER, fragmentShader) + private val texMatrixLocation = shader.getUniformLocation("tex_mat") + private val sharpnessLocation = shader.getUniformLocation("sharpness") + private val texelSizeLocation = shader.getUniformLocation("texel_size") + private val textureLocations: IntArray = when (mode) { + TextureMode.Oes, + TextureMode.Rgb, + -> intArrayOf(shader.getUniformLocation("tex")) + TextureMode.Yuv -> intArrayOf( + shader.getUniformLocation("y_tex"), + shader.getUniformLocation("u_tex"), + shader.getUniformLocation("v_tex"), + ) + } + + fun draw( + textureIds: IntArray, + textureTarget: Int, + texMatrix: FloatArray, + frameWidth: Int, + frameHeight: Int, + viewportX: Int, + viewportY: Int, + viewportWidth: Int, + viewportHeight: Int, + amount: Float, + vertexBuffer: FloatBuffer, + textureBuffer: FloatBuffer, + ) { + shader.useProgram() + GLES20.glViewport(viewportX, viewportY, viewportWidth, viewportHeight) + shader.setVertexAttribArray("in_pos", 2, vertexBuffer) + shader.setVertexAttribArray("in_tc", 2, textureBuffer) + GLES20.glUniformMatrix4fv(texMatrixLocation, 1, false, texMatrix, 0) + GLES20.glUniform1f(sharpnessLocation, amount.coerceIn(0f, STREAM_SHARPNESS_MAX_SHADER_STRENGTH)) + GLES20.glUniform2f( + texelSizeLocation, + 1f / frameWidth.coerceAtLeast(1).toFloat(), + 1f / frameHeight.coerceAtLeast(1).toFloat(), + ) + textureLocations.forEachIndexed { index, location -> + GLES20.glActiveTexture(GLES20.GL_TEXTURE0 + index) + GLES20.glBindTexture(textureTarget, textureIds.getOrElse(index) { 0 }) + GLES20.glUniform1i(location, index) + } + GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4) + textureLocations.indices.forEach { index -> + GLES20.glActiveTexture(GLES20.GL_TEXTURE0 + index) + GLES20.glBindTexture(textureTarget, 0) + } + GlUtil.checkNoGLES2Error("StreamSharpnessGlDrawer.draw") + } + + fun release() { + shader.release() + } + } + + private enum class TextureMode { + Oes, + Rgb, + Yuv, + } + + private companion object { + private const val SHARPEN_VERTEX_SHADER = """ + attribute vec4 in_pos; + attribute vec2 in_tc; + uniform mat4 tex_mat; + varying vec2 tc; + + void main() { + gl_Position = in_pos; + tc = (tex_mat * vec4(in_tc, 0.0, 1.0)).xy; + } + """ + + private const val SHARPEN_BODY = """ + uniform float sharpness; + uniform vec2 texel_size; + varying vec2 tc; + + void main() { + vec4 center = sampleColor(tc); + if (sharpness <= 0.001) { + gl_FragColor = center; + return; + } + vec3 north = sampleColor(tc + vec2(0.0, -texel_size.y)).rgb; + vec3 south = sampleColor(tc + vec2(0.0, texel_size.y)).rgb; + vec3 west = sampleColor(tc + vec2(-texel_size.x, 0.0)).rgb; + vec3 east = sampleColor(tc + vec2(texel_size.x, 0.0)).rgb; + vec3 sharpened = center.rgb * (1.0 + 4.0 * sharpness) - (north + south + west + east) * sharpness; + gl_FragColor = vec4(clamp(sharpened, 0.0, 1.0), center.a); + } + """ + + private const val SHARPEN_OES_FRAGMENT_SHADER = """ + #extension GL_OES_EGL_image_external : require + precision mediump float; + uniform samplerExternalOES tex; + vec4 sampleColor(vec2 pos) { + return texture2D(tex, pos); + } + """ + SHARPEN_BODY + + private const val SHARPEN_RGB_FRAGMENT_SHADER = """ + precision mediump float; + uniform sampler2D tex; + vec4 sampleColor(vec2 pos) { + return texture2D(tex, pos); + } + """ + SHARPEN_BODY + + private const val SHARPEN_YUV_FRAGMENT_SHADER = """ + precision mediump float; + uniform sampler2D y_tex; + uniform sampler2D u_tex; + uniform sampler2D v_tex; + vec4 sampleColor(vec2 pos) { + float y = texture2D(y_tex, pos).r; + float u = texture2D(u_tex, pos).r - 0.5; + float v = texture2D(v_tex, pos).r - 0.5; + return vec4( + y + 1.403 * v, + y - 0.344 * u - 0.714 * v, + y + 1.770 * u, + 1.0 + ); + } + """ + SHARPEN_BODY + } +} + +internal fun streamSharpnessShaderActive(amount: Float): Boolean = + amount.isFinite() && amount > STREAM_SHARPNESS_ACTIVE_EPSILON + +private const val STREAM_SHARPNESS_ACTIVE_EPSILON = 0.001f + +internal sealed interface StreamLivenessAction { + data object None : StreamLivenessAction + data class RequestKeyframe(val stalledMs: Long, val attempt: Int) : StreamLivenessAction + data class RestartTransport(val stalledMs: Long) : StreamLivenessAction +} + +internal class StreamLivenessWatchdog( + private val keyframeAfterMs: Long = MEDIA_STALL_KEYFRAME_AFTER_MS, + private val keyframeIntervalMs: Long = MEDIA_STALL_KEYFRAME_INTERVAL_MS, + private val restartAfterMs: Long = MEDIA_STALL_RESTART_AFTER_MS, +) { + private var lastProgressAtMs: Long? = null + private var lastBytesReceived: Long? = null + private var lastFramesDecoded: Long? = null + private var lastKeyframeRequestAtMs = Long.MIN_VALUE + private var keyframeAttempts = 0 + var latestObservationProgressed: Boolean = false + private set + + fun reset() { + lastProgressAtMs = null + lastBytesReceived = null + lastFramesDecoded = null + lastKeyframeRequestAtMs = Long.MIN_VALUE + keyframeAttempts = 0 + latestObservationProgressed = false + } + + fun markConnected(nowMs: Long) { + lastProgressAtMs = nowMs + lastKeyframeRequestAtMs = Long.MIN_VALUE + keyframeAttempts = 0 + } + + fun observe(nowMs: Long, bytesReceived: Long?, framesDecoded: Long?, connected: Boolean): StreamLivenessAction { + latestObservationProgressed = false + if (!connected) { + reset() + return StreamLivenessAction.None + } + + val progressed = if (framesDecoded != null) { + lastFramesDecoded?.let { framesDecoded > it } ?: (framesDecoded > 0) + } else { + bytesReceived != null && (lastBytesReceived?.let { bytesReceived > it } ?: (bytesReceived > 0)) + } + if (bytesReceived != null) lastBytesReceived = bytesReceived + if (framesDecoded != null) lastFramesDecoded = framesDecoded + if (progressed) { + latestObservationProgressed = true + lastProgressAtMs = nowMs + lastKeyframeRequestAtMs = Long.MIN_VALUE + keyframeAttempts = 0 + return StreamLivenessAction.None + } + + val stalledMs = nowMs - (lastProgressAtMs ?: nowMs.also { lastProgressAtMs = it }) + if (stalledMs >= restartAfterMs) { + reset() + return StreamLivenessAction.RestartTransport(stalledMs) + } + val keyframeDue = lastKeyframeRequestAtMs == Long.MIN_VALUE || + nowMs - lastKeyframeRequestAtMs >= keyframeIntervalMs + if (stalledMs >= keyframeAfterMs && keyframeDue) { + lastKeyframeRequestAtMs = nowMs + keyframeAttempts += 1 + return StreamLivenessAction.RequestKeyframe(stalledMs, keyframeAttempts) + } + return StreamLivenessAction.None + } +} + +internal data class StreamRecoveryTiming( + val keyframeAfterMs: Long, + val keyframeIntervalMs: Long, + val restartAfterMs: Long, +) + +internal fun streamRecoveryTiming(androidTvProfile: Boolean): StreamRecoveryTiming = + if (androidTvProfile) { + StreamRecoveryTiming( + keyframeAfterMs = TV_MEDIA_STALL_KEYFRAME_AFTER_MS, + keyframeIntervalMs = TV_MEDIA_STALL_KEYFRAME_INTERVAL_MS, + restartAfterMs = TV_MEDIA_STALL_RESTART_AFTER_MS, + ) + } else { + StreamRecoveryTiming( + keyframeAfterMs = MEDIA_STALL_KEYFRAME_AFTER_MS, + keyframeIntervalMs = MEDIA_STALL_KEYFRAME_INTERVAL_MS, + restartAfterMs = MEDIA_STALL_RESTART_AFTER_MS, + ) + } + +internal fun firstVideoFrameRecoveryTimeoutMs(androidTvProfile: Boolean): Long = + streamRecoveryTiming(androidTvProfile).restartAfterMs + +internal enum class FirstFrameRecoveryStep { + RetryRequestedProfile, + RetrySelectedProfile, + ContinueBoundedTransportRecovery, +} + +internal data class DecodedResolutionTransition( + val previousWidth: Int?, + val previousHeight: Int?, + val width: Int, + val height: Int, +) { + val isInitial: Boolean + get() = previousWidth == null || previousHeight == null +} + +/** Tracks actual decoder output sizes; every valid change is accepted without transport policy. */ +internal class DecodedResolutionTracker { + private var width: Int? = null + private var height: Int? = null + + @Synchronized + fun observe(width: Int, height: Int): DecodedResolutionTransition? { + if (width <= 0 || height <= 0) return null + if (this.width == width && this.height == height) return null + return DecodedResolutionTransition( + previousWidth = this.width, + previousHeight = this.height, + width = width, + height = height, + ).also { + this.width = width + this.height = height + } + } +} + +internal fun firstFrameRecoveryStep( + transportHasStableMedia: Boolean, + reconnectAttempts: Int, + selectedProfileRetryApplied: Boolean, +): FirstFrameRecoveryStep = when { + transportHasStableMedia -> FirstFrameRecoveryStep.ContinueBoundedTransportRecovery + reconnectAttempts == 0 -> FirstFrameRecoveryStep.RetryRequestedProfile + !selectedProfileRetryApplied -> FirstFrameRecoveryStep.RetrySelectedProfile + else -> FirstFrameRecoveryStep.ContinueBoundedTransportRecovery +} + +internal fun transportRestartShouldRetrySelectedProfile( + videoFailure: Boolean, + reconnectAttempts: Int, + transportHasStableMedia: Boolean, +): Boolean = videoFailure && reconnectAttempts >= 1 && !transportHasStableMedia + +internal fun repeatedStableMediaStallShouldRetrySelectedProfile( + androidTvProfile: Boolean, + transportCodec: VideoCodec, + completedStableMediaStallRestarts: Int, + selectedProfileRetryApplied: Boolean, +): Boolean = + androidTvProfile && + transportCodec != VideoCodec.H264 && + completedStableMediaStallRestarts >= 2 && + !selectedProfileRetryApplied + +internal fun newStreamLivenessWatchdog(androidTvProfile: Boolean): StreamLivenessWatchdog { + val timing = streamRecoveryTiming(androidTvProfile) + return StreamLivenessWatchdog( + keyframeAfterMs = timing.keyframeAfterMs, + keyframeIntervalMs = timing.keyframeIntervalMs, + restartAfterMs = timing.restartAfterMs, + ) +} + +internal class FirstVideoFrameWatchdog( + private val timeoutMs: Long = FIRST_VIDEO_FRAME_TIMEOUT_MS, +) { + private var bytesWithoutFrameSinceMs: Long? = null + private var rendered = false + + @Synchronized + fun reset() { + bytesWithoutFrameSinceMs = null + rendered = false + } + + @Synchronized + fun markRendered() { + rendered = true + bytesWithoutFrameSinceMs = null + } + + @Synchronized + fun shouldRecover(nowMs: Long, bytesReceived: Long?, connected: Boolean): Boolean { + if (!connected || rendered || bytesReceived == null || bytesReceived <= 0L) { + if (!connected) bytesWithoutFrameSinceMs = null + return false + } + val startedAt = bytesWithoutFrameSinceMs ?: nowMs.also { bytesWithoutFrameSinceMs = it } + return nowMs - startedAt >= timeoutMs + } +} + +internal data class MouseMotionDelta( + val dx: Int, + val dy: Int, +) + +/** Applies mouse tuning in float space and retains the wire format's subpixel rounding residual. */ +internal class MouseMotionAccumulator( + private val minimumSendIntervalMs: Long = 8L, +) { + private var pendingDx = 0f + private var pendingDy = 0f + private var lastSendTimeMs = Long.MIN_VALUE + + fun reset() { + pendingDx = 0f + pendingDy = 0f + lastSendTimeMs = Long.MIN_VALUE + } + + fun add( + dx: Float, + dy: Float, + eventTimeMs: Long, + sensitivity: Float, + acceleration: Int, + force: Boolean = false, + ): MouseMotionDelta? { + if (!dx.isFinite() || !dy.isFinite() || !sensitivity.isFinite()) { + reset() + return null + } + var adjustedDx = dx * sensitivity + var adjustedDy = dy * sensitivity + if (acceleration > 1) { + val speed = sqrt(adjustedDx * adjustedDx + adjustedDy * adjustedDy) + val strength = (acceleration - 1f) / 149f + val accelerationFactor = 1f + min(0.6f * strength, (speed / 50f) * strength) + adjustedDx *= accelerationFactor + adjustedDy *= accelerationFactor + } + if (!adjustedDx.isFinite() || !adjustedDy.isFinite()) { + reset() + return null + } + pendingDx += adjustedDx + pendingDy += adjustedDy + if (!pendingDx.isFinite() || !pendingDy.isFinite()) { + reset() + return null + } + + val elapsedSinceSend = eventTimeMs - lastSendTimeMs + if ( + !force && + lastSendTimeMs != Long.MIN_VALUE && + elapsedSinceSend in 0 until minimumSendIntervalMs + ) { + return null + } + + val sendDx = pendingDx.roundToInt() + val sendDy = pendingDy.roundToInt() + if (sendDx == 0 && sendDy == 0) return null + + pendingDx -= sendDx + pendingDy -= sendDy + lastSendTimeMs = eventTimeMs + return MouseMotionDelta(sendDx, sendDy) + } +} + +/** A point in the stream's own pixel space, as produced by [streamPointForTouch]. */ +internal data class StreamPoint(val x: Float, val y: Float) + +/** Phase of a single finger, as the host expects it on the wire. */ +internal object TouchPhase { + const val DOWN = 1 + const val UP = 2 + const val MOVE = 4 + const val CANCEL = 8 +} + +/** Touch coordinates travel as an unsigned 16-bit fraction of the video area. */ +internal const val TOUCH_COORDINATE_MAX = 65535 + +/** The host tracks at most this many fingers at once. */ +internal const val MAX_CONCURRENT_TOUCHES = 8 + +/** One packet carries at most this many records. */ +internal const val MAX_TOUCH_RECORDS_PER_BATCH = 40 + +/** + * One finger in one packet. [slot] is the host's finger index — deliberately not the platform's + * pointer id, see [TouchSlotAllocator]. + */ +internal data class TouchRecord( + val slot: Int, + val phase: Int, + val x: Int, + val y: Int, + val radiusX: Int = 0, + val radiusY: Int = 0, + val timestampUs: Long = 0L, +) + +/** + * Maps platform pointer ids onto the small, dense finger indices the host expects. + * + * Android pointer ids are arbitrary and can climb without bound across a session; the host wants + * the lowest free index, reused as soon as a finger lifts. Forwarding pointer ids directly would + * make the host see fingers appear at ever-higher indices and eventually run past its own limit. + * + * Kept free of `MotionEvent` so it can be tested on the JVM. + */ +internal class TouchSlotAllocator { + private val slotByPointerId = mutableMapOf() + private val usedSlots = mutableSetOf() + + val activeCount: Int get() = slotByPointerId.size + + /** Slot for [pointerId], allocating the lowest free one. Null when all slots are in use. */ + fun acquire(pointerId: Int): Int? { + slotByPointerId[pointerId]?.let { return it } + var slot = 0 + while (slot in usedSlots) slot++ + if (slot >= MAX_CONCURRENT_TOUCHES) return null + slotByPointerId[pointerId] = slot + usedSlots.add(slot) + return slot + } + + /** Slot for [pointerId] without allocating one. */ + fun peek(pointerId: Int): Int? = slotByPointerId[pointerId] + + /** Frees the slot held by [pointerId], returning it so the caller can still report the lift. */ + fun release(pointerId: Int): Int? { + val slot = slotByPointerId.remove(pointerId) ?: return null + usedSlots.remove(slot) + return slot + } + + fun activePointerIds(): List = slotByPointerId.keys.toList() + + fun clear() { + slotByPointerId.clear() + usedSlots.clear() + } +} + +/** One finger as the platform reported it, before any mapping. */ +internal data class TouchPointerSample( + val pointerId: Int, + val x: Float, + val y: Float, + val radiusX: Float = 0f, + val radiusY: Float = 0f, +) + +/** + * Turns one input event's fingers into the records for a single packet. + * + * Holds every rule that is easy to get subtly wrong — slot allocation, normalising to the video + * area, and what to do with a finger that is outside it — and takes no `MotionEvent`, so all of it + * is testable on the JVM. + */ +internal fun buildTouchBatch( + allocator: TouchSlotAllocator, + phase: Int, + pointers: List, + viewWidth: Int, + viewHeight: Int, + streamWidth: Int, + streamHeight: Int, + stretchToFit: Boolean, + renderingAspectRatio: Float, + presentationZoomScale: Float = 1f, + presentationTranslationX: Float = 0f, + presentationTranslationY: Float = 0f, + timestampUs: Long = 0L, +): List { + if (viewWidth <= 0 || viewHeight <= 0 || streamWidth <= 0 || streamHeight <= 0) return emptyList() + + // A lift must always be reported, wherever the finger ended up. Swallowing one leaves the host + // holding that finger down for the rest of the session. + val lifting = phase == TouchPhase.UP || phase == TouchPhase.CANCEL + val records = ArrayList(pointers.size) + + for (pointer in pointers) { + if (records.size >= MAX_TOUCH_RECORDS_PER_BATCH) break + + val point = streamPointForTouch( + touchX = pointer.x, + touchY = pointer.y, + viewWidth = viewWidth, + viewHeight = viewHeight, + streamWidth = streamWidth, + streamHeight = streamHeight, + stretchToFit = stretchToFit, + renderingAspectRatio = renderingAspectRatio, + presentationZoomScale = presentationZoomScale, + presentationTranslationX = presentationTranslationX, + presentationTranslationY = presentationTranslationY, + clamp = false, + ) + val x = point.x / streamWidth * TOUCH_COORDINATE_MAX + val y = point.y / streamHeight * TOUCH_COORDINATE_MAX + val radiusX = pointer.radiusX / streamWidth * TOUCH_COORDINATE_MAX + val radiusY = pointer.radiusY / streamHeight * TOUCH_COORDINATE_MAX + + val finiteSample = x.isFinite() && y.isFinite() && radiusX.isFinite() && radiusY.isFinite() + if (!finiteSample && !lifting) continue + + val safeX = if (x.isFinite()) x else 0f + val safeY = if (y.isFinite()) y else 0f + val safeRadiusX = if (radiusX.isFinite()) radiusX.coerceAtLeast(0f) else 0f + val safeRadiusY = if (radiusY.isFinite()) radiusY.coerceAtLeast(0f) else 0f + val safePointerRadiusX = if (pointer.radiusX.isFinite()) pointer.radiusX.coerceAtLeast(0f) else 0f + val safePointerRadiusY = if (pointer.radiusY.isFinite()) pointer.radiusY.coerceAtLeast(0f) else 0f + val outside = safeX < -safeRadiusX || safeX > TOUCH_COORDINATE_MAX + safeRadiusX || + safeY < -safeRadiusY || safeY > TOUCH_COORDINATE_MAX + safeRadiusY + if (outside && !lifting) continue + + val slot = ( + if (lifting) allocator.release(pointer.pointerId) else allocator.acquire(pointer.pointerId) + ) ?: continue + + records += TouchRecord( + slot = slot, + phase = phase, + x = safeX.roundToInt().coerceIn(0, TOUCH_COORDINATE_MAX), + y = safeY.roundToInt().coerceIn(0, TOUCH_COORDINATE_MAX), + radiusX = safePointerRadiusX.roundToInt().coerceAtLeast(0), + radiusY = safePointerRadiusY.roundToInt().coerceAtLeast(0), + timestampUs = timestampUs, + ) + } + return records +} + +/** + * Maps a touch inside a view of [viewWidth] x [viewHeight] onto the stream's pixel space, undoing + * the presentation zoom/pan first, then the letterbox/pillarbox bars the renderer adds whenever + * the view and the stream disagree about aspect ratio. + * + * Everything it needs arrives as an argument, and the result is expressed as a fraction of the + * view — which is why a window resize (PiP, rotation, minimise) needs no cursor bookkeeping at all. + * The event carries the view size it was measured against, so even a size captured mid-resize maps + * that event correctly. + */ +internal fun streamPointForTouch( + touchX: Float, + touchY: Float, + viewWidth: Int, + viewHeight: Int, + streamWidth: Int, + streamHeight: Int, + stretchToFit: Boolean, + renderingAspectRatio: Float, + presentationZoomScale: Float = 1f, + presentationTranslationX: Float = 0f, + presentationTranslationY: Float = 0f, + /** + * Clamping is right for a cursor, which must land somewhere. Native touch passes false so it + * can tell a finger on the letterbox bar from one at the edge of the picture, and drop it. + */ + clamp: Boolean = true, +): StreamPoint { + if (viewWidth <= 0 || viewHeight <= 0 || streamWidth <= 0 || streamHeight <= 0) { + return StreamPoint(0f, 0f) + } + if (!touchX.isFinite() || !touchY.isFinite()) { + return StreamPoint(Float.NaN, Float.NaN) + } + + val safeZoomScale = presentationZoomScale + .takeIf { it.isFinite() && it >= 1f } + ?: 1f + val safeTranslationX = presentationTranslationX.takeIf { it.isFinite() } ?: 0f + val safeTranslationY = presentationTranslationY.takeIf { it.isFinite() } ?: 0f + val viewCenterX = viewWidth / 2f + val viewCenterY = viewHeight / 2f + val untransformedTouchX = + viewCenterX + (touchX - viewCenterX - safeTranslationX) / safeZoomScale + val untransformedTouchY = + viewCenterY + (touchY - viewCenterY - safeTranslationY) / safeZoomScale + + var videoWidth = viewWidth.toFloat() + var videoHeight = viewHeight.toFloat() + var offsetX = 0f + var offsetY = 0f + + // A stretched surface occupies the complete view. Aspect-ratio bars only exist in fit mode. + if (!stretchToFit) { + val streamAspectRatio = + if (renderingAspectRatio.isFinite() && renderingAspectRatio > 0f) { + renderingAspectRatio + } else { + viewAspectOf(streamWidth, streamHeight) + } + val viewAspectRatio = viewAspectOf(viewWidth, viewHeight) + if (viewAspectRatio > streamAspectRatio) { + // Pillarboxed — bars left and right. + videoWidth = viewHeight * streamAspectRatio + offsetX = (viewWidth - videoWidth) / 2f + } else if (viewAspectRatio < streamAspectRatio) { + // Letterboxed — bars top and bottom. + videoHeight = viewWidth / streamAspectRatio + offsetY = (viewHeight - videoHeight) / 2f + } + } + + if (!videoWidth.isFinite() || !videoHeight.isFinite() || videoWidth <= 0f || videoHeight <= 0f) { + return StreamPoint(Float.NaN, Float.NaN) + } + + val x = (untransformedTouchX - offsetX) / videoWidth * streamWidth + val y = (untransformedTouchY - offsetY) / videoHeight * streamHeight + if (!x.isFinite() || !y.isFinite()) { + return StreamPoint(Float.NaN, Float.NaN) + } + return if (clamp) { + StreamPoint(x.coerceIn(0f, streamWidth.toFloat()), y.coerceIn(0f, streamHeight.toFloat())) + } else { + StreamPoint(x, y) + } +} + +private fun viewAspectOf(width: Int, height: Int): Float = width.toFloat() / height.toFloat() + +/** A whole-pixel relative move, the only kind the wire format carries. */ +internal data class CursorDelta(val dx: Int, val dy: Int) + +/** + * Our model of where the host's cursor sits while a direct-click drag is active. + * + * The protocol has no absolute-positioning packet — [InputEncoder.INPUT_MOUSE_REL] is all there is. + * At the start of every tap, [reanchorDeltasTo] first sends the largest supported negative movement + * so the desktop clamps the cursor to its top-left boundary, then sends the target coordinates from + * that known origin. That removes the old assumption that the host cursor started in the centre, + * which left every direct click offset whenever a game had moved it elsewhere. + */ +internal class VirtualCursor { + private var x = 0f + private var y = 0f + private var initialized = false + private var streamWidth = 0 + private var streamHeight = 0 + + /** Exposed for tests; production code only ever needs [consumeDeltaTo]. */ + val position: StreamPoint get() = StreamPoint(x, y) + + fun onStreamSize(width: Int, height: Int) { + if (width <= 0 || height <= 0) { + // Do not reset to 0,0 if we already have a valid size. This prevents the cursor + // from re-centering when the stream momentarily reports a 0x0 size during a + // resolution change or PiP transition. + if (streamWidth > 0 && streamHeight > 0) return + // Never anchor to a degenerate size either: a 0x0 report before the first valid size + // must leave the model uninitialised so the first real size anchors normally. + if (!initialized) return + } + if (!initialized) { + // Direct click reanchors before pressing; this temporary value only keeps the generic + // relative cursor model well-defined until that first DOWN arrives. + x = width / 2f + y = height / 2f + initialized = true + } else if (streamWidth != width || streamHeight != height) { + if (streamWidth > 0 && streamHeight > 0) { + x = x / streamWidth * width + y = y / streamHeight * height + } + } + streamWidth = width + streamHeight = height + } + + /** + * The move to send in order to land on [target], advancing the model by exactly that much — + * not by [target]. The two differ by the rounding residue, and assigning [target] would swallow + * that residue every event, letting the model random-walk away from the real cursor over a long + * drag. Null when the rounded move is zero and there is nothing worth sending. + */ + fun consumeDeltaTo(target: StreamPoint): CursorDelta? { + if (!target.x.isFinite() || !target.y.isFinite() || !x.isFinite() || !y.isFinite()) return null + val dx = (target.x - x).roundToInt() + val dy = (target.y - y).roundToInt() + if (dx == 0 && dy == 0) return null + // Advance the model by what was actually sent, never by [target]: the difference is the + // rounding residue, and assigning [target] would swallow it every event, letting the model + // drift away from the host cursor over a long drag. + x += dx + y += dy + return CursorDelta(dx, dy) + } + + /** + * Reliable relative moves that place the host cursor at [target] without knowing its current + * position. The first move is already the protocol's signed-16-bit minimum, so the encoder will + * transmit it unchanged and any supported single-display stream is guaranteed to hit (0, 0). + */ + fun reanchorDeltasTo(target: StreamPoint): List { + if ( + !initialized || + streamWidth <= 0 || + streamHeight <= 0 || + !target.x.isFinite() || + !target.y.isFinite() + ) return emptyList() + val targetX = target.x.roundToInt().coerceIn(0, streamWidth - 1) + val targetY = target.y.roundToInt().coerceIn(0, streamHeight - 1) + x = targetX.toFloat() + y = targetY.toFloat() + return buildList { + repeat(2) { + add(CursorDelta(Short.MIN_VALUE.toInt(), Short.MIN_VALUE.toInt())) + } + add(CursorDelta(targetX, targetY)) + } + } + + fun forget() { + initialized = false + streamWidth = 0 + streamHeight = 0 + } +} + +internal class TouchMouseState { + private var activePointerId = -1 + private var downX = 0f + private var downY = 0f + private var downTimeMs = 0L + private var lastX = 0f + private var lastY = 0f + private var selecting = false + private var doubleTapDragCandidate = false + private var lastTapTimeMs = Long.MIN_VALUE + private var lastTapX = Float.NaN + private var lastTapY = Float.NaN + private val virtualCursor = VirtualCursor() + private var twoFingerTapCandidate = false + private val motionAccumulator = MouseMotionAccumulator() + // 2-finger scroll state + private var secondPointerId = -1 + private var secondPointerDownY = 0f + private var secondPointerLastY = 0f + private var isScrollGesture = false + private var scrollAccumulator = 0f + private var scrollGestureOccurred = false + + /** + * Tears down the in-flight gesture. The cursor model is retained only for an active drag; every + * new direct-click DOWN independently reanchors at the host boundary before moving to its target. + */ + fun reset(client: NativeStreamClient?) { + // Correct for both modes now that direct click also maintains `selecting`. Widening this + // to `activePointerId >= 0` instead would fire in touchpad mode during an ordinary drag, + // where a pointer is tracked but no button is held, sending a spurious release. + if (selecting) client?.setTouchMouseButton(false) + activePointerId = -1 + selecting = false + doubleTapDragCandidate = false + twoFingerTapCandidate = false + motionAccumulator.reset() + secondPointerId = -1 + isScrollGesture = false + scrollAccumulator = 0f + scrollGestureOccurred = false + } + + /** Clears any position retained from the previous stream client. */ + fun forgetCursorPosition() { + virtualCursor.forget() + } + + private fun moveVirtualCursorTo(target: StreamPoint, client: NativeStreamClient) { + val delta = virtualCursor.consumeDeltaTo(target) ?: return + client.sendRawMouseMove(delta.dx, delta.dy) + } + + private fun reanchorVirtualCursorTo(target: StreamPoint, client: NativeStreamClient): Boolean { + val deltas = virtualCursor.reanchorDeltasTo(target) + if (deltas.isEmpty()) return false + for (delta in deltas) { + // The reanchor series is order-sensitive: the host must clamp through the top-left + // boundary before moving to the target. The unordered loss-tolerant channel could + // deliver these out of order and clamp the cursor back to 0,0, so keep it reliable. + if (!client.sendRawMouseMove(delta.dx, delta.dy)) { + virtualCursor.forget() + return false + } + } + return true + } + + fun handle( + event: MotionEvent, + enabled: Boolean, + client: NativeStreamClient, + ignoredPointerIds: Set, + directClick: Boolean = false, + width: Int = 0, + height: Int = 0, + stretchToFit: Boolean = false, + renderingAspectRatio: Float = 0f, + presentationZoomScale: Float = 1f, + presentationTranslationX: Float = 0f, + presentationTranslationY: Float = 0f, + decodedStreamWidth: Int = 0, + decodedStreamHeight: Int = 0, + ): Boolean { + if (!enabled) { + reset(client) + return false + } + + if (directClick) { + val settingsRes = streamResolutionPixels(client.settings) + val streamWidth = if (decodedStreamWidth > 0) decodedStreamWidth else settingsRes.first + val streamHeight = if (decodedStreamHeight > 0) decodedStreamHeight else settingsRes.second + virtualCursor.onStreamSize(streamWidth, streamHeight) + + when (event.actionMasked) { + MotionEvent.ACTION_DOWN, MotionEvent.ACTION_POINTER_DOWN -> { + val index = if (event.actionMasked == MotionEvent.ACTION_DOWN) 0 else event.actionIndex + if (index in 0 until event.pointerCount && event.getPointerId(index) !in ignoredPointerIds) { + val pointerId = event.getPointerId(index) + // Guard: only block if the *same* pointer is already being tracked (true dup). + // Allow a new pointer if the previous activePointerId is no longer present in the event. + if (activePointerId >= 0 && event.findPointerIndex(activePointerId) >= 0) { + // Active pointer still in contact — absorb this extra DOWN. + return true + } + // If we get here the old pointer was lifted without a UP event — reset first. + if (activePointerId >= 0) { + client.setTouchMouseButton(false) + selecting = false + } + + activePointerId = pointerId + val target = streamPointForTouch( + touchX = event.getX(index), + touchY = event.getY(index), + viewWidth = width, + viewHeight = height, + streamWidth = streamWidth, + streamHeight = streamHeight, + stretchToFit = stretchToFit, + renderingAspectRatio = renderingAspectRatio, + presentationZoomScale = presentationZoomScale, + presentationTranslationX = presentationTranslationX, + presentationTranslationY = presentationTranslationY, + ) + // Move cursor smoothly to target without forced reanchoring. + moveVirtualCursorTo(target, client) + + selecting = client.setTouchMouseButton(true) + if (!selecting) { + activePointerId = -1 + return true + } + // `selecting` is this class's single record of "we are holding the button + // down on the host". Direct click used to leave it false and track the + // press only through activePointerId, so reset() — which releases on + // `selecting` — could not release it, and backgrounding mid-tap left the + // button stuck down with no event left to clear it. + } + return true + } + MotionEvent.ACTION_MOVE -> { + if (activePointerId >= 0) { + val index = event.findPointerIndex(activePointerId) + if (index >= 0) { + moveVirtualCursorTo( + streamPointForTouch( + touchX = event.getX(index), + touchY = event.getY(index), + viewWidth = width, + viewHeight = height, + streamWidth = streamWidth, + streamHeight = streamHeight, + stretchToFit = stretchToFit, + renderingAspectRatio = renderingAspectRatio, + presentationZoomScale = presentationZoomScale, + presentationTranslationX = presentationTranslationX, + presentationTranslationY = presentationTranslationY, + ), + client, + ) + } + } + return true + } + MotionEvent.ACTION_UP -> { + // Final pointer lifted — always release the button. + client.setTouchMouseButton(false) + selecting = false + activePointerId = -1 + return true + } + MotionEvent.ACTION_POINTER_UP -> { + val releasedId = event.getPointerId(event.actionIndex) + if (releasedId == activePointerId) { + client.setTouchMouseButton(false) + selecting = false + activePointerId = -1 + } + return true + } + MotionEvent.ACTION_CANCEL -> { + client.setTouchMouseButton(false) + selecting = false + activePointerId = -1 + return true + } + } + return true + } + + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + if (event.getPointerId(0) in ignoredPointerIds) { + reset(client) + return false + } + beginPointer(event, 0) + twoFingerTapCandidate = false + return true + } + MotionEvent.ACTION_POINTER_DOWN -> { + if (activePointerId < 0) { + val index = event.actionIndex + if (index in 0 until event.pointerCount && event.getPointerId(index) !in ignoredPointerIds) { + beginPointer(event, index) + } + } else { + val newIndex = event.actionIndex + val newPointerId = if (newIndex in 0 until event.pointerCount) event.getPointerId(newIndex) else -1 + if (newPointerId >= 0 && newPointerId !in ignoredPointerIds) { + var nonIgnoredCount = 0 + for (i in 0 until event.pointerCount) { + if (event.getPointerId(i) !in ignoredPointerIds) { + nonIgnoredCount++ + } + } + if (nonIgnoredCount == 2) { + twoFingerTapCandidate = true + secondPointerId = newPointerId + val secIdx = event.findPointerIndex(newPointerId) + if (secIdx >= 0) { + secondPointerLastY = event.getY(secIdx) + secondPointerDownY = secondPointerLastY + } + } + } + } + return true + } + MotionEvent.ACTION_MOVE -> { + if (activePointerId < 0) { + val index = event.firstPointerIndexNotIn(ignoredPointerIds) + if (index >= 0) beginPointer(event, index) + return index >= 0 + } + // Handle 2-finger scroll + if (secondPointerId >= 0) { + val secIdx = event.findPointerIndex(secondPointerId) + if (secIdx >= 0) { + val secY = event.getY(secIdx) + val secDy = secY - secondPointerLastY + secondPointerLastY = secY + if (!isScrollGesture && abs(secY - secondPointerDownY) > SCROLL_START_SLOP_PX) { + isScrollGesture = true + scrollGestureOccurred = true + twoFingerTapCandidate = false + scrollAccumulator = 0f + NativeInputDiagnostics.add("touch scroll start") + } + if (isScrollGesture) { + val scrollPxPerNotch = client.settings.mouseScrollSensitivity.toFloat().coerceIn(10f, 100f) + scrollAccumulator -= secDy + val notches = (scrollAccumulator / scrollPxPerNotch).toInt() + if (notches != 0) { + client.sendTouchMouseWheel(notches * 120) + scrollAccumulator -= notches * scrollPxPerNotch + } + return true + } + } + } + val index = event.findPointerIndex(activePointerId) + if (index < 0) return true + val x = event.getX(index) + val y = event.getY(index) + val dx = x - lastX + val dy = y - lastY + if ( + doubleTapDragCandidate && + !selecting && + (abs(x - downX) > TOUCH_MOUSE_DRAG_START_SLOP_PX || abs(y - downY) > TOUCH_MOUSE_DRAG_START_SLOP_PX) + ) { + selecting = client.setTouchMouseButton(true) + doubleTapDragCandidate = false + if (selecting) { + NativeInputDiagnostics.add("touch double tap drag start") + } + } + sendMouseDelta(dx, dy, event.eventTime, client) + lastX = x + lastY = y + return true + } + MotionEvent.ACTION_POINTER_UP -> { + val index = event.actionIndex + if (index in 0 until event.pointerCount) { + val upId = event.getPointerId(index) + if (upId == secondPointerId) { + secondPointerId = -1 + isScrollGesture = false + scrollAccumulator = 0f + } + if (upId == activePointerId) { + finishPointer(event, index, client) + } + } + return true + } + MotionEvent.ACTION_UP -> { + val index = event.findPointerIndex(activePointerId).takeIf { it >= 0 } ?: event.firstPointerIndexNotIn(ignoredPointerIds) + if (index < 0) return false + finishPointer(event, index, client) + return true + } + MotionEvent.ACTION_CANCEL -> { + reset(client) + return true + } + } + return true + } + + private fun beginPointer(event: MotionEvent, index: Int) { + activePointerId = event.getPointerId(index) + downX = event.getX(index) + downY = event.getY(index) + downTimeMs = event.eventTime + lastX = downX + lastY = downY + motionAccumulator.reset() + selecting = false + doubleTapDragCandidate = isDoubleTap(event, index) + if (doubleTapDragCandidate) { + lastTapTimeMs = Long.MIN_VALUE + } + } + + private fun finishPointer(event: MotionEvent, index: Int, client: NativeStreamClient) { + val x = event.getX(index) + val y = event.getY(index) + sendMouseDelta( + dx = x - lastX, + dy = y - lastY, + eventTimeMs = event.eventTime, + client = client, + force = true, + ) + lastX = x + lastY = y + val tapDistanceX = abs(x - downX) + val tapDistanceY = abs(y - downY) + val wasTap = activePointerId >= 0 && + !scrollGestureOccurred && + event.eventTime - downTimeMs <= TOUCH_MOUSE_TAP_TIMEOUT_MS && + tapDistanceX <= TOUCH_MOUSE_TAP_SLOP_PX && + tapDistanceY <= TOUCH_MOUSE_TAP_SLOP_PX + activePointerId = -1 + doubleTapDragCandidate = false + scrollGestureOccurred = false + if (selecting) { + client.setTouchMouseButton(false) + selecting = false + return + } + if (wasTap) { + if (twoFingerTapCandidate) { + NativeInputDiagnostics.add("touch 2-finger tap right click dx=${tapDistanceX.roundToInt()} dy=${tapDistanceY.roundToInt()}") + client.sendTouchMouseRightClick() + } else { + NativeInputDiagnostics.add("touch tap click dx=${tapDistanceX.roundToInt()} dy=${tapDistanceY.roundToInt()}") + client.sendTouchMouseClick() + } + lastTapTimeMs = event.eventTime + lastTapX = x + lastTapY = y + } + twoFingerTapCandidate = false + } + + private fun MotionEvent.firstPointerIndexNotIn(ignoredPointerIds: Set): Int { + for (index in 0 until pointerCount) { + if (getPointerId(index) !in ignoredPointerIds) return index + } + return -1 + } + + private fun isDoubleTap(event: MotionEvent, index: Int): Boolean { + if (lastTapTimeMs == Long.MIN_VALUE) return false + if (event.eventTime - lastTapTimeMs > TOUCH_MOUSE_DOUBLE_TAP_TIMEOUT_MS) return false + if (!lastTapX.isFinite() || !lastTapY.isFinite()) return false + return abs(event.getX(index) - lastTapX) <= TOUCH_MOUSE_DOUBLE_TAP_SLOP_PX && + abs(event.getY(index) - lastTapY) <= TOUCH_MOUSE_DOUBLE_TAP_SLOP_PX + } + + private fun sendMouseDelta( + dx: Float, + dy: Float, + eventTimeMs: Long, + client: NativeStreamClient, + force: Boolean = false, + ) { + val delta = motionAccumulator.add( + dx = dx, + dy = dy, + eventTimeMs = eventTimeMs, + sensitivity = client.settings.mouseSensitivity, + acceleration = client.settings.mouseAcceleration, + force = force, + ) ?: return + client.sendRawMouseMove(delta.dx, delta.dy) + } + + companion object { + private const val TOUCH_MOUSE_DRAG_START_SLOP_PX = 10f + private const val TOUCH_MOUSE_TAP_SLOP_PX = 42f + private const val TOUCH_MOUSE_TAP_TIMEOUT_MS = 450L + private const val TOUCH_MOUSE_DOUBLE_TAP_TIMEOUT_MS = 320L + private const val TOUCH_MOUSE_DOUBLE_TAP_SLOP_PX = 36f + private const val SCROLL_START_SLOP_PX = 12f + } +} diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/StreamKeyboardText.kt b/android/app/src/main/java/com/opencloudgaming/opennow/StreamKeyboardText.kt new file mode 100644 index 000000000..d23a52af0 --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/StreamKeyboardText.kt @@ -0,0 +1,21 @@ +package com.opencloudgaming.opennow + +/** A minimal remote edit that keeps the host field aligned with the locally mirrored draft. */ +internal sealed interface StreamKeyboardEdit { + data object None : StreamKeyboardEdit + data class Append(val text: String) : StreamKeyboardEdit + data class Backspace(val count: Int) : StreamKeyboardEdit + data class Replace(val text: String) : StreamKeyboardEdit +} + +internal fun streamKeyboardEdit(syncedText: String?, draft: String): StreamKeyboardEdit { + val previous = syncedText.orEmpty() + return when { + previous == draft -> StreamKeyboardEdit.None + draft.startsWith(previous) -> StreamKeyboardEdit.Append(draft.removePrefix(previous)) + previous.startsWith(draft) -> StreamKeyboardEdit.Backspace( + previous.codePointCount(draft.length, previous.length), + ) + else -> StreamKeyboardEdit.Replace(draft) + } +} diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/StreamNetworkWarning.kt b/android/app/src/main/java/com/opencloudgaming/opennow/StreamNetworkWarning.kt new file mode 100644 index 000000000..2a9341d45 --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/StreamNetworkWarning.kt @@ -0,0 +1,81 @@ +package com.opencloudgaming.opennow + +import java.util.Locale + +internal data class StreamNetworkWarning( + val key: String, + val message: String, +) + +/** + * Turns only direct network measurements into an in-stream warning. Decoder FPS is deliberately + * excluded as a trigger because a slow device is not evidence of a bad connection. + */ +internal fun streamNetworkWarning( + stats: StreamRuntimeStats, +): StreamNetworkWarning? { + val reasons = buildList { + stats.pingMs + ?.takeIf { it >= 0 && StreamQuality.latency(it) == StreamQualityLevel.Poor } + ?.let { add("latency" to "$it ms latency") } + + val packetDeltaIsUsable = stats.packetsLostDelta != null && + stats.packetsReceivedDelta != null && + stats.packetsLostDelta >= 0L && + stats.packetsReceivedDelta >= 0L && + stats.packetsLostDelta + stats.packetsReceivedDelta > 0L + stats.packetLossPct + ?.takeIf { + packetDeltaIsUsable && + it >= 0.0 && + StreamQuality.packetLoss(it) == StreamQualityLevel.Poor + } + ?.let { add("loss" to "${"%.2f".format(Locale.US, it)}% packet loss") } + + stats.jitterMs + ?.takeIf { it >= 0.0 && StreamQuality.jitter(it) == StreamQualityLevel.Poor } + ?.let { add("jitter" to "${"%.1f".format(Locale.US, it)} ms jitter") } + } + if (reasons.isEmpty()) return null + + val measured = reasons.map { it.second } + return StreamNetworkWarning( + key = reasons.map { it.first }.sorted().joinToString("+"), + message = buildString { + append(measured.joinToString(" · ")) + append(". You may experience lag due to your internet connection.") + }, + ) +} + +/** Requires a sustained problem and shows at most one connection banner per stream session. */ +internal class StreamNetworkWarningGate( + private val minimumConsecutiveSamples: Int = 3, +) { + private var candidateKey: String? = null + private var consecutiveSamples = 0 + private var warningShown = false + + init { + require(minimumConsecutiveSamples > 0) + } + + fun update(candidate: StreamNetworkWarning?): StreamNetworkWarning? { + if (candidate == null) { + candidateKey = null + consecutiveSamples = 0 + return null + } + + if (candidate.key == candidateKey) { + consecutiveSamples += 1 + } else { + candidateKey = candidate.key + consecutiveSamples = 1 + } + if (consecutiveSamples < minimumConsecutiveSamples) return null + if (warningShown) return null + warningShown = true + return candidate + } +} diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/StreamPacketLoss.kt b/android/app/src/main/java/com/opencloudgaming/opennow/StreamPacketLoss.kt new file mode 100644 index 000000000..96a076b84 --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/StreamPacketLoss.kt @@ -0,0 +1,124 @@ +package com.opencloudgaming.opennow + +import java.util.ArrayDeque + +internal data class StreamPacketDelta( + val lost: Long, + val received: Long, +) + +/** Drops delayed WebRTC stats callbacks before they can rewind cumulative counters or windows. */ +internal fun isNewerStreamStatsSample(currentTimestampMs: Double, previousTimestampMs: Double?): Boolean = + currentTimestampMs.isFinite() && + (previousTimestampMs == null || currentTimestampMs > previousTimestampMs) + +/** + * Returns a usable delta only while WebRTC is reporting the same monotonically increasing packet + * counters. Counter resets happen during SSRC/transport changes and must not be presented as loss. + */ +internal fun streamPacketDelta( + currentLost: Long, + currentReceived: Long, + previousLost: Long, + previousReceived: Long, +): StreamPacketDelta? { + if (currentLost < previousLost || currentReceived < previousReceived) return null + return StreamPacketDelta( + lost = currentLost - previousLost, + received = currentReceived - previousReceived, + ) +} + +/** + * Smooths the one-second WebRTC packet counters into a short rolling measurement. A sparse delta + * can otherwise make the overlay jump from 0% to 50% for one sample; three samples are enough to + * avoid that misleading flash while five seconds remains responsive to a real network problem. + */ +internal class StreamPacketLossWindow( + private val maximumSamples: Int = 5, + private val minimumSamples: Int = 3, +) { + private val samples = ArrayDeque() + + init { + require(maximumSamples > 0) + require(minimumSamples in 1..maximumSamples) + } + + fun add(delta: StreamPacketDelta): Double? { + samples.addLast(delta) + while (samples.size > maximumSamples) samples.removeFirst() + if (samples.size < minimumSamples) return null + + val lost = samples.sumOf(StreamPacketDelta::lost) + val received = samples.sumOf(StreamPacketDelta::received) + val total = lost + received + return if (total > 0L) { + (lost.toDouble() / total.toDouble() * 100.0).coerceIn(0.0, 100.0) + } else { + 0.0 + } + } + + fun reset() { + samples.clear() + } +} + +/** + * Requests one clean frame after a sustained raw-loss burst ends. + * + * This deliberately does not change stream settings or restart transport. Waiting for a healthy + * sample avoids adding a large keyframe while the lossy path is still congested. + */ +internal class StreamPacketLossRecoveryGate( + private val badSamplesBeforeArmed: Int = 2, + private val lossThresholdPct: Double = 5.0, + private val minimumPacketSample: Long = 100L, + private val cooldownSamples: Int = 15, +) { + private var consecutiveBadSamples = 0 + private var recoveryArmed = false + private var remainingCooldownSamples = 0 + + init { + require(badSamplesBeforeArmed > 0) + require(lossThresholdPct in 0.0..100.0) + require(minimumPacketSample > 0L) + require(cooldownSamples >= 0) + } + + fun reset() { + consecutiveBadSamples = 0 + recoveryArmed = false + remainingCooldownSamples = 0 + } + + fun observe(stats: StreamRuntimeStats, recoveryEligible: Boolean): Boolean { + if (!recoveryEligible) { + consecutiveBadSamples = 0 + recoveryArmed = false + return false + } + + val lost = stats.packetsLostDelta?.takeIf { it >= 0L } ?: return false + val received = stats.packetsReceivedDelta?.takeIf { it >= 0L } ?: return false + val total = lost + received + if (total < minimumPacketSample) return false + if (remainingCooldownSamples > 0) remainingCooldownSamples -= 1 + + val rawLossPct = lost.toDouble() / total.toDouble() * 100.0 + if (rawLossPct >= lossThresholdPct) { + consecutiveBadSamples += 1 + if (consecutiveBadSamples >= badSamplesBeforeArmed) recoveryArmed = true + return false + } + + consecutiveBadSamples = 0 + if (!recoveryArmed) return false + recoveryArmed = false + if (remainingCooldownSamples > 0) return false + remainingCooldownSamples = cooldownSamples + return true + } +} diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/StreamSdp.kt b/android/app/src/main/java/com/opencloudgaming/opennow/StreamSdp.kt new file mode 100644 index 000000000..b0d848df5 --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/StreamSdp.kt @@ -0,0 +1,546 @@ +package com.opencloudgaming.opennow + +import java.net.InetAddress +import java.util.Locale +import kotlin.math.max + +object SdpTools { + data class RewriteResult(val sdp: String, val replacements: Int) + + fun fixServerIp(sdp: String, serverIp: String): String = + fixServerEndpoint(sdp, serverIp, mediaConnectionInfo = null) + + fun fixServerEndpoint(sdp: String, serverIp: String, mediaConnectionInfo: MediaConnectionInfo?): String { + val signalingIp = extractPublicIp(serverIp) ?: return sdp + val mediaIp = mediaConnectionInfo?.ip?.let(::extractPublicIp) ?: signalingIp + val mediaPort = mediaConnectionInfo?.port?.takeIf { it in 1..65535 } + return sdp + .replace(Regex("c=IN IP4 ([^\\r\\n]+)")) { match -> + val address = match.groupValues[1] + if (shouldRewriteRemoteEndpoint(address, mediaConnectionInfo != null)) "c=IN IP4 $mediaIp" else match.value + } + .replace(Regex("(a=candidate:\\S+\\s+\\d+\\s+\\w+\\s+\\d+\\s+)([^\\s]+)\\s+(\\d+)(\\s+)")) { match -> + val address = match.groupValues[2] + val port = match.groupValues[3] + if (shouldRewriteRemoteEndpoint(address, mediaConnectionInfo != null)) { + "${match.groupValues[1]}$mediaIp ${mediaPort ?: port}${match.groupValues[4]}" + } else { + match.value + } + } + } + + fun preferCodec(sdp: String, settings: StreamSettings): String = + preferCodec(sdp, settings.codec, settings.prefersTenBitVideo()) + + fun preferCodec(sdp: String, codec: VideoCodec): String = + preferCodec(sdp, codec, preferTenBit = codec != VideoCodec.H265) + + private fun preferCodec(sdp: String, codec: VideoCodec, preferTenBit: Boolean): String { + val target = when (codec) { + VideoCodec.H264 -> "H264" + VideoCodec.H265 -> "H265" + VideoCodec.AV1 -> "AV1" + } + val lineEnding = if (sdp.contains("\r\n")) "\r\n" else "\n" + val lines = sdp.split(Regex("\\r?\\n")) + var inVideo = false + val codecByPt = mutableMapOf() + val rtxApt = mutableMapOf() + val fmtpByPt = mutableMapOf() + lines.forEach { line -> + if (line.startsWith("m=video")) inVideo = true else if (line.startsWith("m=") && inVideo) inVideo = false + if (inVideo && line.startsWith("a=rtpmap:")) { + val rest = line.substringAfter(":") + val pt = rest.substringBefore(" ") + val name = rest.substringAfter(" ").substringBefore("/").uppercase(Locale.US).let { if (it == "HEVC") "H265" else it } + codecByPt[pt] = name + } + if (inVideo && line.startsWith("a=fmtp:")) { + val rest = line.substringAfter(":") + val pt = rest.substringBefore(" ") + val params = rest.substringAfter(" ", "") + fmtpByPt[pt] = params + Regex("(?:^|;)\\s*apt=(\\d+)").find(params)?.groupValues?.getOrNull(1)?.let { rtxApt[pt] = it } + } + } + val preferred = codecByPt.filterValues { it == target }.keys.toMutableList() + if (preferred.isEmpty()) return sdp + if (codec == VideoCodec.H265) { + preferred.sortBy { pt -> h265ProfilePriority(fmtpByPt[pt], preferTenBit) } + } + val allowed = preferred.toMutableSet() + rtxApt.forEach { (rtx, apt) -> + if (apt in preferred && codecByPt[rtx] == "RTX") allowed += rtx + } + val output = mutableListOf() + inVideo = false + lines.forEach { line -> + if (line.startsWith("m=video")) { + inVideo = true + val parts = line.split(Regex("\\s+")) + val ordered = preferred + parts.drop(3).filter { it in allowed && it !in preferred } + output += if (ordered.isNotEmpty()) (parts.take(3) + ordered).joinToString(" ") else line + return@forEach + } + if (line.startsWith("m=") && inVideo) inVideo = false + if (inVideo && (line.startsWith("a=rtpmap:") || line.startsWith("a=fmtp:") || line.startsWith("a=rtcp-fb:"))) { + val pt = line.substringAfter(":").substringBefore(" ") + if (pt !in allowed) return@forEach + } + output += line + } + return output.joinToString(lineEnding) + } + + fun rewriteH265TierFlag(sdp: String, tierFlag: Int): RewriteResult { + val payloads = h265PayloadTypes(sdp) + if (payloads.isEmpty()) return RewriteResult(sdp, 0) + val lineEnding = if (sdp.contains("\r\n")) "\r\n" else "\n" + var replacements = 0 + val output = sdp.split(Regex("\\r?\\n")).map { line -> + if (!line.startsWith("a=fmtp:")) return@map line + val pt = line.substringAfter(":").substringBefore(" ") + if (pt !in payloads) return@map line + val next = line.replace(Regex("tier-flag=1", RegexOption.IGNORE_CASE), "tier-flag=$tierFlag") + if (next != line) replacements += 1 + next + } + return RewriteResult(output.joinToString(lineEnding), replacements) + } + + fun rewriteH265LevelIdByProfile(sdp: String, maxLevelByProfile: Map): RewriteResult { + val payloads = h265PayloadTypes(sdp) + if (payloads.isEmpty() || maxLevelByProfile.isEmpty()) return RewriteResult(sdp, 0) + val lineEnding = if (sdp.contains("\r\n")) "\r\n" else "\n" + var replacements = 0 + val output = sdp.split(Regex("\\r?\\n")).map { line -> + if (!line.startsWith("a=fmtp:")) return@map line + val rest = line.substringAfter(":") + val pt = rest.substringBefore(" ") + val params = rest.substringAfter(" ", "") + if (pt !in payloads || params.isBlank()) return@map line + val profile = Regex("(?:^|;)\\s*profile-id=(\\d+)", RegexOption.IGNORE_CASE) + .find(params) + ?.groupValues + ?.getOrNull(1) + ?.toIntOrNull() + ?: return@map line + val level = Regex("(?:^|;)\\s*level-id=(\\d+)", RegexOption.IGNORE_CASE) + .find(params) + ?.groupValues + ?.getOrNull(1) + ?.toIntOrNull() + ?: return@map line + val maxLevel = maxLevelByProfile[profile] ?: return@map line + if (level <= maxLevel) return@map line + val next = line.replace(Regex("(level-id=)(\\d+)", RegexOption.IGNORE_CASE), "$1$maxLevel") + if (next != line) replacements += 1 + next + } + return RewriteResult(output.joinToString(lineEnding), replacements) + } + + fun negotiatesCodec(sdp: String, codec: VideoCodec): Boolean { + val target = when (codec) { + VideoCodec.H264 -> "H264" + VideoCodec.H265 -> "H265" + VideoCodec.AV1 -> "AV1" + } + var inVideo = false + sdp.split(Regex("\\r?\\n")).forEach { line -> + if (line.startsWith("m=video")) { + inVideo = true + return@forEach + } + if (line.startsWith("m=") && inVideo) { + inVideo = false + } + if (!inVideo || !line.startsWith("a=rtpmap:")) return@forEach + val codecName = line.substringAfter(" ") + .substringBefore("/") + .uppercase(Locale.US) + .let { if (it == "HEVC") "H265" else it } + if (codecName == target) return true + } + return false + } + + private fun h265PayloadTypes(sdp: String): Set { + var inVideo = false + val payloads = mutableSetOf() + sdp.split(Regex("\\r?\\n")).forEach { line -> + if (line.startsWith("m=video")) { + inVideo = true + return@forEach + } + if (line.startsWith("m=") && inVideo) { + inVideo = false + } + if (!inVideo || !line.startsWith("a=rtpmap:")) return@forEach + val rest = line.substringAfter(":") + val pt = rest.substringBefore(" ") + val codecName = rest.substringAfter(" ") + .substringBefore("/") + .uppercase(Locale.US) + .let { if (it == "HEVC") "H265" else it } + if (pt.isNotBlank() && codecName == "H265") payloads += pt + } + return payloads + } + + private fun h265ProfilePriority(fmtp: String?, preferTenBit: Boolean): Int { + val profileId = Regex("(?:^|;)\\s*profile-id=(\\d+)") + .find(fmtp.orEmpty()) + ?.groupValues + ?.getOrNull(1) + return if (preferTenBit) { + when (profileId) { + "2" -> 0 + "1" -> 1 + else -> 2 + } + } else { + when (profileId) { + "1" -> 0 + null -> 1 + "2" -> 2 + else -> 3 + } + } + } + + private fun StreamSettings.prefersTenBitVideo(): Boolean = + hdrEnabled || + colorQuality == ColorQuality.TenBit420 || + colorQuality == ColorQuality.TenBit444 + + fun mungeAnswerSdp(sdp: String, maxBitrateKbps: Int): String { + val lineEnding = if (sdp.contains("\r\n")) "\r\n" else "\n" + val out = mutableListOf() + val lines = sdp.split(Regex("\\r?\\n")) + lines.forEachIndexed { index, line -> + val rewritten = if (line.startsWith("a=fmtp:") && line.contains("minptime=") && !line.contains("stereo=1")) "$line;stereo=1" else line + out += rewritten + if ((line.startsWith("m=video") || line.startsWith("m=audio")) && !lines.getOrNull(index + 1).orEmpty().startsWith("b=")) { + out += if (line.startsWith("m=video")) "b=AS:$maxBitrateKbps" else "b=AS:128" + } + } + return out.joinToString(lineEnding) + } + + /** + * Replaces the existing b=AS bandwidth line in the video section of an SDP string, leaving + * audio untouched. Unlike [mungeAnswerSdp] this is safe to call repeatedly on the same SDP + * (idempotent), which is what a mid-stream bitrate ceiling update needs. + */ + fun replaceVideoBitrateInSdp(sdp: String, maxBitrateKbps: Int): String { + val lineEnding = if (sdp.contains("\r\n")) "\r\n" else "\n" + val lines = sdp.split(Regex("\r?\n")) + val out = mutableListOf() + var inVideoSection = false + var bitrateReplaced = false + for (line in lines) { + if (line.startsWith("m=")) { + inVideoSection = line.startsWith("m=video") + bitrateReplaced = false + } + if (inVideoSection && !bitrateReplaced && line.startsWith("b=AS:")) { + out += "b=AS:$maxBitrateKbps" + bitrateReplaced = true + continue + } + out += line + } + return out.joinToString(lineEnding) + } + + fun parseInputProtocolVersion(sdp: String): Int = + Regex("a=ri\\.version:(\\d+)").find(sdp)?.groupValues?.getOrNull(1)?.toIntOrNull() + ?: DEFAULT_INPUT_PROTOCOL_VERSION + + fun parsePartialReliableThresholdMs(sdp: String): Int = + Regex("a=ri\\.partialReliableThresholdMs:(\\d+)") + .find(sdp) + ?.groupValues + ?.getOrNull(1) + ?.toIntOrNull() + ?.coerceIn(1, 5000) + ?: 30 + + fun parsePartiallyReliableGamepadMask(sdp: String): Int = + parseRiIntegerAttribute( + sdp, + "ri.enablePartiallyReliableTransferGamepad", + PARTIALLY_RELIABLE_GAMEPAD_MASK_ALL, + ) + + fun parseHidDeviceMask(sdp: String): Int = + parseRiIntegerAttribute(sdp, "ri.hidDeviceMask", HID_DEVICE_MASK_ALL) + + fun parsePartiallyReliableHidMask(sdp: String): Int = + parseRiIntegerAttribute( + sdp, + "ri.enablePartiallyReliableTransferHid", + HID_DEVICE_MASK_ALL, + ) + + fun supportsPartiallyReliableHidInput( + hidDeviceMask: Int, + partiallyReliableHidMask: Int, + inputType: Int, + ): Boolean { + if (inputType !in 0..31) return false + val inputMask = 1 shl inputType + return (hidDeviceMask and inputMask) != 0 && + (partiallyReliableHidMask and inputMask) != 0 + } + + fun buildNvstSdp(offerSdp: String, settings: StreamSettings, localAnswer: String): String { + val (width, height) = streamResolutionPixels(settings) + val ufrag = Regex("a=ice-ufrag:([^\\r\\n]+)").find(localAnswer)?.groupValues?.getOrNull(1)?.trim().orEmpty() + val pwd = Regex("a=ice-pwd:([^\\r\\n]+)").find(localAnswer)?.groupValues?.getOrNull(1)?.trim().orEmpty() + val fingerprint = Regex("a=fingerprint:sha-256 ([^\\r\\n]+)").find(localAnswer)?.groupValues?.getOrNull(1)?.trim().orEmpty() + val threshold = Regex("a=ri\\.partialReliableThresholdMs:(\\d+)").find(offerSdp)?.groupValues?.getOrNull(1)?.toIntOrNull() ?: 30 + val hidDeviceMask = parseHidDeviceMask(offerSdp) + val partiallyReliableHidMask = parsePartiallyReliableHidMask(offerSdp) + val bitDepth = if (settings.hdrEnabled || settings.colorQuality == ColorQuality.TenBit420 || settings.colorQuality == ColorQuality.TenBit444) 10 else 8 + // The settings UI intentionally allows 1-3 Mbps for severely constrained links. Keep the + // usual NVIDIA 4 Mbps floor for normal profiles, but never let that floor exceed the + // user's maximum or the server will continue sending above the selected cap. + val maxBitrate = max(MIN_CONFIGURABLE_BITRATE_KBPS, settings.maxBitrateMbps * 1000) + val minBitrate = minOf(OFFICIAL_MIN_BITRATE_KBPS, maxBitrate) + val initialBitrate = max(minBitrate, maxBitrate / 4) + val isHighFps = settings.fps > 60 + val isAtLeast120Fps = settings.fps >= 120 + val is90Fps = settings.fps == 90 + val is120Fps = settings.fps == 120 + val isAtLeast240Fps = settings.fps >= 240 + val isAv1 = settings.codec == VideoCodec.AV1 + val minTargetFrameTimeUs = ((1_000_000L * 95L) / (settings.fps.coerceAtLeast(1) * 100L)) + .coerceAtLeast(1000L) + return buildList { + add("v=0") + add("o=SdpTest test_id_13 14 IN IPv4 127.0.0.1") + add("s=-") + add("t=0 0") + add("a=general.icePassword:$pwd") + add("a=general.iceUserNameFragment:$ufrag") + add("a=general.dtlsFingerprint:$fingerprint") + add("m=video 0 RTP/AVP") + add("a=msid:fbc-video-0") + add("a=vqos.fec.rateDropWindow:10") + add("a=vqos.fec.minRequiredFecPackets:2") + add("a=vqos.fec.repairMinPercent:5") + add("a=vqos.fec.repairPercent:5") + add("a=vqos.fec.repairMaxPercent:35") + add("a=vqos.bllFec.enable:0") + add("a=vqos.dynamicStreamingMode:0") + add("a=vqos.drc.enable:0") + add("a=vqos.calculateAvgVideoStreamingBitrate:1") + add("a=video.dx9EnableNv12:1") + add("a=video.dx9EnableHdr:${if (settings.hdrEnabled) 1 else 0}") + add("a=vqos.qpg.enable:1") + add("a=vqos.resControl.qp.qpg.featureSetting:7") + add("a=video.adaptiveQuantization.spatialAQSetting:7") + add("a=video.adaptiveQuantization.temporalAQSetting:0") + add("a=video.adaptiveQuantization.spatialAQStrength:12") + add("a=video.adaptiveQuantization.qpThresholdAdjPercent:2") + add("a=video.adaptiveQuantization.saqAdaptMinQpThresholdPercent:40") + add("a=video.adaptiveQuantization.saqAdaptMaxQpThresholdPercent:100") + add("a=video.adaptiveQuantization.saqAdaptDecayStrengthX100:250") + add("a=video.adaptiveQuantization.perfAdjEnablement:1") + add("a=video.framePacing.mode:2") + add("a=video.framePacing.pid.minTargetFrameTimeUs:$minTargetFrameTimeUs") + add("a=bwe.useOwdCongestionControl:1") + add("a=video.enableRtpNack:1") + add("a=vqos.bw.txRxLag.minFeedbackTxDeltaMs:200") + add("a=vqos.drc.bitrateIirFilterFactor:18") + add("a=video.packetSize:1140") + add("a=packetPacing.version:3") + add("a=packetPacing.mode:1") + add("a=packetPacing.minNumPacketsPerGroup:15") + add("a=packetPacing.enableAccurateSleep:1") + add("a=packetPacing.enableSmoothTransition:1") + add("a=packetPacing.allowFpsBasedToggle:1") + add("a=vqos.relaxMaxBitrate.overrideAvgBitrateThresholdPercent:4") + add("a=vqos.relaxMaxBitrate.customAvgBitrateThresholdPercent:65") + add("a=vqos.relaxMaxBitrate.overrideAvgQpThresholdPercent:7") + add("a=vqos.relaxMaxBitrate.customAvgQpThresholdPercent:51") + add("a=vqos.relaxMaxBitrate.iirFilterFactor:120") + add("a=vqos.qpDelta.qpDeltaMaxPercent:10") + add("a=vqos.qpDelta.qpDeltaSurfaceAdjustmentStrengthPercent:70") + add("a=vqos.qpDelta.qpDeltaVbvUsageFactorPercentH264:100") + add("a=vqos.qpDelta.qpDeltaVbvUsageFactorPercentH265:100") + add("a=vqos.qpDelta.qpDeltaVbvUsageFactorPercentAv1:100") + add("a=vqos.qpDelta.qpDeltaMinPercent:60") + add("a=vqos.qpDelta.qpDeltaIirFactor:60") + add("a=vqos.qpDelta.qpDeltaThrottlePercent:100") + if (isHighFps) { + add("a=vqos.dfc.enable:1") + add("a=vqos.dfc.decodeFpsAdjPercent:85") + add("a=vqos.dfc.targetDownCooldownMs:250") + add("a=vqos.dfc.dfcAlgoVersion:${if (isAtLeast120Fps) 2 else 1}") + add("a=vqos.dfc.minTargetFps:${if (isAtLeast120Fps) 100 else 60}") + add("a=vqos.resControl.dfc.useClientFpsPerf:0") + add("a=vqos.dfc.adjustResAndFps:0") + add("a=bwe.iirFilterFactor:8") + add("a=video.encoderFeatureSetting:47") + add("a=video.encoderPreset:6") + val captureTuning = when { + is90Fps -> 9 to 11 + is120Fps -> 6 to 9 + isAtLeast240Fps -> 18 to 9 + else -> null + } + captureTuning?.let { (grabTimeoutMs, decodeThresholdMs) -> + add("a=video.fbcDynamicFpsGrabTimeoutMs:$grabTimeoutMs") + add("a=vqos.resControl.cpmRtc.decodeTimeThresholdMs:$decodeThresholdMs") + } + add("a=vqos.maxStreamFpsEstimate:${settings.fps}") + } else { + add("a=vqos.dfc.enable:0") + add("a=vqos.dfc.adjustResAndFps:0") + } + if (isAtLeast240Fps) { + add("a=video.enableNextCaptureMode:1") + val splitEncodeStrips = if (isAv1 && width * height >= HIGH_RESOLUTION_AV1_SPLIT_ENCODE_PIXELS) 63 else 3 + add("a=video.videoSplitEncodeStripsPerFrame:$splitEncodeStrips") + add("a=video.updateSplitEncodeStateDynamically:1") + add("a=vqos.rtcPreemptiveIdrSettings.minBurstNackSize:65535") + add("a=vqos.rtcPreemptiveIdrSettings.minNackPacketCaptureAgeMs:65535") + } + add("a=vqos.adjustStreamingFpsDuringOutOfFocus:0") + add("a=vqos.resControl.cpmRtc.ignoreOutOfFocusWindowState:1") + add("a=vqos.resControl.perfHistory.rtcIgnoreOutOfFocusWindowState:1") + add("a=vqos.resControl.cpmRtc.featureMask:0") + add("a=vqos.resControl.cpmRtc.enable:0") + add("a=vqos.resControl.cpmRtc.minResolutionPercent:100") + add("a=vqos.resControl.cpmRtc.resolutionChangeHoldonMs:999999") + add("a=packetPacing.numGroups:${if (is120Fps) 3 else 5}") + add("a=packetPacing.maxDelayUs:1000") + add("a=packetPacing.minNumPacketsFrame:10") + add("a=video.rtpNackQueueLength:1024") + add("a=video.rtpNackQueueMaxPackets:512") + add("a=video.rtpNackMaxPacketCount:25") + add("a=vqos.drc.qpMaxResThresholdAdj:4") + add("a=vqos.grc.qpMaxResThresholdAdj:4") + add("a=vqos.drc.iirFilterFactor:100") + if (isAv1) { + add("a=vqos.drc.minQpHeadroom:20") + add("a=vqos.drc.lowerQpThreshold:100") + add("a=vqos.drc.upperQpThreshold:200") + add("a=vqos.drc.minAdaptiveQpThreshold:180") + add("a=vqos.drc.qpCodecThresholdAdj:0") + add("a=vqos.drc.qpMaxResThresholdAdj:20") + add("a=vqos.dfc.minQpHeadroom:20") + add("a=vqos.dfc.qpLowerLimit:100") + add("a=vqos.dfc.qpMaxUpperLimit:200") + add("a=vqos.dfc.qpMinUpperLimit:180") + add("a=vqos.dfc.qpMaxResThresholdAdj:20") + add("a=vqos.dfc.qpCodecThresholdAdj:0") + add("a=vqos.grc.minQpHeadroom:20") + add("a=vqos.grc.lowerQpThreshold:100") + add("a=vqos.grc.upperQpThreshold:200") + add("a=vqos.grc.minAdaptiveQpThreshold:180") + add("a=vqos.grc.qpMaxResThresholdAdj:20") + add("a=vqos.grc.qpCodecThresholdAdj:0") + add("a=video.minQp:25") + add("a=video.enableAv1RcPrecisionFactor:1") + } + add("a=video.clientViewportWd:$width") + add("a=video.clientViewportHt:$height") + add("a=video.maxFPS:${settings.fps}") + add("a=video.initialBitrateKbps:$initialBitrate") + add("a=video.initialPeakBitrateKbps:$initialBitrate") + add("a=vqos.bw.maximumBitrateKbps:$maxBitrate") + add("a=vqos.bw.minimumBitrateKbps:$minBitrate") + add("a=vqos.bw.peakBitrateKbps:$maxBitrate") + add("a=vqos.bw.serverPeakBitrateKbps:$maxBitrate") + add("a=vqos.bw.enableBandwidthEstimation:1") + add("a=vqos.bw.disableBitrateLimit:0") + add("a=vqos.grc.maximumBitrateKbps:$maxBitrate") + add("a=vqos.grc.enable:0") + add("a=video.maxNumReferenceFrames:4") + add("a=video.mapRtpTimestampsToFrames:1") + add("a=video.encoderCscMode:3") + add("a=video.dynamicRangeMode:0") + add("a=video.bitDepth:$bitDepth") + // Keep the encoded geometry fixed for every codec. AV1 value 1 was + // added during the June SDP expansion and permits the horizontal + // scaling seen as 1366x768 -> 1230x768 in affected sessions. + add("a=video.scalingFeature1:0") + add("a=video.prefilterParams.prefilterModel:0") + add("m=audio 0 RTP/AVP") + add("a=msid:audio") + add("m=mic 0 RTP/AVP") + add("a=msid:mic") + add("a=rtpmap:0 PCMU/8000") + add("m=application 0 RTP/AVP") + add("a=msid:input_1") + add("a=ri.partialReliableThresholdMs:$threshold") + add("a=ri.hidDeviceMask:${hidDeviceMask.toUInt()}") + add("a=ri.enablePartiallyReliableTransferGamepad:15") + add("a=ri.enablePartiallyReliableTransferHid:${partiallyReliableHidMask.toUInt()}") + add("") + }.joinToString("\n") + } + + private fun extractPublicIp(hostOrIp: String): String? { + if (Regex("^\\d{1,3}(\\.\\d{1,3}){3}$").matches(hostOrIp)) return hostOrIp + val first = hostOrIp.substringBefore(".") + val parts = first.split("-") + return if (parts.size == 4 && parts.all { it.all(Char::isDigit) }) parts.joinToString(".") else null + } + + private fun shouldRewriteRemoteEndpoint(address: String, hasMediaEndpoint: Boolean): Boolean { + val remoteAddress = parseIpv4Address(address) ?: return false + if (remoteAddress.inetAddress.isAnyLocalAddress) return true + return hasMediaEndpoint && remoteAddress.isUnroutable() + } + + private data class RemoteIpv4Address( + val octets: List, + val inetAddress: InetAddress, + ) { + fun isUnroutable(): Boolean = + inetAddress.isLoopbackAddress || + inetAddress.isSiteLocalAddress || + inetAddress.isLinkLocalAddress || + inetAddress.isMulticastAddress || + isCarrierGradeNatAddress(octets) + } + + private fun parseIpv4Address(address: String): RemoteIpv4Address? { + val octets = address.split(".").map { it.toIntOrNull() ?: return null } + if (octets.size != 4 || octets.any { it !in 0..255 }) return null + val inetAddress = InetAddress.getByAddress(octets.map { it.toByte() }.toByteArray()) + return RemoteIpv4Address(octets, inetAddress) + } + + private fun isCarrierGradeNatAddress(octets: List): Boolean { + return octets[0] == 100 && octets[1] in 64..127 + } + + private fun parseRiIntegerAttribute(sdp: String, attribute: String, fallback: Int): Int { + val escaped = Regex.escape(attribute) + val raw = Regex("a=$escaped:([^\\r\\n]+)", RegexOption.IGNORE_CASE) + .find(sdp) + ?.groupValues + ?.getOrNull(1) + ?.trim() + ?: return fallback + val parsed = if (raw.startsWith("0x", ignoreCase = true)) { + raw.drop(2).toULongOrNull(16)?.toInt() + } else { + raw.toLongOrNull()?.toInt() + } + return parsed ?: fallback + } + + private const val OFFICIAL_MIN_BITRATE_KBPS = 4000 + private const val MIN_CONFIGURABLE_BITRATE_KBPS = 1000 + private const val HIGH_RESOLUTION_AV1_SPLIT_ENCODE_PIXELS = 2_764_800 + private const val PARTIALLY_RELIABLE_GAMEPAD_MASK_ALL = 0x0f + private const val HID_DEVICE_MASK_ALL = -1 +} diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/StreamSignaling.kt b/android/app/src/main/java/com/opencloudgaming/opennow/StreamSignaling.kt new file mode 100644 index 000000000..05e539be0 --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/StreamSignaling.kt @@ -0,0 +1,265 @@ +package com.opencloudgaming.opennow + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import okhttp3.ConnectionSpec +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okhttp3.TlsVersion +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import okio.ByteString +import org.webrtc.IceCandidate +import java.util.concurrent.TimeUnit + +sealed interface SignalingEvent { + data object Connected : SignalingEvent + data class Disconnected(val reason: String) : SignalingEvent + data class Offer(val sdp: String) : SignalingEvent + data class RemoteIce(val candidate: IceCandidate) : SignalingEvent + data class Error(val message: String) : SignalingEvent + data class Log(val message: String) : SignalingEvent +} + +class GfnSignalingClient( + private val session: SessionInfo, + private val settings: StreamSettings, + private val http: OkHttpClient = defaultHttpClient(), + private val onEvent: (SignalingEvent) -> Unit, +) { + private val signalingHttp = signalingWebSocketHttpClient(http) + private var webSocket: WebSocket? = null + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private var heartbeatJob: Job? = null + private var peerId = 0 + private var remotePeerId = 1 + private val peerName = "peer-${java.util.UUID.randomUUID().toString().replace("-", "").take(12)}" + private var ackCounter = 0 + + fun connect() { + val url = buildSignInUrl() + val host = url.removePrefix("wss://").substringBefore("/") + onEvent(SignalingEvent.Log("Signaling connecting url=${signalingUrlForDiagnostics(url, session.sessionId)} session=${streamDiagnosticId(session.sessionId)}")) + val request = Request.Builder() + .url(url) + .header("Sec-WebSocket-Protocol", "x-nv-sessionid.${session.sessionId}") + .header("Host", host) + .header("Origin", "https://play.geforcenow.com") + .header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/131.0.0.0 Safari/537.36") + .build() + webSocket = signalingHttp.newWebSocket( + request, + object : WebSocketListener() { + override fun onOpen(webSocket: WebSocket, response: Response) { + onEvent( + SignalingEvent.Log( + "Signaling open http=${response.code} tls=${response.handshake?.tlsVersion?.javaName ?: "unknown"} " + + "protocol=${response.header("Sec-WebSocket-Protocol").orEmpty().replace(session.sessionId, streamDiagnosticId(session.sessionId))}", + ), + ) + sendPeerInfo() + heartbeatJob?.cancel() + NativeInputDiagnostics.retain( + "heartbeat.signaling.lifecycle", + "signaling heartbeat active intervalMs=5000 session=${streamDiagnosticId(session.sessionId)}", + ) + heartbeatJob = scope.launch { + while (true) { + delay(5000) + val sent = sendJson("""{"hb":1}""") + NativeInputDiagnostics.retainResult("heartbeat.signaling.send", sent) { + "client heartbeat session=${streamDiagnosticId(session.sessionId)}" + } + } + } + onEvent(SignalingEvent.Connected) + } + + override fun onMessage(webSocket: WebSocket, text: String) = handleMessage(text) + override fun onMessage(webSocket: WebSocket, bytes: ByteString) = handleMessage(bytes.utf8()) + + override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { + heartbeatJob?.cancel() + NativeInputDiagnostics.retain( + "heartbeat.signaling.lifecycle", + "signaling heartbeat stopped socketClosed=$code session=${streamDiagnosticId(session.sessionId)}", + ) + onEvent(SignalingEvent.Disconnected("socket closed code=$code reason=${reason.ifBlank { "none" }}")) + } + + override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { + heartbeatJob?.cancel() + NativeInputDiagnostics.retain( + "heartbeat.signaling.lifecycle", + "signaling heartbeat stopped failure=${t.javaClass.simpleName} session=${streamDiagnosticId(session.sessionId)}", + ) + val responseText = response?.let { " http=${it.code} message=${it.message}" }.orEmpty() + onEvent(SignalingEvent.Error("${t.javaClass.simpleName}: ${t.message ?: "Signaling failed"}$responseText")) + } + }, + ) + } + + fun sendAnswer(sdp: String, nvstSdp: String?) { + onEvent(SignalingEvent.Log(sdpDiagnosticSummary("Sending answer", sdp))) + if (!nvstSdp.isNullOrBlank()) { + onEvent(SignalingEvent.Log("Sending NVST SDP lines=${nvstSdp.lineSequence().count()} bytes=${nvstSdp.length}")) + } + val msg = buildJsonObject { + put("type", "answer") + put("sdp", sdp) + if (nvstSdp != null) put("nvstSdp", nvstSdp) + }.toString() + sendPeerMessage(msg) + } + + fun sendIceCandidate(candidate: IceCandidate) { + if (candidate.sdp.contains(" tcp ", ignoreCase = true)) { + onEvent(SignalingEvent.Log("Dropping TCP local ICE candidate ${candidate.diagnosticSummary()}")) + return + } + onEvent(SignalingEvent.Log("Sending local ICE candidate ${candidate.diagnosticSummary()}")) + val msg = buildJsonObject { + put("candidate", candidate.sdp) + put("sdpMid", candidate.sdpMid) + put("sdpMLineIndex", candidate.sdpMLineIndex) + }.toString() + sendPeerMessage(msg) + } + + fun requestKeyframe(reason: String, backlogFrames: Int, attempt: Int) { + val msg = buildJsonObject { + put("type", "request_keyframe") + put("reason", reason) + put("backlogFrames", backlogFrames) + put("attempt", attempt) + }.toString() + sendPeerMessage(msg) + } + + fun disconnect() { + heartbeatJob?.cancel() + if (webSocket != null) { + NativeInputDiagnostics.retain( + "heartbeat.signaling.lifecycle", + "signaling heartbeat stopped clientDisconnect session=${streamDiagnosticId(session.sessionId)}", + ) + } + webSocket?.close(1000, "closed") + webSocket = null + } + + private fun buildSignInUrl(): String { + val base = session.signalingUrl.ifBlank { + val host = if (session.signalingServer.contains(":")) session.signalingServer else "${session.signalingServer}:443" + "wss://$host/nvst/" + } + val normalized = base.replace("wss://", "").trimEnd('/') + return "wss://$normalized/sign_in?peer_id=$peerName&version=2&peer_role=1&pairing_id=${session.sessionId}" + } + + private fun handleMessage(text: String) { + val parsed = runCatching { OpenNowJson.parseToJsonElement(text).jsonObject }.getOrNull() + if (parsed == null) { + onEvent(SignalingEvent.Log("Ignoring non-JSON signaling packet")) + return + } + parsed["peer_info"]?.jsonObject?.let { info -> + if (info["name"]?.jsonPrimitive?.contentOrNull == peerName) { + peerId = info["id"]?.jsonPrimitive?.intOrNull ?: peerId + } + } + parsed["ackid"]?.jsonPrimitive?.intOrNull?.let { ack -> + val shouldAck = parsed["peer_info"]?.jsonObject?.get("id")?.jsonPrimitive?.intOrNull != peerId + if (shouldAck) sendJson("""{"ack":$ack}""") + } + signalingHeartbeatReply(parsed)?.let { reply -> + // Match the desktop client and acknowledge server-driven + // heartbeats immediately. The periodic client heartbeat remains + // a separate keepalive when the server does not initiate one. + val sent = sendJson(reply) + NativeInputDiagnostics.retainResult("heartbeat.signaling.reply", sent) { + "server heartbeat reply session=${streamDiagnosticId(session.sessionId)}" + } + return + } + val peerMsg = parsed["peer_msg"]?.jsonObject ?: return + remotePeerId = peerMsg["from"]?.jsonPrimitive?.intOrNull ?: remotePeerId + val msg = peerMsg["msg"]?.jsonPrimitive?.contentOrNull ?: return + val payload = runCatching { OpenNowJson.parseToJsonElement(msg).jsonObject }.getOrNull() ?: return + when { + payload["type"]?.jsonPrimitive?.contentOrNull == "offer" -> { + val sdp = payload["sdp"]?.jsonPrimitive?.contentOrNull + if (sdp != null) { + onEvent(SignalingEvent.Log(sdpDiagnosticSummary("Received offer", sdp))) + onEvent(SignalingEvent.Offer(sdp)) + } + } + payload["candidate"]?.jsonPrimitive?.contentOrNull != null -> { + val candidate = IceCandidate( + payload["sdpMid"]?.jsonPrimitive?.contentOrNull, + payload["sdpMLineIndex"]?.jsonPrimitive?.intOrNull ?: 0, + payload["candidate"]?.jsonPrimitive?.contentOrNull.orEmpty(), + ) + onEvent(SignalingEvent.Log("Received remote ICE candidate ${candidate.diagnosticSummary()}")) + onEvent(SignalingEvent.RemoteIce(candidate)) + } + } + } + + private fun sendPeerInfo() { + val (width, height) = streamResolutionPixels(settings) + onEvent(SignalingEvent.Log("Sending peer info resolution=${width}x$height peer=$peerName")) + sendJson( + """ + {"ackid":${nextAckId()},"peer_info":{"browser":"Chrome","browserVersion":"131","connected":true,"id":$peerId,"name":"$peerName","peerRole":0,"resolution":"${width}x$height","version":2}} + """.trimIndent(), + ) + } + + private fun sendPeerMessage(message: String) { + val escaped = message.replace("\\", "\\\\").replace("\"", "\\\"") + sendJson("""{"peer_msg":{"from":$peerId,"to":$remotePeerId,"msg":"$escaped"},"ackid":${nextAckId()}}""") + } + + private fun sendJson(text: String): Boolean = webSocket?.send(text) == true + + private fun nextAckId(): Int { + ackCounter += 1 + return ackCounter + } +} + +private val SIGNALING_TLS_1_2 = + ConnectionSpec.Builder(ConnectionSpec.MODERN_TLS) + .tlsVersions(TlsVersion.TLS_1_2) + .build() + +internal fun signalingWebSocketHttpClient(base: OkHttpClient): OkHttpClient = + base.newBuilder() + // GFN already has an application heartbeat. Avoid a second WebSocket + // ping loop and Android TV's TLS 1.3/Conscrypt reader spin on this + // long-lived signaling socket; media remains DTLS/WebRTC and unchanged. + .pingInterval(0, TimeUnit.MILLISECONDS) + .connectionSpecs(listOf(SIGNALING_TLS_1_2)) + .build() + +/** + * Owns the app-UI side of an in-progress touch gesture independently from the bounds that first + * claimed it. Compose overlays can disappear and replace one another between DOWN and UP (for + * example, the stream-menu launcher is replaced by the menu panel). Removing the launcher's bounds + * must not turn that already-owned finger back into a game touch or let its trailing UP activate a + * control in the newly opened panel. + */ diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/StreamStatusBattery.kt b/android/app/src/main/java/com/opencloudgaming/opennow/StreamStatusBattery.kt new file mode 100644 index 000000000..620a71606 --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/StreamStatusBattery.kt @@ -0,0 +1,29 @@ +package com.opencloudgaming.opennow + +/** Battery fill levels represented by the Material status-bar icons. */ +internal enum class StreamBatteryLevel { + Unknown, + Empty, + One, + Two, + Three, + Four, + Five, + Six, + Full, +} + +/** Keeps the battery glyph in step with the percentage instead of always drawing a full battery. */ +internal fun streamBatteryLevel(percent: Int?): StreamBatteryLevel { + val normalized = percent?.coerceIn(0, 100) ?: return StreamBatteryLevel.Unknown + return when (normalized) { + in 0..5 -> StreamBatteryLevel.Empty + in 6..20 -> StreamBatteryLevel.One + in 21..35 -> StreamBatteryLevel.Two + in 36..50 -> StreamBatteryLevel.Three + in 51..65 -> StreamBatteryLevel.Four + in 66..80 -> StreamBatteryLevel.Five + in 81..95 -> StreamBatteryLevel.Six + else -> StreamBatteryLevel.Full + } +} diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/StreamStatusCustomization.kt b/android/app/src/main/java/com/opencloudgaming/opennow/StreamStatusCustomization.kt new file mode 100644 index 000000000..8083f1a8a --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/StreamStatusCustomization.kt @@ -0,0 +1,62 @@ +package com.opencloudgaming.opennow + +import androidx.annotation.StringRes + +/** + * The items that can appear in the in-stream status line. + * + * This is shared by the live Stream Controls panel and first-run setup so both surfaces always + * expose the same choices and write the same persisted fields. + */ +internal enum class StreamStatusItem( + @StringRes val labelRes: Int, + @StringRes val previewValueRes: Int?, +) { + Keyboard(R.string.stream_statusbar_metric_keyboard, null), + Fps(R.string.stream_statusbar_metric_fps, R.string.setup_play_metric_fps_preview), + Ping(R.string.stream_statusbar_metric_ping, R.string.setup_play_metric_ping_preview), + Bitrate(R.string.stream_statusbar_metric_bitrate, R.string.setup_play_metric_bitrate_preview), + Battery(R.string.stream_statusbar_metric_battery, R.string.setup_play_metric_battery_preview), + Connection(R.string.stream_statusbar_metric_connection, R.string.setup_play_metric_connection_preview), + Resolution(R.string.stream_statusbar_metric_resolution, R.string.setup_play_metric_resolution_preview), + Codec(R.string.stream_statusbar_metric_codec, R.string.setup_play_metric_codec_preview), + Server(R.string.stream_statusbar_metric_server, R.string.setup_play_metric_server_preview), + Latency(R.string.stream_statusbar_metric_latency, R.string.setup_play_metric_latency_preview), + PacketLoss(R.string.stream_statusbar_metric_loss, R.string.setup_play_metric_loss_preview), + ; + + fun enabledIn(settings: AppSettings): Boolean = when (this) { + Keyboard -> !settings.hideStreamButtons + Fps -> settings.streamStatsMetrics.fps + Ping -> settings.streamStatsMetrics.ping + Bitrate -> settings.streamStatsMetrics.bitrate + Battery -> settings.streamStatsMetrics.battery + Connection -> settings.streamStatsMetrics.connection + Resolution -> settings.streamStatsMetrics.resolution + Codec -> settings.streamStatsMetrics.codec + Server -> settings.streamStatsMetrics.location + Latency -> settings.streamStatsMetrics.latency + PacketLoss -> settings.streamStatsMetrics.packetLoss + } + + fun setEnabled(settings: AppSettings, enabled: Boolean): AppSettings { + if (enabledIn(settings) == enabled) return settings + if (this == Keyboard) return settings.copy(hideStreamButtons = !enabled) + + val metrics = settings.streamStatsMetrics + val updatedMetrics = when (this) { + Keyboard -> metrics + Fps -> metrics.copy(fps = enabled) + Ping -> metrics.copy(ping = enabled) + Bitrate -> metrics.copy(bitrate = enabled) + Battery -> metrics.copy(battery = enabled) + Connection -> metrics.copy(connection = enabled) + Resolution -> metrics.copy(resolution = enabled) + Codec -> metrics.copy(codec = enabled) + Server -> metrics.copy(location = enabled) + Latency -> metrics.copy(latency = enabled) + PacketLoss -> metrics.copy(packetLoss = enabled) + } + return settings.copy(streamStatsMetrics = updatedMetrics) + } +} diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/Streaming.kt b/android/app/src/main/java/com/opencloudgaming/opennow/Streaming.kt new file mode 100644 index 000000000..6d03a1e1d --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/Streaming.kt @@ -0,0 +1,4617 @@ +package com.opencloudgaming.opennow + +import android.Manifest +import android.app.ActivityManager +import android.content.Context +import android.content.pm.PackageManager +import android.content.res.Configuration +import android.media.AudioAttributes +import android.media.MediaCodecInfo +import android.media.MediaCodecList +import android.media.MediaRecorder +import android.opengl.GLES11Ext +import android.opengl.GLES20 +import android.os.Build +import android.os.CombinedVibration +import android.os.SystemClock +import android.os.VibrationEffect +import android.os.Vibrator +import android.os.VibratorManager +import android.view.InputDevice +import android.view.KeyEvent +import android.view.MotionEvent +import android.view.SurfaceHolder +import android.view.View +import androidx.annotation.RequiresApi +import androidx.core.content.ContextCompat +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import okhttp3.ConnectionSpec +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okhttp3.TlsVersion +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import okio.ByteString +import org.webrtc.AudioSource +import org.webrtc.AudioTrack +import org.webrtc.DataChannel +import org.webrtc.DefaultVideoDecoderFactory +import org.webrtc.DefaultVideoEncoderFactory +import org.webrtc.EglBase +import org.webrtc.GlShader +import org.webrtc.GlUtil +import org.webrtc.HardwareVideoDecoderFactory +import org.webrtc.IceCandidate +import org.webrtc.MediaConstraints +import org.webrtc.MediaStream +import org.webrtc.MediaStreamTrack +import org.webrtc.PeerConnection +import org.webrtc.PeerConnectionFactory +import org.webrtc.Predicate +import org.webrtc.RTCStats +import org.webrtc.RTCStatsCollectorCallback +import org.webrtc.RendererCommon +import org.webrtc.RtpCapabilities +import org.webrtc.RtpReceiver +import org.webrtc.RtpSender +import org.webrtc.RtpTransceiver +import org.webrtc.SdpObserver +import org.webrtc.SessionDescription +import org.webrtc.SurfaceViewRenderer +import org.webrtc.VideoCodecInfo +import org.webrtc.VideoDecoder +import org.webrtc.VideoDecoderFactory +import org.webrtc.VideoTrack +import org.webrtc.audio.AudioDeviceModule +import org.webrtc.audio.JavaAudioDeviceModule +import java.net.InetAddress +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.nio.FloatBuffer +import java.security.SecureRandom +import java.util.Locale +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min +import kotlin.math.roundToInt +import kotlin.math.sqrt + +/** Prefer Android's explicit relative axes whenever either axis carries a captured-mouse delta. */ +internal fun shouldUseAndroidRelativeMouseAxes(relativeDx: Float, relativeDy: Float): Boolean = + relativeDx != 0f || relativeDy != 0f + +/** + * Android pointer capture defines X/Y as relative movement. Some device builds also expose the + * explicit RELATIVE_X/Y axes. Keep the explicit axis when both representations agree, but trust the + * pointer-capture contract when an OEM reports the two with opposing signs. + */ +internal fun resolveAndroidCapturedMouseAxis(relativeAxis: Float, capturedAxis: Float): Float = when { + !relativeAxis.isFinite() -> capturedAxis.takeIf(Float::isFinite) ?: 0f + !capturedAxis.isFinite() -> relativeAxis + relativeAxis == 0f -> capturedAxis + capturedAxis == 0f -> relativeAxis + (relativeAxis > 0f) == (capturedAxis > 0f) -> relativeAxis + else -> capturedAxis +} + +internal fun androidCapturedMouseAxesConflict( + relativeDx: Float, + relativeDy: Float, + capturedDx: Float, + capturedDy: Float, +): Boolean = + ( + relativeDx.isFinite() && capturedDx.isFinite() && + relativeDx != 0f && capturedDx != 0f && (relativeDx > 0f) != (capturedDx > 0f) + ) || ( + relativeDy.isFinite() && capturedDy.isFinite() && + relativeDy != 0f && capturedDy != 0f && (relativeDy > 0f) != (capturedDy > 0f) + ) + +/** + * Captured pointers and explicit relative axes must stay relative on the host. Converting those + * deltas to a bounded absolute cursor prevents games from rotating the camera past a stream edge. + */ +internal fun shouldSendExternalMouseAsRelative( + capturedPointer: Boolean, + hasRelativeAxisMotion: Boolean, +): Boolean = capturedPointer || hasRelativeAxisMotion + +internal fun shouldUseFixedSizeStreamSurface( + videoWidth: Int, + videoHeight: Int, + rotation: Int, + viewWidth: Int, + viewHeight: Int, +): Boolean { + if (videoWidth <= 0 || videoHeight <= 0 || viewWidth <= 0 || viewHeight <= 0) return false + val quarterTurns = ((rotation % 360) + 360) % 360 + val rotatedWidth = if (quarterTurns == 90 || quarterTurns == 270) videoHeight else videoWidth + val rotatedHeight = if (quarterTurns == 90 || quarterTurns == 270) videoWidth else videoHeight + val frameLongEdge = max(rotatedWidth, rotatedHeight) + val frameShortEdge = min(rotatedWidth, rotatedHeight) + val viewLongEdge = max(viewWidth, viewHeight) + val viewShortEdge = min(viewWidth, viewHeight) + + // A fixed-size Surface is useful when SurfaceFlinger can upscale a smaller decoded buffer. + // At or above either viewport edge, some OEM compositors crop the fixed buffer instead of + // fitting it. Let the EGL renderer draw into the layout-sized Surface for those modes. + return frameLongEdge < viewLongEdge && frameShortEdge < viewShortEdge +} + +internal fun shouldSuppressHardwareKeyboardRepeat( + hardwareKeyboard: Boolean, + action: Int, + repeatCount: Int, +): Boolean = + hardwareKeyboard && action == KeyEvent.ACTION_DOWN && repeatCount > 0 + +internal fun isCurrentPeerOperation( + operationGeneration: Int, + currentGeneration: Int, + expectedPeer: Any?, + activePeer: Any?, +): Boolean = + activePeer != null && + operationGeneration == currentGeneration && + (expectedPeer == null || expectedPeer === activePeer) + +internal fun normalizedLiveBitrateKbps(maxBitrateKbps: Int): Int = + maxBitrateKbps.coerceAtLeast(1_000).let { value -> + ((value + 500) / 1_000) * 1_000 + } + +internal enum class HapticsOutputTarget { + Controller, + Device, + None, +} + +/** + * A forced [HapticsOutputPreference] is a hard selection, not a hint: picking Controller or Device + * and then silently falling back to the other would defeat the point of the setting, which exists + * precisely because one of the two is lying about its capability. + */ +internal fun selectHapticsOutputTarget( + vibrationEnabled: Boolean, + controllerRumbleAvailable: Boolean, + deviceHapticsAvailable: Boolean, + preference: HapticsOutputPreference = HapticsOutputPreference.Auto, +): HapticsOutputTarget = when { + !vibrationEnabled -> HapticsOutputTarget.None + preference == HapticsOutputPreference.Controller -> + if (controllerRumbleAvailable) HapticsOutputTarget.Controller else HapticsOutputTarget.None + preference == HapticsOutputPreference.Device -> + if (deviceHapticsAvailable) HapticsOutputTarget.Device else HapticsOutputTarget.None + controllerRumbleAvailable -> HapticsOutputTarget.Controller + deviceHapticsAvailable -> HapticsOutputTarget.Device + else -> HapticsOutputTarget.None +} + +/** + * GFN does not return ordinary game rumble for its native PlayStation controller identity. An + * explicitly forced Controller output therefore opts the pad into the XInput-compatible identity + * that carries the host's two-motor rumble stream. Auto deliberately preserves PlayStation button + * prompts; this compatibility tradeoff must never happen silently merely because vibration is on. + */ +internal fun usesPlayStationRumbleCompatibility( + vibrationEnabled: Boolean, + preference: HapticsOutputPreference, +): Boolean = vibrationEnabled && preference == HapticsOutputPreference.Controller + +class NativeStreamClient( + context: Context, + private val onState: (String) -> Unit, + private val onError: (String) -> Unit, + private val onSessionRecoveryRequired: (String) -> Unit = {}, + private val onFirstVideoFrameRendered: () -> Unit = {}, + private val onStats: (StreamRuntimeStats) -> Unit = {}, + private val onControllerMouseAssistChanged: (Boolean) -> Unit = {}, +) { + private val appContext = context.applicationContext + private val initialAndroidTvProfile = isAndroidTvProfile(appContext) + private val eglBase: EglBase = EglBase.create() + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + private val inputExecutor = java.util.concurrent.Executors.newSingleThreadExecutor { runnable -> + Thread(runnable, "opennow-input-sender").apply { + priority = Thread.MAX_PRIORITY + } + } + private val nativeLifecycleExecutor = java.util.concurrent.Executors.newSingleThreadExecutor { runnable -> + Thread(runnable, "opennow-native-lifecycle").apply { + priority = Thread.NORM_PRIORITY + } + } + private val inputScope = CoroutineScope(SupervisorJob() + inputExecutor.asCoroutineDispatcher()) + private val inputEncoder = InputEncoder() + private val audioDeviceModule: AudioDeviceModule = + JavaAudioDeviceModule.builder(appContext) + .setUseLowLatency(shouldUseLowLatencyStreamAudio(initialAndroidTvProfile)) + .setAudioSource(MediaRecorder.AudioSource.VOICE_COMMUNICATION) + .setUseStereoInput(false) + .setUseStereoOutput(true) + .setUseHardwareAcousticEchoCanceler(true) + .setUseHardwareNoiseSuppressor(true) + .setAudioRecordErrorCallback( + object : JavaAudioDeviceModule.AudioRecordErrorCallback { + override fun onWebRtcAudioRecordInitError(errorMessage: String?) { + recordStreamDiagnostic("microphone capture init failed error=${errorMessage.orEmpty()}") + } + + override fun onWebRtcAudioRecordStartError( + errorCode: JavaAudioDeviceModule.AudioRecordStartErrorCode?, + errorMessage: String?, + ) { + recordStreamDiagnostic( + "microphone capture start failed code=${errorCode?.name.orEmpty()} error=${errorMessage.orEmpty()}", + ) + } + + override fun onWebRtcAudioRecordError(errorMessage: String?) { + recordStreamDiagnostic("microphone capture runtime failed error=${errorMessage.orEmpty()}") + } + }, + ) + .setAudioRecordStateCallback( + object : JavaAudioDeviceModule.AudioRecordStateCallback { + override fun onWebRtcAudioRecordStart() { + recordStreamDiagnostic("microphone capture started") + } + + override fun onWebRtcAudioRecordStop() { + recordStreamDiagnostic("microphone capture stopped") + } + }, + ) + .setAudioAttributes( + AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_GAME) + .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) + .build(), + ) + .createAudioDeviceModule() + private var factory: PeerConnectionFactory? = null + /** + * Owned by [nativeLifecycleExecutor]. Volatile reads are used only for cheap readiness checks; + * every JNI call and the final close/dispose run on that same executor so a native handle + * cannot be freed while another thread is entering libwebrtc. + */ + @Volatile + private var peerConnection: PeerConnection? = null + private var signaling: GfnSignalingClient? = null + @Volatile + private var reliableInput: DataChannel? = null + @Volatile + private var partiallyReliableInput: DataChannel? = null + @Volatile + private var reliableInputState: DataChannel.State? = null + @Volatile + private var partiallyReliableInputState: DataChannel.State? = null + private val pendingInputSends = AtomicInteger(0) + private val synchronousInputFallback = AtomicBoolean(false) + private val workerInputSendConfirmed = AtomicBoolean(false) + private val directInputSendConfirmed = AtomicBoolean(false) + private var statsChannel: DataChannel? = null + private var lastParsedGameFps: Int? = null + // Informational only. Gamepad snapshots stay ordered and reliable because a late, older + // snapshot on the loss-tolerant channel can undo a newer button or stick state. + private var partiallyReliableGamepadMask = 0 + private var hidDeviceMask = 0 + private var partiallyReliableHidMask = 0 + @Volatile + private var inputHandshakeReady = false + private var hapticsAdvertised: Boolean? = null + private var lastHapticsAdvertisementAtMs = 0L + private var videoTrack: VideoTrack? = null + private var audioTrack: AudioTrack? = null + private var microphoneSource: AudioSource? = null + private var microphoneTrack: AudioTrack? = null + private var microphoneSender: RtpSender? = null + private var renderer: SurfaceViewRenderer? = null + private var rendererSharpnessDrawer: StreamSharpnessGlDrawer? = null + private var rendererSurfaceCallback: SurfaceHolder.Callback? = null + private val rendererSinkLifecycle = RendererSinkLifecycle() + private var heartbeatJob: Job? = null + private var gamepadKeepaliveJob: Job? = null + private var statsJob: Job? = null + private var iceRecoveryJob: Job? = null + private var offerTimeoutJob: Job? = null + private var bitrateUpdateJob: Job? = null + internal var settings: StreamSettings = StreamSettings() + private var session: SessionInfo? = null + @Volatile + private var transportGeneration = 0 + private var reconnectAttempts = 0 + private var transientSignalingFailures = 0 + /** + * Bitrate ceiling (kbps) requested for the live transport, exposed for the overlay indicator. + * A change is queued for the next legitimate offer. Replacing a healthy signaling/ICE + * transport just to change bitrate can strand the allocated cloud session on a stale endpoint. + */ + @Volatile + var liveBitrateLimitKbps: Int? = null + private var selectedProfileRetryApplied = false + private var stableMediaStallRestarts = 0 + private var transportHasStableMedia = false + private var consecutiveTransportProgressSamples = 0 + private var sessionRecoveryRequested = false + private var lastIceState: PeerConnection.IceConnectionState? = null + private var audioMuted = false + private var microphoneMuted = false + private var virtualButtons = 0 + private val virtualButtonPressSources = mutableMapOf>() + private var virtualLeftTrigger = 0 + private var virtualRightTrigger = 0 + private val virtualLeftTriggerPressSources = mutableSetOf() + private val virtualRightTriggerPressSources = mutableSetOf() + private var virtualLeftStickActive = false + private var virtualLeftStickX = 0 + private var virtualLeftStickY = 0 + private var virtualRightStickActive = false + private var virtualRightStickX = 0 + private var virtualRightStickY = 0 + private var virtualControllerVisible = false + @Volatile + private var touchMouseEnabled = false + private var physicalControllerConnected = false + private var physicalControllerActive = false + private var activeControllerId = 0 + private val controllerSlots = linkedMapOf() + private val controllerFamiliesBySlot = mutableMapOf() + private val controllerAxisAvailability = mutableMapOf() + private var physicalButtons = 0 + private var physicalHatButtons = 0 + private var steamMenuChordButtons = 0 + private val physicalSteamOverlayChord = SteamOverlayChordState() + private val virtualSteamOverlayChord = SteamOverlayChordState() + private var physicalLeftTriggerButtonPressed = false + private var physicalRightTriggerButtonPressed = false + private var lastLeftTrigger = 0 + private var lastRightTrigger = 0 + private var lastLeftStickX = 0 + private var lastLeftStickY = 0 + private var lastRightStickX = 0 + private var lastRightStickY = 0 + private var controllerMouseAutoArmOnStart = false + private var controllerMouseAssistActive = false + private var controllerMouseAssistAutoArmed = false + private var controllerMouseEmulationActive = false + private var controllerMouseMoveLogged = false + private var controllerMouseLeftButtonDown = false + private var controllerMouseRightButtonDown = false + private var mouseLastDeviceId = Int.MIN_VALUE + private var mouseLastSource = 0 + private var mouseLastX = 0f + private var mouseLastY = 0f + private var mousePositionValid = false + private var mouseSuppressNextAbsoluteDelta = false + private val mouseMoveBurstLock = Any() + private val mouseMoveBurstLimiter = MouseMoveBurstLimiter(MOUSE_MOVE_MIN_SEND_INTERVAL_MS) + private var mouseMoveBurstFlushJob: Job? = null + private val externalMouseMoveBurstLock = Any() + private val externalMouseMoveBurstLimiter = MouseMoveBurstLimiter(MOUSE_MOVE_MIN_SEND_INTERVAL_MS) + private var externalMouseMoveBurstFlushJob: Job? = null + private val gamepadStateBurstLock = Any() + private val gamepadStateBurstLimiter = GamepadStateBurstLimiter(GAMEPAD_STATE_MIN_SEND_INTERVAL_MS) + private var gamepadStateBurstFlushJob: Job? = null + private val externalMouseMotionAccumulator = MouseMotionAccumulator(minimumSendIntervalMs = 0L) + private val externalMouseAbsolutePosition = ExternalMouseAbsolutePosition() + private val gyroscopeMouseMotionAccumulator = MouseMotionAccumulator() + private val forwardedPhysicalInput = ForwardedPhysicalInputState() + private var externalMouseMotionDeviceId = Int.MIN_VALUE + private var externalMouseMotionSource = 0 + private var inputDropLogged = false + private var externalMouseEventLogged = false + private var externalMouseMoveSentLogged = false + private var externalMouseCapturedMoveSentLogged = false + private var externalMouseAxisConflictLogged = false + private var externalMouseAbsoluteJumpLogged = false + private var hardwareKeyboardEventLogged = false + private var physicalGamepadAxisLogged = false + private var lastStatsSample: StreamStatsSample? = null + private val processCpuSampler = ProcessCpuSampler() + private val packetLossWindow = StreamPacketLossWindow() + private val packetLossRecoveryGate = StreamPacketLossRecoveryGate() + private val decoderRecoveryGate = StreamDecoderRecoveryGate() + private var androidTvProfile = initialAndroidTvProfile + private var livenessWatchdog = newStreamLivenessWatchdog(androidTvProfile) + private var firstVideoFrameWatchdog = FirstVideoFrameWatchdog( + timeoutMs = firstVideoFrameRecoveryTimeoutMs(androidTvProfile), + ) + private val textSendMutex = Mutex() + private var guideAutoReleaseJob: Job? = null + private var steamMenuChordJob: Job? = null + private var physicalSteamOverlayChordReleaseJob: Job? = null + private var virtualSteamOverlayChordReleaseJob: Job? = null + private val lastRumbleEffectAtMs = LongArray(GAMEPAD_MAX_CONTROLLERS) + private val hapticsSupportLogged = BooleanArray(GAMEPAD_MAX_CONTROLLERS) + private var lastHapticsWarningAtMs = 0L + private var vibrationEnabled = true + private var hapticsOutputPreference = HapticsOutputPreference.Auto + private var deviceHapticsSupportLogged = false + private var released = false + private var controllerMouseLoopJob: Job? = null + private var physicalLeftStickX = 0f + private var physicalLeftStickY = 0f + private var physicalRightStickX = 0f + private var physicalRightStickY = 0f + private var controllerScrollAccumulator = 0f + + private data class RumbleEffectProfile( + val weakAmplitude: Int, + val strongAmplitude: Int, + val combinedAmplitude: Int, + ) { + val isStop: Boolean + get() = weakAmplitude <= 0 && strongAmplitude <= 0 && combinedAmplitude <= 0 + } + + private data class StreamStatsSample( + val inboundRtpId: String, + val atMs: Double, + val bytesReceived: Long, + val framesReceived: Long, + val framesDecoded: Long, + val totalDecodeTime: Double, + val packetsLost: Long, + val packetsReceived: Long, + ) + + private data class RuntimeStatsSnapshot( + val stats: StreamRuntimeStats, + val bytesReceived: Long?, + val framesDecoded: Long?, + ) + + private data class MicrophoneResources( + val sender: RtpSender?, + val track: AudioTrack?, + val source: AudioSource?, + ) + + private fun recordStreamDiagnostic(message: String) { + NativeInputDiagnostics.add("stream $message") + } + + private fun enqueueNativeLifecycleOperation(label: String, command: () -> Unit) { + runCatching { + nativeLifecycleExecutor.execute { + runCatching(command).onFailure { error -> + recordStreamDiagnostic("native lifecycle failed step=$label error=${error.message.orEmpty()}") + } + } + }.onFailure { error -> + recordStreamDiagnostic("native lifecycle rejected step=$label error=${error.message.orEmpty()}") + } + } + + /** Must be called from [nativeLifecycleExecutor] before entering a PeerConnection JNI method. */ + private fun activePeerConnection(generation: Int, expected: PeerConnection? = null): PeerConnection? { + val active = peerConnection + return active.takeIf { + isCurrentPeerOperation( + operationGeneration = generation, + currentGeneration = transportGeneration, + expectedPeer = expected, + activePeer = active, + ) + } + } + + private fun dispatchPeerFailure( + diagnostic: String, + message: String, + generation: Int, + expected: PeerConnection, + ) { + scope.launch { + if (!isCurrentPeerOperation(generation, transportGeneration, expected, peerConnection)) return@launch + recordStreamDiagnostic(diagnostic) + failStream(message, generation) + } + } + + init { + WebRtcRuntime.ensureInitialized(appContext) + val lowLatencyEnabled = SettingsStore(appContext).settings.value.nativeLowLatencyDecoder + factory = PeerConnectionFactory.builder() + .setOptions(PeerConnectionFactory.Options()) + .setAudioDeviceModule(audioDeviceModule) + .setVideoDecoderFactory( + OpenNowVideoDecoderFactory( + sharedContext = eglBase.eglBaseContext, + nativeLowLatencyDecoderEnabled = lowLatencyEnabled, + requestedFps = { settings.fps }, + ), + ) + .setVideoEncoderFactory(DefaultVideoEncoderFactory(eglBase.eglBaseContext, true, true)) + .createPeerConnectionFactory() + } + + fun createRenderer(context: Context, settings: StreamSettings): SurfaceViewRenderer = + SurfaceViewRenderer(context).also { rendererView -> + renderer?.let { oldRenderer -> + releaseRendererInternal(oldRenderer) + } + firstVideoFrameWatchdog.reset() + val requestedResolution = streamResolutionPixels(settings) + val displayMetrics = context.resources.displayMetrics + var fixedSizeSurface = shouldUseFixedSizeStreamSurface( + videoWidth = requestedResolution.first, + videoHeight = requestedResolution.second, + rotation = 0, + viewWidth = displayMetrics.widthPixels, + viewHeight = displayMetrics.heightPixels, + ) + val decodedResolutionTracker = DecodedResolutionTracker() + val rendererEvents = object : RendererCommon.RendererEvents { + override fun onFirstFrameRendered() { + firstVideoFrameWatchdog.markRendered() + NativeInputDiagnostics.add("video renderer first frame codec=${this@NativeStreamClient.settings.codec}") + scope.launch { onFirstVideoFrameRendered() } + } + + override fun onFrameResolutionChanged(videoWidth: Int, videoHeight: Int, rotation: Int) { + val transition = decodedResolutionTracker.observe(videoWidth, videoHeight) + val resolutionMessage = when { + transition == null -> + "video renderer resolution=${videoWidth}x$videoHeight rotation=$rotation" + transition.isInitial -> + "video renderer initial resolution=${videoWidth}x$videoHeight rotation=$rotation" + else -> + "video renderer resolution changed=${transition.previousWidth}x${transition.previousHeight}" + + "->${videoWidth}x$videoHeight rotation=$rotation keeping transport" + } + NativeInputDiagnostics.add(resolutionMessage) + if (videoWidth > 0 && videoHeight > 0) { + NativeStreamInputRouter.setDecodedStreamResolution(videoWidth, videoHeight) + } + if (transition?.isInitial == false) { + scope.launch { + if (renderer !== rendererView) return@launch + // Uplay/provider mode switches can briefly pause decoded-frame stats. + // Accept the decoder's new size and give the existing transport a fresh + // liveness window instead of interpreting the change as a stream crash. + livenessWatchdog.markConnected(SystemClock.elapsedRealtime()) + packetLossRecoveryGate.reset() + firstVideoFrameWatchdog.markRendered() + recordStreamDiagnostic( + "decoded resolution change accepted from=${transition.previousWidth}x${transition.previousHeight} " + + "to=${transition.width}x${transition.height}; cloud session and transport preserved", + ) + } + } + rendererView.post { + if ( + renderer !== rendererView || + rendererView.width <= 0 || + rendererView.height <= 0 + ) { + return@post + } + val nextFixedSizeSurface = shouldUseFixedSizeStreamSurface( + videoWidth = videoWidth, + videoHeight = videoHeight, + rotation = rotation, + viewWidth = rendererView.width, + viewHeight = rendererView.height, + ) + if (nextFixedSizeSurface != fixedSizeSurface) { + fixedSizeSurface = nextFixedSizeSurface + rendererView.setEnableHardwareScaler(nextFixedSizeSurface) + NativeInputDiagnostics.add( + "video renderer surface fixed=$nextFixedSizeSurface " + + "decoded=${videoWidth}x$videoHeight rotation=$rotation " + + "view=${rendererView.width}x${rendererView.height}", + ) + } + } + } + } + // Always attach the sharpness drawer so mid-session toggles take effect live: its + // fragment shader passes pixels through untouched while the amount is 0, and + // updateRendererSettings() adjusts the amount on the fly. Attaching it conditionally + // here used to make the overlay toggle dead whenever the session started with + // sharpening off (the drawer was never created to receive the new amount). + val sharpnessDrawer = StreamSharpnessGlDrawer().also { drawer -> + drawer.amount = streamSharpnessShaderStrength( + settings.streamSharpeningEnabled, + settings.streamSharpeningAmount, + ) + } + rendererSharpnessDrawer = sharpnessDrawer + rendererView.init(eglBase.eglBaseContext, rendererEvents, EglBase.CONFIG_PLAIN, sharpnessDrawer) + rendererView.setEnableHardwareScaler(fixedSizeSurface) + NativeInputDiagnostics.add( + "video renderer surface fixed=$fixedSizeSurface requested=${settings.resolution} " + + "display=${displayMetrics.widthPixels}x${displayMetrics.heightPixels}", + ) + rendererView.setMirror(false) + // Do not give SurfaceViewRenderer an opaque View background. Its decoded + // frames are presented by a separate Surface layer, so a normal View + // background can cover every rendered frame on physical devices. The + // Compose stream container already supplies the black pre-frame backdrop. + rendererView.setStreamScaling() + renderer = rendererView + rendererSurfaceCallback = object : SurfaceHolder.Callback { + override fun surfaceCreated(holder: SurfaceHolder) { + attachRendererSinkIfAvailable(rendererView) + } + + override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) { + NativeInputDiagnostics.add( + "video renderer surface changed=${width}x$height " + + "view=${rendererView.width}x${rendererView.height} fixed=$fixedSizeSurface", + ) + attachRendererSinkIfAvailable(rendererView) + } + + override fun surfaceDestroyed(holder: SurfaceHolder) { + detachRendererSink(rendererView) + } + }.also(rendererView.holder::addCallback) + attachRendererSinkIfAvailable(rendererView) + } + + fun releaseRenderer(candidate: SurfaceViewRenderer) { + if (renderer !== candidate) return + releaseRendererInternal(candidate) + renderer = null + rendererSharpnessDrawer = null + } + + private fun attachRendererSinkIfAvailable(candidate: SurfaceViewRenderer) { + if (renderer !== candidate || candidate.holder.surface?.isValid != true) return + val track = videoTrack ?: return + val generation = transportGeneration + enqueueNativeLifecycleOperation("renderer-sink-attach") { + if ( + activePeerConnection(generation) == null || + videoTrack !== track || + renderer !== candidate + ) { + return@enqueueNativeLifecycleOperation + } + if (!rendererSinkLifecycle.requestAttach()) return@enqueueNativeLifecycleOperation + firstVideoFrameWatchdog.reset() + track.addSink(candidate) + recordStreamDiagnostic("video renderer sink attached") + } + } + + private fun detachRendererSink(candidate: SurfaceViewRenderer) { + if (renderer !== candidate || !rendererSinkLifecycle.requestDetach()) return + val attachedTrack = videoTrack + val generation = transportGeneration + val surfaceValid = candidate.holder.surface?.isValid == true + enqueueNativeLifecycleOperation("renderer-sink-detach") { + if (activePeerConnection(generation) == null) return@enqueueNativeLifecycleOperation + attachedTrack?.removeSink(candidate) + recordStreamDiagnostic("video renderer sink detached surface=$surfaceValid") + } + } + + private fun releaseRendererInternal(candidate: SurfaceViewRenderer) { + prepareRendererForRelease(candidate) + enqueueNativeLifecycleOperation("renderer-release") { + candidate.release() + } + } + + private fun prepareRendererForRelease(candidate: SurfaceViewRenderer) { + if (renderer === candidate) { + detachRendererSink(candidate) + } + rendererSurfaceCallback?.let(candidate.holder::removeCallback) + rendererSurfaceCallback = null + candidate.hideSurfaceBeforeRelease() + } + + private fun SurfaceViewRenderer.hideSurfaceBeforeRelease() { + // SurfaceView frames are composited in a separate native layer. Hide that + // layer before tearing down WebRTC so a stale/pre-frame buffer cannot remain + // above the next Compose screen while SurfaceFlinger processes the detach. + alpha = 0f + visibility = View.GONE + } + + fun updateRendererSettings(settings: StreamSettings) { + val updatedSettings = this.settings.copy( + mouseSensitivity = settings.mouseSensitivity, + mouseAcceleration = settings.mouseAcceleration, + streamSharpeningEnabled = settings.streamSharpeningEnabled, + streamSharpeningAmount = settings.streamSharpeningAmount, + mouseScrollSensitivity = settings.mouseScrollSensitivity, + ) + if (updatedSettings == this.settings) return + this.settings = updatedSettings + rendererSharpnessDrawer?.amount = streamSharpnessShaderStrength(settings.streamSharpeningEnabled, settings.streamSharpeningAmount) + } + + private fun SurfaceViewRenderer.setStreamScaling() { + // Keep the complete decoded frame inside the SurfaceView. Phone edge-to-edge + // presentation is applied by scaling the View itself, never by cropping video. + setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FIT) + } + + fun updateHapticsSettings(enabled: Boolean, preference: HapticsOutputPreference) { + if (vibrationEnabled == enabled && hapticsOutputPreference == preference) return + val outputChanged = hapticsOutputPreference != preference + vibrationEnabled = enabled + hapticsOutputPreference = preference + // Switching output mid-rumble has to silence the old one: the stop that eventually arrives + // from the host is routed to the *new* target and would leave the old motor running. + if (!enabled || outputChanged) { + stopAllGamepadRumble() + } + if (outputChanged && hasAnyControllerState()) { + // The compatibility bit is carried in every gamepad state packet, not in the separate + // haptics advertisement. Publish the new identity immediately when the setting changes. + sendCurrentGamepadState() + } + updateHapticsAdvertisement(force = true) + } + + /** + * Applies every mid-session-adjustable setting in one call so overlay and settings-screen + * changes reach the renderer, the haptics advertisement, and the input router together. All + * three setters are idempotent (guarded on value change), so this is safe to invoke from any + * LaunchedEffect keyed on the relevant fields — including on every AndroidView update. + */ + fun applyLiveSettings( + rendererSettings: StreamSettings, + vibrationEnabled: Boolean, + hapticsOutput: HapticsOutputPreference, + stretchToFit: Boolean, + ) { + updateRendererSettings(rendererSettings) + updateHapticsSettings(vibrationEnabled, hapticsOutput) + NativeStreamInputRouter.setStretchToFit(stretchToFit) + } + + fun updateControllerMouseAssistAutoArm(enabled: Boolean) { + controllerMouseAutoArmOnStart = enabled + if (!enabled) { + setControllerMouseAssistActive(false) + } + } + + fun updateAndroidTvProfile(enabled: Boolean) { + if (androidTvProfile == enabled) return + androidTvProfile = enabled + livenessWatchdog = newStreamLivenessWatchdog(enabled) + firstVideoFrameWatchdog = FirstVideoFrameWatchdog( + timeoutMs = firstVideoFrameRecoveryTimeoutMs(enabled), + ) + recordStreamDiagnostic("recovery profile=${if (enabled) "android-tv" else "mobile"}") + } + + fun setControllerMouseAssistEnabled(enabled: Boolean) { + setControllerMouseAssistActive(enabled) + } + + fun setControllerMouseEmulationActive(enabled: Boolean) { + if (controllerMouseEmulationActive == enabled) return + if (!enabled) { + // Release any held mouse buttons so state stays clean. + releaseControllerMouseButtons() + // Zero both physical and virtual left-stick memory so neither controller path + // delivers stale deflection to the game after mode is disabled. + lastLeftStickX = 0 + lastLeftStickY = 0 + virtualLeftStickActive = false + virtualLeftStickX = 0 + virtualLeftStickY = 0 + physicalLeftStickX = 0f + physicalLeftStickY = 0f + physicalRightStickX = 0f + physicalRightStickY = 0f + } + controllerMouseEmulationActive = enabled + updateControllerMouseLoop() + // Push a fresh gamepad state immediately so the zeroed stick is sent before any next frame. + sendCurrentGamepadState() + NativeInputDiagnostics.add("controller mouse emulation ${if (enabled) "enabled" else "disabled"}") + } + + private fun startControllerMouseLoop() { + if (controllerMouseLoopJob?.isActive == true) return + controllerMouseLoopJob = scope.launch { + val currentJob = coroutineContext[Job] + while (currentJob?.isActive == true) { + // Poll/send mouse updates at 60Hz (approx 16ms delay; physical caches are cleared on disconnect/reset) + delay(16L) + if (controllerMouseEmulationActive) { + sendControllerMouseMove(physicalLeftStickX, physicalLeftStickY) + sendControllerMouseScroll(physicalRightStickY) + } + if (controllerMouseAssistActive) { + sendControllerMouseMove(physicalRightStickX, physicalRightStickY) + } + } + } + } + + private fun updateControllerMouseLoop() { + if (shouldRunControllerMouseLoop(controllerMouseAssistActive, controllerMouseEmulationActive)) { + startControllerMouseLoop() + } else { + stopControllerMouseLoop() + } + } + + private fun stopControllerMouseLoop() { + controllerMouseLoopJob?.cancel() + controllerMouseLoopJob = null + physicalLeftStickX = 0f + physicalLeftStickY = 0f + physicalRightStickX = 0f + physicalRightStickY = 0f + } + + fun start(session: SessionInfo, settings: StreamSettings) { + if (released) return + this.session = session + this.settings = settings + transportGeneration += 1 + reconnectAttempts = 0 + transientSignalingFailures = 0 + selectedProfileRetryApplied = false + stableMediaStallRestarts = 0 + sessionRecoveryRequested = false + bitrateUpdateJob?.cancel() + bitrateUpdateJob = null + liveBitrateLimitKbps = null + lastStatsSample = null + processCpuSampler.reset() + ProcessCpuDiagnostics.beginStream() + packetLossWindow.reset() + packetLossRecoveryGate.reset() + decoderRecoveryGate.reset() + livenessWatchdog.reset() + firstVideoFrameWatchdog.reset() + onStats(StreamRuntimeStats()) + audioDeviceModule.setSpeakerMute(audioMuted) + audioDeviceModule.setMicrophoneMute( + settings.microphoneMode == MicrophoneMode.Disabled || microphoneMuted, + ) + closeTransport(clearInputState = false) + armControllerMouseAssistForSession() + recordStreamDiagnostic( + "start session=${streamDiagnosticId(session.sessionId)} status=${session.status} server=${session.serverIp.take(96)} signaling=${signalingUrlForDiagnostics(session.signalingUrl, session.sessionId)} settings=${settings.resolution}/${settings.fps}/${settings.codec} bitrate=${settings.maxBitrateMbps} microphone=${settings.microphoneMode.name}", + ) + startTransport(session, settings, transportGeneration) + updateControllerMouseLoop() + } + + fun stop() { + stopControllerMouseLoop() + transportGeneration += 1 + reconnectAttempts = 0 + transientSignalingFailures = 0 + stableMediaStallRestarts = 0 + sessionRecoveryRequested = false + bitrateUpdateJob?.cancel() + bitrateUpdateJob = null + liveBitrateLimitKbps = null + packetLossRecoveryGate.reset() + decoderRecoveryGate.reset() + livenessWatchdog.reset() + firstVideoFrameWatchdog.reset() + closeTransport(clearInputState = true) + emitState("Stopped") + } + + fun release() { + if (released) return + released = true + if (androidTvProfile) { + val activeRenderer = renderer + activeRenderer?.let(::prepareRendererForRelease) + renderer = null + rendererSharpnessDrawer = null + stop() + scope.launch { + delay(ANDROID_TV_CODEC_RELEASE_SETTLE_MS) + finishRelease(activeRenderer) + } + return + } + stop() + renderer?.let { activeRenderer -> + releaseRendererInternal(activeRenderer) + } + renderer = null + rendererSharpnessDrawer = null + finishRelease() + } + + private fun finishRelease(preparedRenderer: SurfaceViewRenderer? = null) { + inputScope.cancel() + inputExecutor.shutdown() + val activeFactory = factory + factory = null + enqueueNativeLifecycleOperation("runtime-release") { + preparedRenderer?.let { renderer -> + runCatching { renderer.release() } + .onFailure { error -> recordStreamDiagnostic("renderer release failed error=${error.message.orEmpty()}") } + } + runCatching { activeFactory?.dispose() } + .onFailure { error -> recordStreamDiagnostic("peer factory release failed error=${error.message.orEmpty()}") } + runCatching { audioDeviceModule.release() } + .onFailure { error -> recordStreamDiagnostic("audio module release failed error=${error.message.orEmpty()}") } + runCatching { eglBase.release() } + .onFailure { error -> recordStreamDiagnostic("EGL release failed error=${error.message.orEmpty()}") } + } + nativeLifecycleExecutor.shutdown() + scope.cancel() + } + + private fun resetInputState() { + virtualButtons = 0 + virtualButtonPressSources.clear() + virtualLeftTrigger = 0 + virtualRightTrigger = 0 + virtualLeftTriggerPressSources.clear() + virtualRightTriggerPressSources.clear() + virtualLeftStickActive = false + virtualLeftStickX = 0 + virtualLeftStickY = 0 + virtualRightStickActive = false + virtualRightStickX = 0 + virtualRightStickY = 0 + virtualControllerVisible = false + physicalControllerConnected = false + physicalControllerActive = false + physicalButtons = 0 + physicalHatButtons = 0 + steamMenuChordButtons = 0 + physicalSteamOverlayChord.reset() + virtualSteamOverlayChord.reset() + physicalLeftTriggerButtonPressed = false + physicalRightTriggerButtonPressed = false + guideAutoReleaseJob?.cancel() + guideAutoReleaseJob = null + steamMenuChordJob?.cancel() + steamMenuChordJob = null + physicalSteamOverlayChordReleaseJob?.cancel() + physicalSteamOverlayChordReleaseJob = null + virtualSteamOverlayChordReleaseJob?.cancel() + virtualSteamOverlayChordReleaseJob = null + stopAllGamepadRumble() + lastLeftTrigger = 0 + lastRightTrigger = 0 + lastLeftStickX = 0 + lastLeftStickY = 0 + lastRightStickX = 0 + lastRightStickY = 0 + physicalLeftStickX = 0f + physicalLeftStickY = 0f + physicalRightStickX = 0f + physicalRightStickY = 0f + controllerScrollAccumulator = 0f + controllerMouseAssistActive = false + controllerMouseAssistAutoArmed = false + controllerMouseEmulationActive = false + controllerMouseMoveLogged = false + controllerMouseLeftButtonDown = false + controllerMouseRightButtonDown = false + activeControllerId = 0 + controllerSlots.clear() + controllerFamiliesBySlot.clear() + controllerAxisAvailability.clear() + mousePositionValid = false + mouseSuppressNextAbsoluteDelta = false + externalMouseMotionAccumulator.reset() + externalMouseAbsolutePosition.reset() + gyroscopeMouseMotionAccumulator.reset() + externalMouseMotionDeviceId = Int.MIN_VALUE + externalMouseMotionSource = 0 + forwardedPhysicalInput.reset() + inputDropLogged = false + externalMouseEventLogged = false + externalMouseMoveSentLogged = false + externalMouseCapturedMoveSentLogged = false + externalMouseAbsoluteJumpLogged = false + hardwareKeyboardEventLogged = false + physicalGamepadAxisLogged = false + resetGamepadStateBurstLimiter() + inputEncoder.resetGamepadSequences() + inputHandshakeReady = false + emitControllerMouseAssistChanged(false) + } + + fun dispatchKey(event: KeyEvent): Boolean { + // Before the WebRTC input channel opens, leave keyboard events available to Android and + // Compose. Consuming them here makes queue/desktop UI look completely unresponsive. + if (!hasReadyInputChannel()) return false + if (event.isGamepadEvent() && dispatchGamepadKey(event)) { + return true + } + val key = InputEncoder.mapKeyEvent(event) + val hardwareKeyboard = event.isHardwareKeyboardSource() + if (hardwareKeyboard && !hardwareKeyboardEventLogged) { + hardwareKeyboardEventLogged = true + NativeInputDiagnostics.add( + "hardware keyboard event action=${event.action} key=${event.keyCode} " + + "scan=${event.scanCode} repeat=${event.repeatCount} source=${event.source} " + + "device=${event.deviceId} mapped=${key != null}", + ) + } + if (shouldSuppressHardwareKeyboardRepeat(hardwareKeyboard, event.action, event.repeatCount)) { + return true + } + val textFallback = InputEncoder.keyboardTextFallbackChar( + unicodeChar = event.unicodeChar, + baseUnicodeChar = event.getUnicodeChar(0), + mapped = key != null, + altGraph = event.isAltPressed && event.isCtrlPressed, + ) + if (textFallback != null) { + // Send once, on the press. The release carries the same character and would double it. + if (event.action == KeyEvent.ACTION_DOWN) { + sendKeyboardTextFallback(textFallback) + NativeInputDiagnostics.add( + "keyboard text fallback key=${event.keyCode} char=$textFallback mapped=${key != null}", + ) + } + return true + } + val packet = key?.let { if (event.action == KeyEvent.ACTION_DOWN) inputEncoder.encodeKeyDown(it) else inputEncoder.encodeKeyUp(it) } + if (packet == null) { + if (hardwareKeyboard && (event.action == KeyEvent.ACTION_DOWN || event.action == KeyEvent.ACTION_UP)) { + NativeInputDiagnostics.add("hardware keyboard consumed unmapped key=${event.keyCode} action=${event.action}") + return true + } + return false + } + val sent = sendReliableInput(packet) + if (hardwareKeyboard && (event.action == KeyEvent.ACTION_DOWN || event.action == KeyEvent.ACTION_UP)) { + forwardedPhysicalInput.recordKey( + deviceId = event.deviceId, + keyCode = event.keyCode, + scanCode = event.scanCode, + payload = key, + pressed = event.action == KeyEvent.ACTION_DOWN, + sent = sent, + ) + } + if (hardwareKeyboard && !sent) { + NativeInputDiagnostics.add("hardware keyboard consumed without send key=${event.keyCode} ${inputChannelStateSummary()}") + } + return sent || hardwareKeyboard + } + + fun dispatchMotion(event: MotionEvent): Boolean { + // Mouse/controller events must fall through to native UI until cloud input is ready. + if (!hasReadyInputChannel()) return false + if (event.isGamepadMotionEvent()) { + return dispatchJoystick(event) + } + if (event.isMouseLikePointer()) { + return dispatchMouseLikePointer(event) + } + return false + } + + /** + * Mouse packets are relative deltas, so every packet must arrive in order. Keep Android mouse + * motion on the reliable channel: losing a delta leaves the VM cursor permanently displaced, + * and the Android partially-reliable data channel has failed to move the live host cursor. + */ + fun sendRawMouseMove(dx: Int, dy: Int): Boolean { + return sendInput( + inputEncoder.encodeMouseMove(dx, dy), + partiallyReliable = false, + fallbackToReliable = true, + resultDiagnosticKey = "mouse.move", + ) + } + + /** Sends one batch of finger updates. Reliable: a dropped lift leaves a finger stuck down. */ + internal fun sendNativeTouch(touches: List): Boolean { + val packet = inputEncoder.encodeTouchBatch(touches) ?: return false + return sendReliableInput(packet) + } + + fun sendTouchMouseMove(dx: Int, dy: Int): Boolean { + var adjustedDx = dx * settings.mouseSensitivity + var adjustedDy = dy * settings.mouseSensitivity + if (settings.mouseAcceleration > 1) { + val speed = sqrt(adjustedDx * adjustedDx + adjustedDy * adjustedDy) + val strength = (settings.mouseAcceleration - 1f) / 149f + val accelFactor = 1f + min(0.6f * strength, (speed / 50f) * strength) + adjustedDx *= accelFactor + adjustedDy *= accelFactor + } + return sendBurstLimitedMouseMove( + dx = adjustedDx.roundToInt(), + dy = adjustedDy.roundToInt(), + partiallyReliable = false, + ) + } + + /** + * Sends the leading movement immediately, then combines only the excess events inside the + * next short interval. This retains responsive mouse/controller movement while keeping a + * 500 Hz device from creating 500 SCTP packets and sender coroutines per second. + */ + private fun sendBurstLimitedMouseMove(dx: Int, dy: Int, partiallyReliable: Boolean): Boolean { + if (openInputChannel(partiallyReliable, fallbackToReliable = true) == null) return false + if (dx == 0 && dy == 0) return true + return synchronized(mouseMoveBurstLock) { + val batch = mouseMoveBurstLimiter.offer( + dx = dx, + dy = dy, + partiallyReliable = partiallyReliable, + nowMs = SystemClock.elapsedRealtime(), + ) + if (batch == null && mouseMoveBurstFlushJob?.isActive != true) { + scheduleMouseMoveBurstFlushLocked() + } + batch?.let(::sendMouseMoveBatch) ?: true + } + } + + /** Must be called with [mouseMoveBurstLock] held. */ + private fun scheduleMouseMoveBurstFlushLocked() { + mouseMoveBurstFlushJob = inputScope.launch { + while (true) { + val waitMs = synchronized(mouseMoveBurstLock) { + mouseMoveBurstLimiter.delayUntilFlushMs(SystemClock.elapsedRealtime()) + } + if (waitMs == null) { + synchronized(mouseMoveBurstLock) { mouseMoveBurstFlushJob = null } + return@launch + } + if (waitMs > 0L) delay(waitMs) + + val flushed = synchronized(mouseMoveBurstLock) { + val nowMs = SystemClock.elapsedRealtime() + if ((mouseMoveBurstLimiter.delayUntilFlushMs(nowMs) ?: 0L) > 0L) { + false + } else { + mouseMoveBurstLimiter.flush(nowMs)?.let(::sendMouseMoveBatch) + mouseMoveBurstFlushJob = null + true + } + } + if (flushed) return@launch + } + } + } + + private fun flushPendingMouseMove() { + synchronized(mouseMoveBurstLock) { + mouseMoveBurstFlushJob?.cancel() + mouseMoveBurstFlushJob = null + mouseMoveBurstLimiter.flush(SystemClock.elapsedRealtime())?.let(::sendMouseMoveBatch) + } + synchronized(externalMouseMoveBurstLock) { + externalMouseMoveBurstFlushJob?.cancel() + externalMouseMoveBurstFlushJob = null + externalMouseMoveBurstLimiter.flush(SystemClock.elapsedRealtime()) + ?.let(::sendExternalMouseAbsoluteBatch) + } + } + + private fun resetMouseMoveBurstLimiter() { + synchronized(mouseMoveBurstLock) { + mouseMoveBurstFlushJob?.cancel() + mouseMoveBurstFlushJob = null + mouseMoveBurstLimiter.reset() + } + resetExternalMouseMoveBurstLimiter() + } + + private fun resetExternalMouseMoveBurstLimiter() { + synchronized(externalMouseMoveBurstLock) { + externalMouseMoveBurstFlushJob?.cancel() + externalMouseMoveBurstFlushJob = null + externalMouseMoveBurstLimiter.reset() + } + } + + private fun resetGamepadStateBurstLimiter() { + synchronized(gamepadStateBurstLock) { + gamepadStateBurstFlushJob?.cancel() + gamepadStateBurstFlushJob = null + gamepadStateBurstLimiter.reset() + } + } + + private fun sendMouseMoveBatch(batch: MouseMoveBatch): Boolean = + sendInput( + inputEncoder.encodeMouseMove(batch.dx, batch.dy), + partiallyReliable = batch.partiallyReliable, + ) + + private fun dispatchMouseLikePointer(event: MotionEvent): Boolean { + if (!externalMouseEventLogged) { + externalMouseEventLogged = true + val relativeDx = if (Build.VERSION.SDK_INT >= 26) event.getAxisValue(MotionEvent.AXIS_RELATIVE_X) else 0f + val relativeDy = if (Build.VERSION.SDK_INT >= 26) event.getAxisValue(MotionEvent.AXIS_RELATIVE_Y) else 0f + NativeInputDiagnostics.add( + "external mouse event action=${event.actionMasked} source=${event.source} device=${event.deviceId} buttons=${event.buttonState} relativeDx=$relativeDx relativeDy=$relativeDy", + ) + } + when (event.actionMasked) { + MotionEvent.ACTION_HOVER_MOVE, + MotionEvent.ACTION_MOVE, + -> { + val capturedPointer = event.isRelativeMousePointer() + val hasRelativeAxisMotion = event.hasRelativeAxisMotion() + if (shouldSendExternalMouseAsRelative(capturedPointer, hasRelativeAxisMotion)) { + // Captured-pointer implementations disagree about where relative motion lives. + // Pixel/Logitech exposes RELATIVE_X/Y, while other devices only populate X/Y. + // Resolve every coalesced sample independently so neither representation is + // discarded when Android changes it during capture or device hand-off. + val sent = sendExternalMouseMotionSamples(event) + if (capturedPointer && sent && !externalMouseCapturedMoveSentLogged) { + externalMouseCapturedMoveSentLogged = true + NativeInputDiagnostics.add( + "external mouse captured move sent source=${event.source} device=${event.deviceId} " + + "x=${event.x} y=${event.y} " + + "relativeX=${event.getAxisValue(MotionEvent.AXIS_RELATIVE_X)} " + + "relativeY=${event.getAxisValue(MotionEvent.AXIS_RELATIVE_Y)}", + ) + } + if (sent && !externalMouseMoveSentLogged) { + externalMouseMoveSentLogged = true + val mode = if (capturedPointer) "capturedRelative" else "relative" + NativeInputDiagnostics.add( + "external mouse move sent source=${event.source} device=${event.deviceId} mode=$mode packet=relative", + ) + } + mousePositionValid = false + } else if (mousePositionValid && mouseLastDeviceId == event.deviceId && mouseLastSource == event.source) { + val dx = event.x - mouseLastX + val dy = event.y - mouseLastY + if (dx != 0f || dy != 0f) { + val discontinuous = mouseSuppressNextAbsoluteDelta || + abs(dx) > EXTERNAL_MOUSE_ABSOLUTE_DELTA_LIMIT_PX || + abs(dy) > EXTERNAL_MOUSE_ABSOLUTE_DELTA_LIMIT_PX + if (discontinuous) { + externalMouseMotionAccumulator.reset() + if (!externalMouseAbsoluteJumpLogged) { + externalMouseAbsoluteJumpLogged = true + NativeInputDiagnostics.add("external mouse absolute delta rebased source=${event.source} device=${event.deviceId} dx=${dx.roundToInt()} dy=${dy.roundToInt()}") + } + } else { + val sent = sendExternalMouseMotion(event, dx, dy) + if (sent && !externalMouseMoveSentLogged) { + externalMouseMoveSentLogged = true + NativeInputDiagnostics.add("external mouse move sent source=${event.source} device=${event.deviceId} mode=absoluteDelta") + } + } + mouseSuppressNextAbsoluteDelta = false + } + } else { + mouseSuppressNextAbsoluteDelta = false + } + if (!event.isRelativeMousePointer()) { + rememberMousePosition(event) + } + } + MotionEvent.ACTION_DOWN -> { + mouseSuppressNextAbsoluteDelta = true + rememberMousePosition(event) + flushPendingMouseMove() + sendExternalMouseButton(event.primaryMouseButton(), pressed = true) + } + MotionEvent.ACTION_UP, + MotionEvent.ACTION_CANCEL, + -> { + mousePositionValid = false + mouseSuppressNextAbsoluteDelta = true + flushPendingMouseMove() + sendExternalMouseButton(event.primaryMouseButton(), pressed = false) + } + MotionEvent.ACTION_BUTTON_PRESS -> { + mouseSuppressNextAbsoluteDelta = true + rememberMousePosition(event) + flushPendingMouseMove() + val handled = sendExternalMouseButton(event.actionButton.toGfnMouseButton(), pressed = true) + if (!handled) { + NativeInputDiagnostics.add("external mouse button consumed without send action=press button=${event.actionButton} ${inputChannelStateSummary()}") + } + return true + } + MotionEvent.ACTION_BUTTON_RELEASE -> { + mousePositionValid = false + mouseSuppressNextAbsoluteDelta = true + flushPendingMouseMove() + val handled = sendExternalMouseButton(event.actionButton.toGfnMouseButton(), pressed = false) + if (!handled) { + NativeInputDiagnostics.add("external mouse button consumed without send action=release button=${event.actionButton} ${inputChannelStateSummary()}") + } + return true + } + MotionEvent.ACTION_SCROLL -> { + val vertical = event.getAxisValue(MotionEvent.AXIS_VSCROLL) + if (abs(vertical) >= 0.01f) { + flushPendingMouseMove() + sendReliableInput(inputEncoder.encodeMouseWheel((vertical * 120).roundToInt())) + } + } + } + return true + } + + private fun sendExternalMouseButton(button: Int, pressed: Boolean): Boolean { + val sent = sendReliableInput( + inputEncoder.encodeMouseButton( + if (pressed) InputEncoder.INPUT_MOUSE_BUTTON_DOWN else InputEncoder.INPUT_MOUSE_BUTTON_UP, + button, + ), + ) + forwardedPhysicalInput.recordMouseButton(button = button, pressed = pressed, sent = sent) + return sent + } + + /** Releases input whose platform UP event can be lost when a desktop window loses focus. */ + fun releasePhysicalInputForLifecycle(reason: String) { + val pressed = forwardedPhysicalInput.takeReleaseSnapshot() + if (pressed.isEmpty) return + flushPendingMouseMove() + var queued = 0 + pressed.keys.forEach { payload -> + if ( + sendReliableInput( + inputEncoder.encodeKeyUp(payload.copy(modifiers = 0, timestampUs = timestampUs())), + ) + ) { + queued += 1 + } + } + pressed.mouseButtons.forEach { button -> + if (sendReliableInput(inputEncoder.encodeMouseButton(InputEncoder.INPUT_MOUSE_BUTTON_UP, button))) { + queued += 1 + } + } + NativeInputDiagnostics.add( + "physical input lifecycle release reason=$reason keys=${pressed.keys.size} " + + "mouseButtons=${pressed.mouseButtons.size} queued=$queued ${inputChannelStateSummary()}", + ) + } + + private fun MotionEvent.hasRelativeAxisMotion(): Boolean { + if (Build.VERSION.SDK_INT < 26) return false + for (historyIndex in 0 until historySize) { + if ( + getHistoricalAxisValue(MotionEvent.AXIS_RELATIVE_X, historyIndex) != 0f || + getHistoricalAxisValue(MotionEvent.AXIS_RELATIVE_Y, historyIndex) != 0f + ) { + return true + } + } + return getAxisValue(MotionEvent.AXIS_RELATIVE_X) != 0f || + getAxisValue(MotionEvent.AXIS_RELATIVE_Y) != 0f + } + + private fun sendExternalMouseMotionSamples(event: MotionEvent): Boolean { + prepareExternalMouseMotion(event) + var sendDx = 0 + var sendDy = 0 + // Pointer capture may coalesce several raw samples into one MotionEvent. Process every + // sample before one packet is sent so neither slow motion nor high-polling-rate input is lost. + for (historyIndex in 0 until event.historySize) { + val relativeDx = event.getHistoricalAxisValue(MotionEvent.AXIS_RELATIVE_X, historyIndex) + val relativeDy = event.getHistoricalAxisValue(MotionEvent.AXIS_RELATIVE_Y, historyIndex) + val capturedDx = event.getHistoricalX(historyIndex) + val capturedDy = event.getHistoricalY(historyIndex) + val capturedPointer = event.isRelativeMousePointer() + val useRelativeAxes = shouldUseAndroidRelativeMouseAxes(relativeDx, relativeDy) + val dx = when { + capturedPointer -> resolveAndroidCapturedMouseAxis(relativeDx, capturedDx) + useRelativeAxes -> relativeDx + else -> capturedDx + } + val dy = when { + capturedPointer -> resolveAndroidCapturedMouseAxis(relativeDy, capturedDy) + useRelativeAxes -> relativeDy + else -> capturedDy + } + externalMouseMotionAccumulator.add( + dx = dx, + dy = dy, + eventTimeMs = event.getHistoricalEventTime(historyIndex), + sensitivity = settings.mouseSensitivity, + acceleration = settings.mouseAcceleration, + )?.let { delta -> + sendDx += delta.dx + sendDy += delta.dy + } + } + val relativeDx = event.getAxisValue(MotionEvent.AXIS_RELATIVE_X) + val relativeDy = event.getAxisValue(MotionEvent.AXIS_RELATIVE_Y) + val capturedPointer = event.isRelativeMousePointer() + val useRelativeAxes = shouldUseAndroidRelativeMouseAxes(relativeDx, relativeDy) + val dx = when { + capturedPointer -> resolveAndroidCapturedMouseAxis(relativeDx, event.x) + useRelativeAxes -> relativeDx + else -> event.x + } + val dy = when { + capturedPointer -> resolveAndroidCapturedMouseAxis(relativeDy, event.y) + useRelativeAxes -> relativeDy + else -> event.y + } + if ( + capturedPointer && + !externalMouseAxisConflictLogged && + androidCapturedMouseAxesConflict(relativeDx, relativeDy, event.x, event.y) + ) { + externalMouseAxisConflictLogged = true + NativeInputDiagnostics.add( + "external mouse axis conflict resolved sdk=${Build.VERSION.SDK_INT} source=${event.source} " + + "relativeX=$relativeDx relativeY=$relativeDy capturedX=${event.x} capturedY=${event.y}", + ) + } + externalMouseMotionAccumulator.add( + dx = dx, + dy = dy, + eventTimeMs = event.eventTime, + sensitivity = settings.mouseSensitivity, + acceleration = settings.mouseAcceleration, + )?.let { delta -> + sendDx += delta.dx + sendDy += delta.dy + } + // Relative motion is unbounded by design. Camera-look must not inherit the clamped + // absolute cursor used by the uncaptured desktop-pointer path below. + return (sendDx != 0 || sendDy != 0) && sendRawMouseMove(sendDx, sendDy) + } + + private fun sendExternalMouseMotion(event: MotionEvent, dx: Float, dy: Float): Boolean { + prepareExternalMouseMotion(event) + val delta = externalMouseMotionAccumulator.add( + dx = dx, + dy = dy, + eventTimeMs = event.eventTime, + sensitivity = settings.mouseSensitivity, + acceleration = settings.mouseAcceleration, + ) ?: return false + return sendExternalMouseAbsoluteMove(delta.dx, delta.dy) + } + + private fun sendExternalMouseAbsoluteMove(dx: Int, dy: Int): Boolean { + val usePartiallyReliable = + partiallyReliableInputState == DataChannel.State.OPEN && + SdpTools.supportsPartiallyReliableHidInput( + hidDeviceMask = hidDeviceMask, + partiallyReliableHidMask = partiallyReliableHidMask, + inputType = InputEncoder.INPUT_MOUSE_ABS, + ) + if (openInputChannel(usePartiallyReliable, fallbackToReliable = true) == null) return false + if (dx == 0 && dy == 0) return true + return synchronized(externalMouseMoveBurstLock) { + val batch = externalMouseMoveBurstLimiter.offer( + dx = dx, + dy = dy, + partiallyReliable = usePartiallyReliable, + nowMs = SystemClock.elapsedRealtime(), + ) + if (batch == null && externalMouseMoveBurstFlushJob?.isActive != true) { + scheduleExternalMouseMoveBurstFlushLocked() + } + batch?.let(::sendExternalMouseAbsoluteBatch) ?: true + } + } + + /** Must be called with [externalMouseMoveBurstLock] held. */ + private fun scheduleExternalMouseMoveBurstFlushLocked() { + externalMouseMoveBurstFlushJob = inputScope.launch { + while (true) { + val waitMs = synchronized(externalMouseMoveBurstLock) { + externalMouseMoveBurstLimiter.delayUntilFlushMs(SystemClock.elapsedRealtime()) + } + if (waitMs == null) { + synchronized(externalMouseMoveBurstLock) { externalMouseMoveBurstFlushJob = null } + return@launch + } + if (waitMs > 0L) delay(waitMs) + + val flushed = synchronized(externalMouseMoveBurstLock) { + val nowMs = SystemClock.elapsedRealtime() + if ((externalMouseMoveBurstLimiter.delayUntilFlushMs(nowMs) ?: 0L) > 0L) { + false + } else { + externalMouseMoveBurstLimiter.flush(nowMs)?.let(::sendExternalMouseAbsoluteBatch) + externalMouseMoveBurstFlushJob = null + true + } + } + if (flushed) return@launch + } + } + } + + private fun sendExternalMouseAbsoluteBatch(batch: MouseMoveBatch): Boolean { + val (width, height) = streamResolutionPixels(settings) + val position = externalMouseAbsolutePosition.moveBy(batch.dx, batch.dy, width, height) + return sendInput( + inputEncoder.encodeMouseAbsolute(position.x, position.y, position.width, position.height), + partiallyReliable = batch.partiallyReliable, + fallbackToReliable = true, + resultDiagnosticKey = "mouse.absolute", + ) + } + + private fun prepareExternalMouseMotion(event: MotionEvent) { + if (externalMouseMotionDeviceId == event.deviceId && externalMouseMotionSource == event.source) return + externalMouseMotionAccumulator.reset() + externalMouseAbsolutePosition.reset() + resetExternalMouseMoveBurstLimiter() + externalMouseMotionDeviceId = event.deviceId + externalMouseMotionSource = event.source + } + + private fun rememberMousePosition(event: MotionEvent) { + mouseLastDeviceId = event.deviceId + mouseLastSource = event.source + mouseLastX = event.x + mouseLastY = event.y + mousePositionValid = true + } + + fun sendTouchMouseClick(delayBeforeDownMs: Long = 0L) { + scope.launch { + if (delayBeforeDownMs > 0) { + delay(delayBeforeDownMs) + } + if (!setTouchMouseButton(true)) return@launch + delay(160L) + setTouchMouseButton(false) + } + } + + fun sendTouchMouseRightClick() { + scope.launch { + if (!sendMouseButton(button = 3, pressed = true, source = "touch mouse right click")) return@launch + delay(160L) + sendMouseButton(button = 3, pressed = false, source = "touch mouse right click") + } + } + + fun sendTouchMouseWheel(delta: Int) { + flushPendingMouseMove() + sendReliableInput(inputEncoder.encodeMouseWheel(delta)) + } + + fun sendKeyCode(keyCode: Int) { + val down = KeyEvent(SystemClock.uptimeMillis(), SystemClock.uptimeMillis(), KeyEvent.ACTION_DOWN, keyCode, 0) + val up = KeyEvent(SystemClock.uptimeMillis(), SystemClock.uptimeMillis(), KeyEvent.ACTION_UP, keyCode, 0) + val mapped = InputEncoder.mapKeyEvent(down) + val downQueued = dispatchKey(down) + val upQueued = dispatchKey(up) + NativeInputDiagnostics.add( + "overlay keyboard key=$keyCode mapped=${mapped != null} " + + "vk=${mapped?.keycode} scan=${mapped?.scancode} " + + "downQueued=$downQueued upQueued=$upQueued " + + inputChannelStateSummary(), + ) + } + + /** Applies each Android IME edit immediately while preserving input packet order. */ + fun syncText(syncedText: String?, draft: String) { + val edit = streamKeyboardEdit(syncedText, draft) + if (edit == StreamKeyboardEdit.None) return + scope.launch { + textSendMutex.withLock { + when (edit) { + StreamKeyboardEdit.None -> Unit + is StreamKeyboardEdit.Append -> sendTextLocked(edit.text.take(STREAM_TEXT_SEND_MAX_CHARS)) + is StreamKeyboardEdit.Backspace -> repeat(edit.count.coerceAtMost(STREAM_TEXT_SEND_MAX_CHARS)) { + if (!sendTextKeyStroke(KeyEvent.KEYCODE_DEL)) return@withLock + } + is StreamKeyboardEdit.Replace -> { + if (!selectAllAndDeleteRemoteText()) return@withLock + sendTextLocked(edit.text.take(STREAM_TEXT_SEND_MAX_CHARS)) + } + } + } + } + } + + /** Clears the focused remote field without dismissing the Android stream keyboard. */ + fun clearText() { + scope.launch { + textSendMutex.withLock { + selectAllAndDeleteRemoteText() + } + } + } + + /** Queues editor control keys behind any text currently being replayed to the host. */ + fun sendTextControlKey(keyCode: Int) { + scope.launch { + textSendMutex.withLock { + sendTextKeyStroke(keyCode) + } + } + } + + /** + * Routes one character down the same reliable text channel the on-screen keyboard bar uses, and + * behind the same mutex, so a physical keystroke cannot overtake text already being replayed. + */ + private fun sendKeyboardTextFallback(char: Char) { + scope.launch { + textSendMutex.withLock { + sendTextLocked(char.toString()) + } + } + } + + private suspend fun sendTextLocked(text: String) { + inputEncoder.encodeTextInput(text).forEach { packet -> + if (!sendTextPacketWithRetry(packet)) return + } + } + + private suspend fun selectAllAndDeleteRemoteText(): Boolean { + val ctrl = InputEncoder.mapKeyboardPayload(KeyEvent.KEYCODE_CTRL_LEFT, unicode = 0, scanCode = 0) + ?: return false + val selectAll = InputEncoder.mapKeyboardPayload( + keyCode = KeyEvent.KEYCODE_A, + unicode = 0, + scanCode = 0, + ctrl = true, + ) ?: return false + val ctrlPressed = sendKeyboardPayloadWithRetry(ctrl.copy(modifiers = 0x02), isDown = true) + if (!ctrlPressed) return false + val selected = sendKeyboardPayloadWithRetry(selectAll, isDown = true) && + sendKeyboardPayloadWithRetry(selectAll, isDown = false) + val ctrlReleased = sendKeyboardPayloadWithRetry(ctrl.copy(modifiers = 0), isDown = false) + if (!selected || !ctrlReleased) return false + return sendTextKeyStroke(KeyEvent.KEYCODE_DEL) + } + + private suspend fun sendTextKeyStroke(keyCode: Int): Boolean { + val payload = InputEncoder.mapKeyboardPayload(keyCode, unicode = 0, scanCode = 0) ?: return false + return sendKeyboardPayloadWithRetry(payload, isDown = true) && + sendKeyboardPayloadWithRetry(payload, isDown = false) + } + + private fun sendKeyboardPayload(payload: InputEncoder.KeyboardPayload, isDown: Boolean): Boolean = + sendReliableInput(if (isDown) inputEncoder.encodeKeyDown(payload) else inputEncoder.encodeKeyUp(payload)) + + private suspend fun sendTextPacketWithRetry(packet: ByteArray): Boolean { + repeat(STREAM_TEXT_SEND_ATTEMPTS) { attempt -> + if (sendReliableInput(packet)) { + delay(STREAM_TEXT_PACKET_DELAY_MS) + return true + } + if (attempt < STREAM_TEXT_SEND_ATTEMPTS - 1) delay(STREAM_TEXT_RETRY_DELAY_MS) + } + NativeInputDiagnostics.add( + "overlay keyboard dropped unicode bytes=${packet.size} ${inputChannelStateSummary()}", + ) + return false + } + + private suspend fun sendKeyboardPayloadWithRetry(payload: InputEncoder.KeyboardPayload, isDown: Boolean): Boolean { + repeat(STREAM_TEXT_SEND_ATTEMPTS) { attempt -> + if (sendKeyboardPayload(payload, isDown)) { + delay(STREAM_TEXT_PACKET_DELAY_MS) + return true + } + if (attempt < STREAM_TEXT_SEND_ATTEMPTS - 1) { + delay(STREAM_TEXT_RETRY_DELAY_MS) + } + } + NativeInputDiagnostics.add( + "overlay keyboard dropped key=${payload.keycode} action=${if (isDown) "down" else "up"} ${inputChannelStateSummary()}", + ) + return false + } + + fun setAudioMuted(muted: Boolean) { + audioMuted = muted + audioDeviceModule.setSpeakerMute(muted) + val generation = transportGeneration + enqueueNativeLifecycleOperation("audio-track-mute") { + if (activePeerConnection(generation) == null) return@enqueueNativeLifecycleOperation + audioTrack?.setEnabled(!muted) + } + } + + fun setMicrophoneEnabled(enabled: Boolean) { + microphoneMuted = !enabled + audioDeviceModule.setMicrophoneMute(!enabled) + val generation = transportGeneration + enqueueNativeLifecycleOperation("microphone-track-mute") { + if (activePeerConnection(generation) == null) return@enqueueNativeLifecycleOperation + microphoneTrack?.setEnabled(enabled) + } + recordStreamDiagnostic("microphone ${if (enabled) "enabled" else "muted"}") + } + + /** Queues a receive bitrate ceiling for the next offer without replacing the active transport. */ + fun updateBitrateLimit(maxBitrateKbps: Int) { + val normalizedKbps = normalizedLiveBitrateKbps(maxBitrateKbps) + if (liveBitrateLimitKbps == normalizedKbps && bitrateUpdateJob?.isActive != true) return + liveBitrateLimitKbps = normalizedKbps + bitrateUpdateJob?.cancel() + bitrateUpdateJob = scope.launch { + delay(LIVE_BITRATE_UPDATE_DEBOUNCE_MS) + bitrateUpdateJob = null + val updatedSettings = settings.copy(maxBitrateMbps = normalizedKbps / 1_000) + if (updatedSettings == settings) return@launch + settings = updatedSettings + recordStreamDiagnostic("live bitrate limit queued for next offer $normalizedKbps kbps") + } + } + + fun setTouchMouseButton(pressed: Boolean): Boolean { + return sendMouseButton(button = 1, pressed = pressed, source = "touch mouse") + } + + private fun sendMouseButton(button: Int, pressed: Boolean, source: String): Boolean { + flushPendingMouseMove() + val packet = inputEncoder.encodeMouseButton( + if (pressed) InputEncoder.INPUT_MOUSE_BUTTON_DOWN else InputEncoder.INPUT_MOUSE_BUTTON_UP, + button, + ) + val reliableSent = sendInput(packet, partiallyReliable = false) + val partialSent = sendInput(packet, partiallyReliable = true) + NativeInputDiagnostics.add( + "$source button=$button ${if (pressed) "down" else "up"} reliableSent=$reliableSent partialSent=$partialSent ${inputChannelStateSummary()}", + ) + return reliableSent || partialSent + } + + private fun armControllerMouseAssistForSession() { + if (!controllerMouseAutoArmOnStart) return + controllerMouseAssistActive = true + controllerMouseAssistAutoArmed = true + controllerMouseMoveLogged = false + emitControllerMouseAssistChanged(true) + NativeInputDiagnostics.add("controller mouse assist auto-armed for Android TV") + } + + private fun setControllerMouseAssistActive(active: Boolean, autoArmed: Boolean = false) { + if (controllerMouseAssistActive == active && controllerMouseAssistAutoArmed == (autoArmed && active)) return + if (!active) releaseControllerMouseButtons() + controllerMouseAssistActive = active + controllerMouseAssistAutoArmed = autoArmed && active + updateControllerMouseLoop() + controllerMouseMoveLogged = false + sendCurrentGamepadState() + emitControllerMouseAssistChanged(active) + NativeInputDiagnostics.add("controller mouse assist ${if (active) "enabled" else "disabled"} auto=$controllerMouseAssistAutoArmed") + } + + private fun emitControllerMouseAssistChanged(active: Boolean) { + scope.launch { onControllerMouseAssistChanged(active) } + } + + private fun releaseControllerMouseButtons() { + if (controllerMouseLeftButtonDown) { + controllerMouseLeftButtonDown = false + sendMouseButton(button = 1, pressed = false, source = "controller mouse") + } + if (controllerMouseRightButtonDown) { + controllerMouseRightButtonDown = false + sendMouseButton(button = 3, pressed = false, source = "controller mouse") + } + } + + private fun setControllerMouseButton(button: Int, pressed: Boolean): Boolean { + when (button) { + 1 -> { + if (controllerMouseLeftButtonDown == pressed) return true + controllerMouseLeftButtonDown = pressed + } + 3 -> { + if (controllerMouseRightButtonDown == pressed) return true + controllerMouseRightButtonDown = pressed + } + else -> return false + } + val sent = sendMouseButton(button = button, pressed = pressed, source = "controller mouse") + if (!pressed && controllerMouseAssistAutoArmed && button == 1) { + setControllerMouseAssistActive(false) + } + return sent + } + + fun setVirtualButton(buttonMask: Int, pressed: Boolean) { + setVirtualButtonFromSource(buttonMask, "primary-$buttonMask", pressed) + } + + /** Keeps duplicate movable buttons from releasing an action another finger still holds. */ + fun setVirtualButtonFromSource(buttonMask: Int, sourceId: String, pressed: Boolean) { + val sources = virtualButtonPressSources.getOrPut(buttonMask) { mutableSetOf() } + val wasEffectivelyPressed = sources.isNotEmpty() + val changed = if (pressed) sources.add(sourceId) else sources.remove(sourceId) + if (!pressed && sources.isEmpty()) virtualButtonPressSources.remove(buttonMask) + if (!changed) return + val effectivelyPressed = sources.isNotEmpty() + if (wasEffectivelyPressed == effectivelyPressed) return + // When left-stick mouse emulation is active, intercept A (left click) and B (right click). + if (controllerMouseEmulationActive) { + val mouseButton = AndroidControllerMouseAssist.mouseButtonForGamepad(buttonMask) + if (mouseButton != null) { + val sent = setControllerMouseButton(mouseButton, effectivelyPressed) + recordVirtualButtonDiagnostic( + buttonMask = buttonMask, + pressed = effectivelyPressed, + route = "mouse-$mouseButton", + sent = sent, + ) + return + } + } + virtualButtons = if (effectivelyPressed) virtualButtons or buttonMask else virtualButtons and buttonMask.inv() + val steamOverlayChordActivated = virtualSteamOverlayChord.update(virtualButtons) + val sent = sendCurrentGamepadState() + recordVirtualButtonDiagnostic( + buttonMask = buttonMask, + pressed = effectivelyPressed, + route = "gamepad", + sent = sent, + ) + if (steamOverlayChordActivated) { + scheduleVirtualSteamOverlayChordRelease() + } + } + + private fun recordVirtualButtonDiagnostic( + buttonMask: Int, + pressed: Boolean, + route: String, + sent: Boolean, + ) { + val action = if (pressed) "down" else "up" + val maskHex = buttonMask.toString(16).padStart(4, '0') + NativeInputDiagnostics.retain( + key = "controller.virtual-button.$maskHex.$action", + message = "virtual gamepad button mask=0x$maskHex action=$action route=$route sent=$sent " + + "buttons=$virtualButtons ${inputChannelStateSummary()}", + ) + } + + fun openSteamMenu() { + steamMenuChordJob?.cancel() + val controllerId = activeControllerId + steamMenuChordButtons = SteamMenuChord.buttons(aPressed = false) + sendCurrentGamepadState(controllerId) + steamMenuChordJob = scope.launch { + delay(STEAM_MENU_MODIFIER_DELAY_MS) + steamMenuChordButtons = SteamMenuChord.buttons(aPressed = true) + sendCurrentGamepadState(controllerId) + delay(GAMEPAD_GUIDE_AUTO_RELEASE_MS) + steamMenuChordButtons = SteamMenuChord.buttons(aPressed = false) + sendCurrentGamepadState(controllerId) + delay(STEAM_MENU_MODIFIER_DELAY_MS) + steamMenuChordButtons = 0 + sendCurrentGamepadState(controllerId) + NativeInputDiagnostics.add("Steam Menu sent Guide+A chord slot=$controllerId") + } + } + + fun setVirtualTrigger(left: Boolean, pressed: Boolean) { + setVirtualTriggerFromSource(left, if (left) "primary-left" else "primary-right", pressed) + } + + /** Trigger equivalent of [setVirtualButtonFromSource]. */ + fun setVirtualTriggerFromSource(left: Boolean, sourceId: String, pressed: Boolean) { + val sources = if (left) virtualLeftTriggerPressSources else virtualRightTriggerPressSources + val wasEffectivelyPressed = sources.isNotEmpty() + val changed = if (pressed) sources.add(sourceId) else sources.remove(sourceId) + if (!changed) return + val effectivelyPressed = sources.isNotEmpty() + if (wasEffectivelyPressed == effectivelyPressed) return + if (left) { + virtualLeftTrigger = if (effectivelyPressed) 255 else 0 + } else { + virtualRightTrigger = if (effectivelyPressed) 255 else 0 + } + val sent = sendCurrentGamepadState() + val side = if (left) "LT" else "RT" + val action = if (effectivelyPressed) "down" else "up" + NativeInputDiagnostics.retain( + key = "controller.virtual-trigger.$side.$action", + message = "virtual gamepad trigger=$side action=$action sent=$sent " + + "triggers=$virtualLeftTrigger,$virtualRightTrigger ${inputChannelStateSummary()}", + ) + } + + fun setVirtualLeftStick(x: Float, y: Float) { + val scale = radialDeadzoneScale(x, y, deadzone = 0.08f) + val normalizedX = x * scale + val normalizedY = y * scale + if (controllerMouseEmulationActive) { + // Redirect left-stick input to mouse movement; keep virtual stick zeroed so the game + // receives no stick deflection from the touch controller either. + physicalLeftStickX = normalizedX + physicalLeftStickY = normalizedY + virtualLeftStickActive = false + virtualLeftStickX = 0 + virtualLeftStickY = 0 + sendBurstLimitedGamepadState() + return + } + virtualLeftStickActive = normalizedX != 0f || normalizedY != 0f + virtualLeftStickX = normalizeToInt16(normalizedX) + virtualLeftStickY = normalizeToInt16(-normalizedY) + sendBurstLimitedGamepadState() + } + + fun setVirtualRightStick(x: Float, y: Float) { + val scale = radialDeadzoneScale(x, y, deadzone = 0.08f) + val normalizedX = x * scale + val normalizedY = y * scale + if (controllerMouseEmulationActive) { + // Redirect right-stick input to scrolling; keep virtual stick zeroed. + physicalRightStickX = normalizedX + physicalRightStickY = normalizedY + virtualRightStickActive = false + virtualRightStickX = 0 + virtualRightStickY = 0 + sendBurstLimitedGamepadState() + return + } + virtualRightStickActive = normalizedX != 0f || normalizedY != 0f + virtualRightStickX = normalizeToInt16(normalizedX) + virtualRightStickY = normalizeToInt16(-normalizedY) + sendBurstLimitedGamepadState() + } + + fun beginGyroscopeMouseAim() = gyroscopeMouseMotionAccumulator.reset() + + fun sendGyroscopeMouseMove(dx: Float, dy: Float, eventTimeMs: Long): Boolean = + sendAccumulatedMouseMove( + accumulator = gyroscopeMouseMotionAccumulator, + dx = dx, + dy = dy, + eventTimeMs = eventTimeMs, + sensitivity = 1f, + acceleration = 1, + ) + + fun endGyroscopeMouseAim(eventTimeMs: Long) { + flushAccumulatedMouseMove(gyroscopeMouseMotionAccumulator, eventTimeMs) + } + + private fun sendAccumulatedMouseMove( + accumulator: MouseMotionAccumulator, + dx: Float, + dy: Float, + eventTimeMs: Long, + sensitivity: Float, + acceleration: Int, + ): Boolean { + val delta = accumulator.add( + dx = dx, + dy = dy, + eventTimeMs = eventTimeMs, + sensitivity = sensitivity, + acceleration = acceleration, + ) ?: return true + return sendRawMouseMove(delta.dx, delta.dy) + } + + private fun flushAccumulatedMouseMove(accumulator: MouseMotionAccumulator, eventTimeMs: Long) { + accumulator.add( + dx = 0f, + dy = 0f, + eventTimeMs = eventTimeMs, + sensitivity = 1f, + acceleration = 1, + force = true, + )?.let { delta -> sendRawMouseMove(delta.dx, delta.dy) } + accumulator.reset() + } + + fun setVirtualControllerVisible(visible: Boolean) { + if (virtualControllerVisible == visible) return + virtualControllerVisible = visible + sendCurrentGamepadState() + } + + fun setTouchMouseEnabled(enabled: Boolean) { + touchMouseEnabled = enabled + } + + private fun startTransport(session: SessionInfo, settings: StreamSettings, generation: Int) { + inputDropLogged = false + lastIceState = null + lastStatsSample = null + packetLossWindow.reset() + packetLossRecoveryGate.reset() + transportHasStableMedia = false + consecutiveTransportProgressSamples = 0 + firstVideoFrameWatchdog.reset() + emitStats(StreamRuntimeStats()) + audioDeviceModule.setSpeakerMute(audioMuted) + audioDeviceModule.setMicrophoneMute( + settings.microphoneMode == MicrophoneMode.Disabled || microphoneMuted, + ) + recordStreamDiagnostic( + "transport start generation=$generation reconnectAttempts=$reconnectAttempts session=${streamDiagnosticId(session.sessionId)} iceServers=${session.iceServers.size} media=${session.mediaConnectionInfo?.let { "${it.ip}:${it.port}" } ?: "unknown"}", + ) + emitState(if (reconnectAttempts > 0) "Reconnecting signaling" else "Connecting signaling") + signaling = GfnSignalingClient(session, settings = settings) { event -> + // OkHttp invokes WebSocket callbacks on its own threads. Keep transport state on the + // existing main-owner scope; offer parsing and all PeerConnection JNI work are handed + // to nativeLifecycleExecutor below so this does not add UI-thread SDP work. + scope.launch { handleSignaling(event, generation) } + }.also { it.connect() } + } + + private fun closeTransport(clearInputState: Boolean, cancelRecovery: Boolean = true) { + if (peerConnection != null || signaling != null || reliableInput != null || partiallyReliableInput != null) { + recordStreamDiagnostic("transport close clearInput=$clearInputState cancelRecovery=$cancelRecovery lastIce=${lastIceState?.name ?: "none"}") + } + if (cancelRecovery) { + iceRecoveryJob?.cancel() + iceRecoveryJob = null + } + if (heartbeatJob != null) { + NativeInputDiagnostics.retain( + "heartbeat.input.lifecycle", + "input heartbeat stopped generation=$transportGeneration", + ) + heartbeatJob?.cancel() + } + gamepadKeepaliveJob?.cancel() + statsJob?.cancel() + offerTimeoutJob?.cancel() + heartbeatJob = null + gamepadKeepaliveJob = null + statsJob = null + offerTimeoutJob = null + resetMouseMoveBurstLimiter() + resetGamepadStateBurstLimiter() + lastStatsSample = null + packetLossWindow.reset() + packetLossRecoveryGate.reset() + decoderRecoveryGate.reset() + lastIceState = null + livenessWatchdog.reset() + val closingSignaling = signaling + val closingRenderer = renderer + val closingRendererSinkAttached = rendererSinkLifecycle.requestDetach() + signaling = null + reliableInput = null + partiallyReliableInput = null + reliableInputState = null + partiallyReliableInputState = null + statsChannel = null + lastParsedGameFps = null + partiallyReliableGamepadMask = 0 + hidDeviceMask = 0 + partiallyReliableHidMask = 0 + inputHandshakeReady = false + hapticsAdvertised = null + lastHapticsAdvertisementAtMs = 0L + if (clearInputState) resetInputState() + enqueueNativeLifecycleOperation("transport-close") { + // Peer creation, SDP/ICE/stats JNI calls, and this detach/dispose step share this one + // executor. Capturing the peer here (instead of on the caller thread) closes the race + // where a stale offer could create a peer after teardown had already captured null. + val closingMicrophone = takeMicrophoneResources() + val closingPeerConnection = peerConnection + val closingVideoTrack = videoTrack + peerConnection = null + videoTrack = null + audioTrack = null + // Repeat the fast caller-thread detach after all earlier native operations. A callback + // already running on this executor may have attached a channel just before close was + // queued; clearing again prevents that stale wrapper from escaping this generation. + reliableInput = null + partiallyReliableInput = null + reliableInputState = null + partiallyReliableInputState = null + statsChannel = null + lastParsedGameFps = null + partiallyReliableGamepadMask = 0 + hidDeviceMask = 0 + partiallyReliableHidMask = 0 + inputHandshakeReady = false + runCatching { closingSignaling?.disconnect() } + .onFailure { error -> recordStreamDiagnostic("signaling disconnect failed error=${error.message.orEmpty()}") } + if (closingRendererSinkAttached && closingRenderer != null) { + runCatching { closingVideoTrack?.removeSink(closingRenderer) } + .onFailure { error -> recordStreamDiagnostic("video sink detach failed error=${error.message.orEmpty()}") } + } + runCatching { disposeMicrophoneResources(closingMicrophone) } + .onFailure { error -> recordStreamDiagnostic("microphone release failed error=${error.message.orEmpty()}") } + runCatching { closingPeerConnection?.close() } + .onFailure { error -> recordStreamDiagnostic("peer close failed error=${error.message.orEmpty()}") } + runCatching { closingPeerConnection?.dispose() } + .onFailure { error -> recordStreamDiagnostic("peer dispose failed error=${error.message.orEmpty()}") } + } + } + + private fun handleSignaling(event: SignalingEvent, generation: Int) { + if (generation != transportGeneration) return + when (event) { + SignalingEvent.Connected -> { + transientSignalingFailures = 0 + recordStreamDiagnostic("signaling connected generation=$generation") + emitState("Waiting for offer") + startOfferTimeout(generation) + } + is SignalingEvent.Disconnected -> { + recordStreamDiagnostic("signaling disconnected ${event.reason}") + // A clean WebSocket close is never proof that the allocated cloud session ended. + // With packet loss or high RTT the signaling socket can close while media remains + // healthy or independently reconnectable. Even an explicit 410 is probed through + // session recovery before the local client leaves the stream. + val disposition = signalingFailureDisposition(event.reason) + if (shouldPreserveMediaAfterSignalingFailure(disposition, lastIceState)) { + recordStreamDiagnostic( + "signaling disconnected while ICE=${lastIceState?.name}; preserving active media transport", + ) + return + } + when (disposition) { + SignalingFailureDisposition.SessionEnded -> { + recordStreamDiagnostic("Signaling reported a terminal session response; verifying allocated session before local exit.") + requestSessionRecovery("The provider reported that the signaling session ended.") + } + SignalingFailureDisposition.RecoverSession -> { + recordStreamDiagnostic("Signaling endpoint is stale. Recovering cloud session.") + requestSessionRecovery("Signaling endpoint became unavailable while connecting to the cloud session.") + } + SignalingFailureDisposition.RetrySignaling -> + scheduleTransientSignalingRetry(event.reason, generation) + SignalingFailureDisposition.RetryTransport -> + scheduleTransportReconnect("Signaling disconnected: ${event.reason}", SIGNALING_RECONNECT_DELAY_MS, generation) + } + } + is SignalingEvent.Error -> { + recordStreamDiagnostic("signaling error ${event.message}") + val disposition = signalingFailureDisposition(event.message) + if (shouldPreserveMediaAfterSignalingFailure(disposition, lastIceState)) { + recordStreamDiagnostic( + "signaling error while ICE=${lastIceState?.name}; preserving active media transport", + ) + return + } + when (disposition) { + SignalingFailureDisposition.SessionEnded -> { + recordStreamDiagnostic("Signaling error reported a terminal session response; verifying allocated session before local exit.") + requestSessionRecovery("The provider reported that the signaling session ended.") + } + SignalingFailureDisposition.RecoverSession -> { + recordStreamDiagnostic("Signaling endpoint is stale. Recovering cloud session.") + requestSessionRecovery("Signaling endpoint became unavailable while connecting to the cloud session.") + } + SignalingFailureDisposition.RetrySignaling -> + scheduleTransientSignalingRetry(event.message, generation) + SignalingFailureDisposition.RetryTransport -> + scheduleTransportReconnect("Signaling failed: ${event.message}", SIGNALING_RECONNECT_DELAY_MS, generation) + } + } + is SignalingEvent.Log -> recordStreamDiagnostic(event.message) + is SignalingEvent.RemoteIce -> { + enqueueNativeLifecycleOperation("remote-ice") { + val pc = activePeerConnection(generation) + val added = pc?.addIceCandidate(event.candidate) + recordStreamDiagnostic( + "remote ICE add requested accepted=${added ?: false} pcReady=${pc != null} ${event.candidate.diagnosticSummary()}", + ) + } + } + is SignalingEvent.Offer -> { + offerTimeoutJob?.cancel() + offerTimeoutJob = null + enqueueNativeLifecycleOperation("remote-offer") { + handleOffer(event.sdp, generation) + } + } + } + } + + /** Runs on [nativeLifecycleExecutor], preserving native handle ownership through SDP setup. */ + private fun handleOffer(rawOffer: String, generation: Int) { + if (generation != transportGeneration) return + val currentSession = session ?: return + recordStreamDiagnostic(sdpDiagnosticSummary("raw offer", rawOffer)) + val fixed = prepareRemoteOffer(rawOffer, currentSession) + val preferred = SdpTools.preferCodec(fixed, settings) + if (fixed != rawOffer) { + recordStreamDiagnostic(sdpDiagnosticSummary("fixed offer", fixed)) + } + if (preferred != fixed) { + recordStreamDiagnostic(sdpDiagnosticSummary("preferred offer", preferred)) + } + val pc = ensurePeerConnection(currentSession, generation) + if (activePeerConnection(generation, pc) == null) return + ensureInputDataChannels(pc, preferred) + inputEncoder.setProtocolVersion(SdpTools.parseInputProtocolVersion(preferred)) + partiallyReliableGamepadMask = SdpTools.parsePartiallyReliableGamepadMask(preferred) + hidDeviceMask = SdpTools.parseHidDeviceMask(preferred) + partiallyReliableHidMask = SdpTools.parsePartiallyReliableHidMask(preferred) + recordStreamDiagnostic( + "offer input protocol=${SdpTools.parseInputProtocolVersion(preferred)} " + + "partialGamepadMask=$partiallyReliableGamepadMask " + + "hidMask=${hidDeviceMask.toUInt()} partialHidMask=${partiallyReliableHidMask.toUInt()} " + + "mouseMoveTransport=relative-captured-absolute-uncaptured", + ) + pc.setRemoteDescription( + object : SimpleSdpObserver() { + override fun onSetSuccess() { + enqueueNativeLifecycleOperation("remote-description-set") remoteDescription@ { + val active = activePeerConnection(generation, pc) ?: return@remoteDescription + recordStreamDiagnostic("remote description set") + // WebRTC disposes previously returned transceiver wrappers whenever + // getTransceivers() refreshes its cache. Share one snapshot so the + // microphone sender remains valid through transport teardown. + val transceivers = active.transceivers + applyVideoCodecPreferences(transceivers) + attachMicrophoneTrack(active, transceivers) + active.createAnswer( + object : SimpleSdpObserver() { + override fun onCreateSuccess(description: SessionDescription?) { + enqueueNativeLifecycleOperation("answer-created") answerCreated@ { + val answerPeer = activePeerConnection(generation, pc) + ?: return@answerCreated + val rawDescription = description ?: run { + dispatchPeerFailure( + diagnostic = "answer create returned empty description", + message = "WebRTC returned an empty answer", + generation = generation, + expected = pc, + ) + return@answerCreated + } + val munged = SdpTools.mungeAnswerSdp( + rawDescription.description, + settings.maxBitrateMbps * 1000, + ) + recordStreamDiagnostic(sdpDiagnosticSummary("created answer", munged)) + if ( + settings.codec != VideoCodec.H264 && + !SdpTools.negotiatesCodec(munged, settings.codec) + ) { + scope.launch { + if (!isCurrentPeerOperation(generation, transportGeneration, pc, peerConnection)) { + return@launch + } + NativeInputDiagnostics.add( + "local answer did not negotiate requested codec=${settings.codec}; retrying selected profile", + ) + if ( + requestSelectedVideoProfileRetry( + message = "${settings.codec} was requested but WebRTC did not negotiate it; retrying the selected profile", + diagnosticReason = "codec negotiation", + ) + ) { + return@launch + } + failStream( + "${settings.codec} requested but not negotiated in local SDP", + generation, + ) + } + return@answerCreated + } + val answer = SessionDescription(SessionDescription.Type.ANSWER, munged) + answerPeer.setLocalDescription( + object : SimpleSdpObserver() { + override fun onSetSuccess() { + enqueueNativeLifecycleOperation("local-description-set") localDescription@ { + if (activePeerConnection(generation, pc) == null) { + return@localDescription + } + val nvst = SdpTools.buildNvstSdp( + offerSdp = preferred, + settings = settings, + localAnswer = munged, + ) + scope.launch { + if ( + !isCurrentPeerOperation( + generation, + transportGeneration, + pc, + peerConnection, + ) + ) { + return@launch + } + signaling?.sendAnswer(munged, nvst) + recordStreamDiagnostic("local description set and answer sent") + emitState("Streaming") + startHeartbeat() + startGamepadKeepalive() + startStatsPolling() + } + } + } + + override fun onSetFailure(error: String?) { + dispatchPeerFailure( + diagnostic = "local description failed error=${error.orEmpty()}", + message = error ?: "Failed to set local description", + generation = generation, + expected = pc, + ) + } + }, + answer, + ) + } + } + + override fun onCreateFailure(error: String?) { + dispatchPeerFailure( + diagnostic = "answer create failed error=${error.orEmpty()}", + message = error ?: "Failed to create WebRTC answer", + generation = generation, + expected = pc, + ) + } + }, + MediaConstraints(), + ) + } + } + + override fun onSetFailure(error: String?) { + dispatchPeerFailure( + diagnostic = "remote description failed error=${error.orEmpty()}", + message = error ?: "Failed to apply server offer", + generation = generation, + expected = pc, + ) + } + }, + SessionDescription(SessionDescription.Type.OFFER, preferred), + ) + } + + private fun prepareRemoteOffer(rawOffer: String, session: SessionInfo): String { + var prepared = SdpTools.fixServerEndpoint(rawOffer, session.serverIp, session.mediaConnectionInfo) + if (settings.codec == VideoCodec.H265) { + val maxLevels = h265ReceiverMaxLevelsByProfile() + if (maxLevels.isNotEmpty()) { + val rewritten = SdpTools.rewriteH265LevelIdByProfile(prepared, maxLevels) + if (rewritten.replacements > 0) { + NativeInputDiagnostics.add("h265 level-id clamped replacements=${rewritten.replacements} maxLevels=$maxLevels") + prepared = rewritten.sdp + } + } + if (!supportsH265TierFlagOne()) { + val rewritten = SdpTools.rewriteH265TierFlag(prepared, 0) + if (rewritten.replacements > 0) { + NativeInputDiagnostics.add("h265 tier-flag rewritten replacements=${rewritten.replacements}") + prepared = rewritten.sdp + } + } + } + return prepared + } + + private fun applyVideoCodecPreferences(transceivers: List) { + val preferences = receiverCodecPreferences(settings.codec) + if (preferences.isEmpty()) return + val transceiver = transceivers.firstOrNull { + it.mediaType == MediaStreamTrack.MediaType.MEDIA_TYPE_VIDEO || + it.receiver?.track()?.kind() == MediaStreamTrack.VIDEO_TRACK_KIND + } ?: return + val result = transceiver.setCodecPreferences(preferences) + if (result.isSuccess) { + NativeInputDiagnostics.add("codec preferences applied codec=${settings.codec} count=${preferences.size}") + } else { + NativeInputDiagnostics.add("codec preferences failed codec=${settings.codec} error=${result.error()?.message.orEmpty()}") + } + } + + @Synchronized + private fun attachMicrophoneTrack( + pc: PeerConnection, + transceivers: List, + ) { + val permissionGranted = ContextCompat.checkSelfPermission( + appContext, + Manifest.permission.RECORD_AUDIO, + ) == PackageManager.PERMISSION_GRANTED + if (!shouldCaptureMicrophone(settings.microphoneMode, permissionGranted)) { + recordStreamDiagnostic( + "microphone not attached mode=${settings.microphoneMode.name} permission=$permissionGranted", + ) + return + } + + releaseMicrophoneTrack() + val audioConstraints = MediaConstraints().apply { + optional.add(MediaConstraints.KeyValuePair("googEchoCancellation", "true")) + optional.add(MediaConstraints.KeyValuePair("googAutoGainControl", "true")) + optional.add(MediaConstraints.KeyValuePair("googNoiseSuppression", "true")) + optional.add(MediaConstraints.KeyValuePair("googHighpassFilter", "true")) + } + val source = requireNotNull(factory).createAudioSource(audioConstraints) + val track = requireNotNull(factory).createAudioTrack(MICROPHONE_TRACK_ID, source) + track.setEnabled(!microphoneMuted) + + val audioTransceivers = transceivers.filter { + it.mediaType == MediaStreamTrack.MediaType.MEDIA_TYPE_AUDIO || + it.receiver?.track()?.kind() == MediaStreamTrack.AUDIO_TRACK_KIND + } + val transceiver = audioTransceivers.firstOrNull { it.mid == GFN_MICROPHONE_MID } + ?: audioTransceivers.firstOrNull { + it.direction == RtpTransceiver.RtpTransceiverDirection.SEND_ONLY && + it.sender?.track() == null + } + ?: audioTransceivers.firstOrNull { it.sender?.track() == null } + + val sender = if (transceiver != null) { + when (transceiver.direction) { + RtpTransceiver.RtpTransceiverDirection.RECV_ONLY -> + transceiver.setDirection(RtpTransceiver.RtpTransceiverDirection.SEND_RECV) + RtpTransceiver.RtpTransceiverDirection.INACTIVE -> + transceiver.setDirection(RtpTransceiver.RtpTransceiverDirection.SEND_ONLY) + else -> Unit + } + transceiver.sender.apply { + setStreams(listOf(MICROPHONE_STREAM_ID)) + }.takeIf { sender -> + runCatching { sender.setTrack(track, false) } + .onFailure { error -> + if (error is IllegalStateException && isDisposedRtpSenderFailure(error)) { + recordStreamDiagnostic("microphone sender was disposed during setTrack attachment") + } else throw error + } + .getOrDefault(false) + } + } else { + pc.addTrack(track, listOf(MICROPHONE_STREAM_ID)) + } + + if (sender == null) { + track.dispose() + source.dispose() + recordStreamDiagnostic("microphone sender attachment failed") + return + } + microphoneSource = source + microphoneTrack = track + microphoneSender = sender + audioDeviceModule.setMicrophoneMute(microphoneMuted) + recordStreamDiagnostic( + "microphone track attached mid=${transceiver?.mid ?: "new"} direction=${transceiver?.direction?.name ?: "new"} muted=$microphoneMuted", + ) + } + + @Synchronized + private fun releaseMicrophoneTrack() { + disposeMicrophoneResources(takeMicrophoneResources()) + } + + @Synchronized + private fun takeMicrophoneResources(): MicrophoneResources { + val resources = MicrophoneResources( + sender = microphoneSender, + track = microphoneTrack, + source = microphoneSource, + ) + microphoneSender = null + microphoneTrack = null + microphoneSource = null + return resources + } + + private fun disposeMicrophoneResources(resources: MicrophoneResources) { + try { + resources.sender?.setTrack(null, false) + } catch (error: IllegalStateException) { + if (!isDisposedRtpSenderFailure(error)) throw error + recordStreamDiagnostic("microphone sender was already disposed during transport close") + } finally { + if (resources.track?.isDisposed == false) resources.track.dispose() + resources.source?.dispose() + } + } + + private fun receiverCodecPreferences(codec: VideoCodec): List { + val receiverCaps = runCatching { + requireNotNull(factory).getRtpReceiverCapabilities(MediaStreamTrack.MediaType.MEDIA_TYPE_VIDEO).codecs + }.getOrNull().orEmpty() + val target = codec.webRtcCodecName() + val preferred = receiverCaps + .filter { it.openNowCodecName() == target } + .let { caps -> if (codec == VideoCodec.H265) caps.sortedBy { it.h265ProfilePriority(settings.prefersTenBitVideo()) } else caps } + if (preferred.isEmpty()) return emptyList() + val auxiliary = receiverCaps.filter { it.openNowCodecName() in WEBRTC_AUXILIARY_VIDEO_CODECS } + return (preferred + auxiliary).distinctBy { it.preferenceKey() } + } + + private fun h265ReceiverMaxLevelsByProfile(): Map = + receiverH265Capabilities() + .mapNotNull { capability -> + val profile = capability.codecParameterInt("profile-id") ?: return@mapNotNull null + val level = capability.codecParameterInt("level-id") ?: return@mapNotNull null + profile to level + } + .groupBy({ it.first }, { it.second }) + .mapValues { (_, levels) -> levels.maxOrNull() ?: 0 } + .filterValues { it > 0 } + + private fun supportsH265TierFlagOne(): Boolean = + receiverH265Capabilities().any { it.codecParameterInt("tier-flag") == 1 } + + private fun receiverH265Capabilities(): List = + runCatching { + requireNotNull(factory).getRtpReceiverCapabilities(MediaStreamTrack.MediaType.MEDIA_TYPE_VIDEO).codecs + }.getOrNull().orEmpty() + .filter { it.openNowCodecName() == VideoCodec.H265.webRtcCodecName() } + + private fun startOfferTimeout(generation: Int) { + offerTimeoutJob?.cancel() + offerTimeoutJob = scope.launch { + delay(OFFER_TIMEOUT_MS) + if (generation != transportGeneration || peerConnection != null) return@launch + offerTimeoutJob = null + NativeInputDiagnostics.add("video offer timeout codec=${settings.codec} resolution=${settings.resolution} bitrate=${settings.maxBitrateMbps}") + if ( + requestSelectedVideoProfileRetry( + message = "Timed out waiting for video offer; retrying the selected profile", + diagnosticReason = "offer timeout", + ) + ) { + return@launch + } + restartTransport("Timed out waiting for video offer", videoFailure = true) + } + } + + private fun ensurePeerConnection(session: SessionInfo, generation: Int): PeerConnection { + peerConnection?.let { return it } + val ice = session.iceServers.map { + PeerConnection.IceServer.builder(it.urls).apply { + if (it.username != null) setUsername(it.username) + if (it.credential != null) setPassword(it.credential) + }.createIceServer() + } + val config = PeerConnection.RTCConfiguration(ice).apply { + sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN + continualGatheringPolicy = PeerConnection.ContinualGatheringPolicy.GATHER_CONTINUALLY + tcpCandidatePolicy = PeerConnection.TcpCandidatePolicy.ENABLED + bundlePolicy = PeerConnection.BundlePolicy.MAXBUNDLE + rtcpMuxPolicy = PeerConnection.RtcpMuxPolicy.REQUIRE + } + recordStreamDiagnostic( + "peer connection create generation=$generation iceServers=${ice.size} iceUrls=${session.iceServers.flatMap { it.urls }.joinToString(limit = 8) { url -> url.substringBefore('?').take(120) }}", + ) + val pc = requireNotNull(factory).createPeerConnection(config, object : PeerConnection.Observer { + override fun onSignalingChange(state: PeerConnection.SignalingState?) { + recordStreamDiagnostic("webrtc signaling state=${state?.name ?: "null"}") + } + override fun onIceConnectionChange(state: PeerConnection.IceConnectionState?) { + handleIceConnectionChange(state, generation) + } + override fun onIceConnectionReceivingChange(receiving: Boolean) { + recordStreamDiagnostic("ice receiving=$receiving generation=$generation") + } + override fun onIceGatheringChange(state: PeerConnection.IceGatheringState?) { + recordStreamDiagnostic("ice gathering state=${state?.name ?: "null"} generation=$generation") + } + override fun onIceCandidate(candidate: IceCandidate?) { + scope.launch { + if (generation != transportGeneration) return@launch + if (candidate != null) { + recordStreamDiagnostic("local ICE candidate gathered ${candidate.diagnosticSummary()}") + signaling?.sendIceCandidate(candidate) + } else { + recordStreamDiagnostic("local ICE candidate gathering complete") + } + } + } + override fun onIceCandidatesRemoved(candidates: Array?) { + recordStreamDiagnostic("ice candidates removed count=${candidates?.size ?: 0}") + } + override fun onAddStream(stream: MediaStream?) { + enqueueNativeLifecycleOperation("media-stream-added") { + if (activePeerConnection(generation) == null) return@enqueueNativeLifecycleOperation + recordStreamDiagnostic("media stream added video=${stream?.videoTracks?.size ?: 0} audio=${stream?.audioTracks?.size ?: 0}") + stream?.videoTracks?.firstOrNull()?.let(::attachVideo) + stream?.audioTracks?.firstOrNull()?.let { + audioTrack = it + it.setEnabled(!audioMuted) + } + } + } + override fun onRemoveStream(stream: MediaStream?) { + recordStreamDiagnostic("media stream removed video=${stream?.videoTracks?.size ?: 0} audio=${stream?.audioTracks?.size ?: 0}") + } + override fun onDataChannel(channel: DataChannel?) { + enqueueNativeLifecycleOperation("data-channel-added") { + if (activePeerConnection(generation) == null) return@enqueueNativeLifecycleOperation + if (channel != null) attachDataChannel(channel) + } + } + override fun onRenegotiationNeeded() { + recordStreamDiagnostic("renegotiation needed") + } + override fun onAddTrack(receiver: RtpReceiver?, streams: Array?) { + enqueueNativeLifecycleOperation("receiver-track-added") { + if (activePeerConnection(generation) == null) return@enqueueNativeLifecycleOperation + val track = receiver?.track() + recordStreamDiagnostic("track added kind=${track?.kind().orEmpty()} streams=${streams?.size ?: 0}") + if (track is VideoTrack) attachVideo(track) + if (track is AudioTrack) { + audioTrack = track + track.setEnabled(!audioMuted) + } + } + } + override fun onTrack(transceiver: RtpTransceiver?) { + enqueueNativeLifecycleOperation("transceiver-track-added") { + if (activePeerConnection(generation) == null) return@enqueueNativeLifecycleOperation + val track = transceiver?.receiver?.track() + recordStreamDiagnostic("transceiver track kind=${track?.kind().orEmpty()} media=${transceiver?.mediaType?.name ?: "unknown"}") + if (track is VideoTrack) attachVideo(track) + if (track is AudioTrack) { + audioTrack = track + track.setEnabled(!audioMuted) + } + } + } + }) ?: error("Failed to create PeerConnection") + peerConnection = pc + recordStreamDiagnostic("peer connection ready generation=$generation") + return pc + } + + private fun handleIceConnectionChange(state: PeerConnection.IceConnectionState?, generation: Int) { + scope.launch { + if (generation != transportGeneration) return@launch + val previous = lastIceState + lastIceState = state + recordStreamDiagnostic("ice connection ${previous?.name ?: "none"} -> ${state?.name ?: "null"} generation=$generation") + when (state) { + PeerConnection.IceConnectionState.CONNECTED, + PeerConnection.IceConnectionState.COMPLETED, + -> { + iceRecoveryJob?.cancel() + iceRecoveryJob = null + livenessWatchdog.markConnected(SystemClock.elapsedRealtime()) + if (reconnectAttempts > 0) { + signaling?.requestKeyframe( + reason = "transport_reconnect", + backlogFrames = 0, + attempt = reconnectAttempts, + ) + recordStreamDiagnostic("reconnect keyframe requested attempt=$reconnectAttempts generation=$generation") + } + emitState("Streaming") + } + PeerConnection.IceConnectionState.DISCONNECTED -> { + emitState("ICE_DISCONNECTED") + scheduleTransportReconnect("ICE disconnected", ICE_DISCONNECTED_GRACE_MS, generation) + } + PeerConnection.IceConnectionState.FAILED -> { + emitState("ICE_FAILED") + scheduleTransportReconnect("ICE failed", ICE_FAILED_RECONNECT_DELAY_MS, generation) + } + PeerConnection.IceConnectionState.CHECKING, + PeerConnection.IceConnectionState.NEW, + -> emitState(state.toIceStatusLabel()) + PeerConnection.IceConnectionState.CLOSED -> Unit + null -> Unit + } + } + } + + private fun scheduleTransportReconnect(reason: String, delayMs: Long, generation: Int) { + if (generation != transportGeneration || iceRecoveryJob?.isActive == true) { + recordStreamDiagnostic("reconnect not scheduled reason=$reason generation=$generation activeJob=${iceRecoveryJob?.isActive == true}") + return + } + recordStreamDiagnostic("reconnect scheduled reason=$reason delayMs=$delayMs generation=$generation") + iceRecoveryJob = scope.launch { + if (delayMs > 0) delay(delayMs) + if (generation != transportGeneration) return@launch + if (reason == "ICE disconnected" && lastIceState != PeerConnection.IceConnectionState.DISCONNECTED) return@launch + restartTransport(reason) + } + } + + private fun scheduleTransientSignalingRetry(message: String, generation: Int) { + if (generation != transportGeneration || iceRecoveryJob?.isActive == true) { + recordStreamDiagnostic( + "signaling service retry not scheduled generation=$generation activeJob=${iceRecoveryJob?.isActive == true}", + ) + return + } + transientSignalingFailures += 1 + val failureCount = transientSignalingFailures + val delayMs = transientSignalingRetryDelayMs(failureCount) + if (delayMs == null) { + recordStreamDiagnostic("signaling service retry limit reached failures=$failureCount") + requestSessionRecovery( + "The signaling service stayed unavailable after ${failureCount - 1} retries.", + ) + return + } + recordStreamDiagnostic( + "signaling service retry scheduled failure=$failureCount/$MAX_TRANSIENT_SIGNALING_RETRIES " + + "delayMs=$delayMs generation=$generation", + ) + iceRecoveryJob = scope.launch { + delay(delayMs) + if (generation != transportGeneration) return@launch + restartTransport( + reason = "Signaling service unavailable: $message", + consumeReconnectAttempt = false, + ) + } + } + + private fun restartTransport( + reason: String, + videoFailure: Boolean = false, + consumeReconnectAttempt: Boolean = true, + ) { + val currentSession = session ?: return + val currentSettings = settings + val hadStableMedia = transportHasStableMedia + if ( + transportRestartShouldRetrySelectedProfile( + videoFailure = videoFailure, + reconnectAttempts = reconnectAttempts, + transportHasStableMedia = transportHasStableMedia, + ) && + requestSelectedVideoProfileRetry( + message = "$reason. Retrying the selected local transport profile.", + diagnosticReason = "transport reconnect", + ) + ) { + return + } + if (consumeReconnectAttempt && reconnectAttempts >= MAX_TRANSPORT_RECONNECT_ATTEMPTS) { + recordStreamDiagnostic("reconnect limit reached reason=$reason attempts=$reconnectAttempts") + requestSessionRecovery("$reason. Stream reconnect failed after $MAX_TRANSPORT_RECONNECT_ATTEMPTS attempts.") + return + } + if (consumeReconnectAttempt) reconnectAttempts += 1 + transportGeneration += 1 + val generation = transportGeneration + recordStreamDiagnostic( + "transport restart reason=$reason attempt=$reconnectAttempts " + + "signalingFailures=$transientSignalingFailures generation=$generation", + ) + if (consumeReconnectAttempt) { + emitState("Reconnecting stream ($reconnectAttempts/$MAX_TRANSPORT_RECONNECT_ATTEMPTS)") + } else { + emitState("Reconnecting signaling ($transientSignalingFailures/$MAX_TRANSIENT_SIGNALING_RETRIES)") + } + closeTransport(clearInputState = false, cancelRecovery = false) + val codecSettleDelayMs = advancedCodecRestartSettleDelayMs( + codec = currentSettings.codec, + hadStableMedia = hadStableMedia, + ) + if (codecSettleDelayMs == 0L) { + iceRecoveryJob = null + startTransport(currentSession, currentSettings, generation) + return + } + recordStreamDiagnostic( + "waiting ${codecSettleDelayMs}ms for ${currentSettings.codec} decoder release before transport restart generation=$generation", + ) + iceRecoveryJob = scope.launch { + delay(codecSettleDelayMs) + if (generation != transportGeneration || session?.sessionId != currentSession.sessionId) return@launch + iceRecoveryJob = null + startTransport(currentSession, currentSettings, generation) + } + } + + private fun requestSessionRecovery(message: String) { + if (sessionRecoveryRequested) return + sessionRecoveryRequested = true + transportGeneration += 1 + closeTransport(clearInputState = false) + recordStreamDiagnostic("session recovery requested message=$message") + emitState("Recovering cloud session") + emitSessionRecoveryRequired(message) + } + + private fun failStream(message: String, generation: Int? = null) { + if (generation != null && generation != transportGeneration) return + transportGeneration += 1 + recordStreamDiagnostic("stream failed message=$message") + closeTransport(clearInputState = true) + emitError(message) + } + + private fun emitState(message: String) { + scope.launch { onState(message) } + } + + private fun emitError(message: String) { + scope.launch { onError(message) } + } + + private fun emitSessionRecoveryRequired(message: String) { + scope.launch { onSessionRecoveryRequired(message) } + } + + private fun PeerConnection.IceConnectionState.toIceStatusLabel(): String = "ICE_${name}" + + private fun emitStats(stats: StreamRuntimeStats) { + scope.launch { onStats(stats) } + } + + private fun ensureInputDataChannels(pc: PeerConnection, offerSdp: String) { + if (reliableInput == null) { + val reliableInit = DataChannel.Init().apply { + ordered = true + } + pc.createDataChannel("input_channel_v1", reliableInit)?.let(::attachDataChannel) + } + + if (partiallyReliableInput == null) { + val thresholdMs = SdpTools.parsePartialReliableThresholdMs(offerSdp) + val partialInit = DataChannel.Init().apply { + ordered = false + maxRetransmitTimeMs = thresholdMs + } + pc.createDataChannel("input_channel_partially_reliable", partialInit)?.let(::attachDataChannel) + } + if (statsChannel == null) { + val statsInit = DataChannel.Init().apply { + ordered = false + maxRetransmits = 0 + } + pc.createDataChannel("stats_channel", statsInit)?.let(::attachDataChannel) + } + } + + private fun attachVideo(track: VideoTrack) { + val currentTrack = videoTrack + if (currentTrack != null && currentTrack.id() == track.id() && currentTrack.state() != MediaStreamTrack.State.ENDED) { + currentTrack.setEnabled(true) + renderer?.let(::attachRendererSinkIfAvailable) + return + } + renderer?.let(::detachRendererSink) + videoTrack = track + track.setEnabled(true) + renderer?.let(::attachRendererSinkIfAvailable) + recordStreamDiagnostic( + "video track attached id=${track.id()} state=${track.state()?.name ?: "unknown"} " + + "renderer=${renderer != null} sink=${rendererSinkLifecycle.isAttachRequested()}", + ) + } + + private fun attachDataChannel(channel: DataChannel) { + val label = channel.label() + val normalizedLabel = label.lowercase(Locale.US) + val role = InputDataChannelLabels.classify(label) + val initialState = channel.state() + NativeInputDiagnostics.addRetained( + key = "channel.$normalizedLabel", + message = "data channel attached label=$normalizedLabel role=$role state=$initialState", + ) + if (normalizedLabel == "stats_channel") { + statsChannel = channel + channel.registerObserver(object : DataChannel.Observer { + override fun onBufferedAmountChange(previousAmount: Long) = Unit + override fun onStateChange() { + NativeInputDiagnostics.add("stats channel state label=$normalizedLabel state=${channel.state()}") + } + override fun onMessage(buffer: DataChannel.Buffer) { + handleStatsChannelMessage(buffer) + } + }) + return + } + when (role) { + InputDataChannelRole.Reliable -> { + reliableInput = channel + reliableInputState = initialState + } + InputDataChannelRole.PartiallyReliable -> { + partiallyReliableInput = channel + partiallyReliableInputState = initialState + } + InputDataChannelRole.Other -> return + } + channel.registerObserver(object : DataChannel.Observer { + override fun onBufferedAmountChange(previousAmount: Long) = Unit + override fun onStateChange() { + val state = channel.state() + when (role) { + InputDataChannelRole.Reliable -> if (reliableInput === channel) reliableInputState = state + InputDataChannelRole.PartiallyReliable -> if (partiallyReliableInput === channel) partiallyReliableInputState = state + InputDataChannelRole.Other -> Unit + } + NativeInputDiagnostics.addRetained( + key = "channel.$normalizedLabel", + message = "input channel state label=$normalizedLabel role=$role state=$state", + ) + if (state == DataChannel.State.OPEN) { + inputDropLogged = false + NativeInputDiagnostics.add("input channel open label=$normalizedLabel") + } + } + override fun onMessage(buffer: DataChannel.Buffer) { + handleInputChannelMessage(buffer) + } + }) + } + + private fun handleStatsChannelMessage(buffer: DataChannel.Buffer) { + val data = buffer.data.duplicate() + val size = data.remaining() + if (size <= 0) return + + val firstByte = data.get(data.position()).toInt() and 0xff + val statsBuffer = when (firstByte) { + 3 -> { + if (size < 2) return + data.position(data.position() + 1) + data.slice().order(ByteOrder.LITTLE_ENDIAN) + } + 4 -> { + data.order(ByteOrder.LITTLE_ENDIAN) + } + else -> return + } + + val statsSize = statsBuffer.remaining() + if (statsSize < 33) return + + val version = statsBuffer.get(0).toInt() and 0xff + if (version >= 4) { + val avgGameFps = statsBuffer.getDouble(25) + if (avgGameFps > 0.0 && avgGameFps <= 360.0) { + lastParsedGameFps = kotlin.math.round(avgGameFps).toInt() + } + } + } + + private fun handleInputChannelMessage(buffer: DataChannel.Buffer) { + val bytes = buffer.data.duplicate().let { data -> + ByteArray(data.remaining()).also(data::get) + } + if (bytes.isEmpty()) return + if (handleInputHandshakeMessage(ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN))) return + HapticsPacketParser.parse(bytes)?.let { command -> + applyGamepadRumble(command.controllerId, command.weakMagnitude, command.strongMagnitude) + } + } + + private fun handleInputHandshakeMessage(data: ByteBuffer): Boolean { + val size = data.remaining() + val firstWord = if (size >= 2) { + data.getShort(0).toInt() and 0xffff + } else { + data.get(0).toInt() and 0xff + } + val version = when { + firstWord == INPUT_HANDSHAKE_MAGIC_WORD -> { + if (size >= 4) { + data.getShort(2).toInt() and 0xffff + } else { + DEFAULT_INPUT_PROTOCOL_VERSION + } + } + (data.get(0).toInt() and 0xff) == INPUT_HANDSHAKE_MARKER -> firstWord + else -> return false + }.coerceAtLeast(1) + + inputEncoder.setProtocolVersion(version) + inputEncoder.resetGamepadSequences() + startInputSessionClock() + inputHandshakeReady = true + // Pre-handshake sends are intentionally blocked now. Re-arm the one-shot diagnostics so + // the first protocol-ready mouse packet records its v3 size and negotiated transport. + externalMouseMoveSentLogged = false + externalMouseCapturedMoveSentLogged = false + workerInputSendConfirmed.set(false) + directInputSendConfirmed.set(false) + NativeInputDiagnostics.addRetained( + key = "protocol", + message = "input handshake protocol=$version bytes=$size", + ) + updateHapticsAdvertisement(force = true) + schedulePrimeConnectedGamepadState(reason = "input handshake") + return true + } + + private fun startHeartbeat() { + heartbeatJob?.cancel() + NativeInputDiagnostics.retain( + "heartbeat.input.lifecycle", + "input heartbeat active intervalMs=1000 generation=$transportGeneration", + ) + heartbeatJob = scope.launch { + while (true) { + delay(1000) + if (!inputHandshakeReady) continue + val usePartialFallback = + reliableInputState != DataChannel.State.OPEN && + partiallyReliableInputState == DataChannel.State.OPEN + sendInput( + bytes = inputEncoder.encodeHeartbeat(), + partiallyReliable = usePartialFallback, + fallbackToReliable = !usePartialFallback, + resultDiagnosticKey = "heartbeat.input", + ) + } + } + } + + private fun startGamepadKeepalive() { + gamepadKeepaliveJob?.cancel() + gamepadKeepaliveJob = scope.launch { + var connectedScanCountdown = 0 + primeConnectedGamepadState(reason = "keepalive start") + while (true) { + delay(100L) + connectedScanCountdown -= 1 + if (connectedScanCountdown <= 0) { + connectedScanCountdown = 10 + refreshConnectedPhysicalControllers() + } + if ( + shouldSendGamepadKeepalive( + hasControllerState = hasAnyControllerState(), + hasActiveControllerInput = hasActiveControllerInput(), + touchMouseEnabled = touchMouseEnabled, + ) + ) { + sendCurrentGamepadState() + } + updateHapticsAdvertisement() + } + } + } + + private fun startStatsPolling() { + statsJob?.cancel() + statsJob = scope.launch { + while (true) { + pollRuntimeStats() + delay(1000L) + } + } + } + + private fun pollRuntimeStats() { + val generation = transportGeneration + enqueueNativeLifecycleOperation("runtime-stats") { + val pc = activePeerConnection(generation) ?: return@enqueueNativeLifecycleOperation + pc.getStats(RTCStatsCollectorCallback { report -> + if (generation != transportGeneration) return@RTCStatsCollectorCallback + val cpuSample = processCpuSampler.sample() + cpuSample?.let(ProcessCpuDiagnostics::record) + val snapshot = buildRuntimeStatsSnapshot( + timestampMs = report.timestampUs / 1000.0, + stats = report.statsMap.values, + cpuSample = cpuSample, + ) ?: return@RTCStatsCollectorCallback + scope.launch { + if (generation != transportGeneration) return@launch + handleMediaLiveness(snapshot) + onStats(snapshot.stats) + } + }) + } + } + + @Synchronized + private fun buildRuntimeStatsSnapshot( + timestampMs: Double, + stats: Collection, + cpuSample: ProcessCpuUsageSample?, + ): RuntimeStatsSnapshot? { + if (!isNewerStreamStatsSample(timestampMs, lastStatsSample?.atMs)) { + recordStreamDiagnostic("stale runtime stats ignored timestampMs=$timestampMs previousMs=${lastStatsSample?.atMs}") + return null + } + val inboundVideo = stats.firstOrNull { stat -> + val members = stat.members + stat.type == "inbound-rtp" && + (members["kind"] == "video" || members["mediaType"] == "video") + } + val activePair = stats.firstOrNull { stat -> + val members = stat.members + stat.type == "candidate-pair" && + members["state"] == "succeeded" && + members["nominated"] == true + } + val codecId = inboundVideo?.members?.get("codecId") as? String + val codec = codecId + ?.let { id -> stats.firstOrNull { stat -> stat.id == id } } + ?.members + ?.get("mimeType") + ?.let(::formatStatsCodec) + + val members = inboundVideo?.members.orEmpty() + val bytesReceived = members["bytesReceived"].statsLong() + val framesReceived = members["framesReceived"].statsLong() + val framesDecoded = members["framesDecoded"].statsLong() + val explicitFps = members["framesPerSecond"].statsDouble() + val width = members["frameWidth"].statsLong() + val height = members["frameHeight"].statsLong() + val totalDecodeTime = members["totalDecodeTime"].statsDouble() ?: 0.0 + val jitterMs = members["jitter"].statsDouble()?.let { (it * 1000.0).coerceAtLeast(0.0) } + val packetsLost = members["packetsLost"].statsLong() ?: 0L + val packetsReceived = members["packetsReceived"].statsLong() ?: 0L + + val previous = lastStatsSample?.takeIf { it.inboundRtpId == inboundVideo?.id } + if (lastStatsSample != null && previous == null) { + packetLossWindow.reset() + } + val elapsedSeconds = previous?.let { (timestampMs - it.atMs) / 1000.0 }?.takeIf { it > 0.0 } + val bitrateKbps = if (previous != null && bytesReceived != null && elapsedSeconds != null) { + (((bytesReceived - previous.bytesReceived).coerceAtLeast(0) * 8.0) / elapsedSeconds / 1000.0) + .roundToInt() + .coerceAtLeast(0) + } else { + null + } + val derivedFps = if (previous != null && framesDecoded != null && elapsedSeconds != null) { + ((framesDecoded - previous.framesDecoded).coerceAtLeast(0) / elapsedSeconds).roundToInt() + } else { + null + } + val receivedFps = if (previous != null && framesReceived != null && elapsedSeconds != null) { + ((framesReceived - previous.framesReceived).coerceAtLeast(0) / elapsedSeconds).roundToInt() + } else { + null + } + + val decodeMs = if (previous != null && framesDecoded != null && framesDecoded > previous.framesDecoded) { + val deltaDecodeTime = totalDecodeTime - previous.totalDecodeTime + val deltaFrames = framesDecoded - previous.framesDecoded + if (deltaFrames > 0) { + (deltaDecodeTime / deltaFrames * 1000.0).coerceIn(0.1, 50.0) + } else { + null + } + } else { + null + } + + val packetDelta = previous?.let { + streamPacketDelta( + currentLost = packetsLost, + currentReceived = packetsReceived, + previousLost = it.packetsLost, + previousReceived = it.packetsReceived, + ) + } + if (previous != null && packetDelta == null) { + packetLossWindow.reset() + } + val packetLossPct = packetDelta?.let(packetLossWindow::add) + val packetsLostDelta = packetDelta?.lost + val packetsReceivedDelta = packetDelta?.received + + if (inboundVideo != null && (bytesReceived != null || framesDecoded != null)) { + lastStatsSample = StreamStatsSample( + inboundRtpId = inboundVideo.id, + atMs = timestampMs, + bytesReceived = bytesReceived ?: previous?.bytesReceived ?: 0L, + framesReceived = framesReceived ?: previous?.framesReceived ?: 0L, + framesDecoded = framesDecoded ?: previous?.framesDecoded ?: 0L, + totalDecodeTime = totalDecodeTime, + packetsLost = packetsLost, + packetsReceived = packetsReceived, + ) + } + + val pingMs = activePair?.members?.get("currentRoundTripTime") + .statsDouble() + ?.let { (it * 1000.0).roundToInt().coerceAtLeast(0) } + val availableIncomingBitrateKbps = activePair?.members?.get("availableIncomingBitrate") + .statsDouble() + ?.takeIf { it >= 0.0 } + ?.let { (it / 1000.0).roundToInt().coerceAtLeast(0) } + val resolution = if (width != null && height != null && width > 0 && height > 0) { + "${width}x$height" + } else { + null + } + + return RuntimeStatsSnapshot( + stats = StreamRuntimeStats( + bitrateKbps = bitrateKbps, + availableIncomingBitrateKbps = availableIncomingBitrateKbps, + pingMs = pingMs, + fps = explicitFps?.roundToInt()?.takeIf { it > 0 } ?: derivedFps?.takeIf { it > 0 }, + gameFps = lastParsedGameFps, + receivedFps = receivedFps?.takeIf { it > 0 }, + decodedFps = derivedFps?.takeIf { it > 0 }, + resolution = resolution, + codec = codec, + decodeMs = decodeMs, + jitterMs = jitterMs, + packetLossPct = packetLossPct, + packetsLostDelta = packetsLostDelta, + packetsReceivedDelta = packetsReceivedDelta, + processCpuPercent = cpuSample?.processCpuPercent, + deviceCpuCapacityPercent = cpuSample?.deviceCpuCapacityPercent, + cpuLogicalCoreCount = cpuSample?.logicalCoreCount, + ), + bytesReceived = bytesReceived, + framesDecoded = framesDecoded, + ) + } + + private fun handleMediaLiveness(snapshot: RuntimeStatsSnapshot) { + val connected = lastIceState == PeerConnection.IceConnectionState.CONNECTED || + lastIceState == PeerConnection.IceConnectionState.COMPLETED + val action = livenessWatchdog.observe( + SystemClock.elapsedRealtime(), + snapshot.bytesReceived, + snapshot.framesDecoded, + connected, + ) + updateTransportRecoveryProgress(livenessWatchdog.latestObservationProgressed) + val requestPostLossKeyframe = packetLossRecoveryGate.observe( + stats = snapshot.stats, + recoveryEligible = connected && + transportHasStableMedia && + iceRecoveryJob?.isActive != true && + !sessionRecoveryRequested, + ) + if (requestPostLossKeyframe && action == StreamLivenessAction.None) { + signaling?.requestKeyframe( + reason = "packet_loss_recovered", + backlogFrames = 0, + attempt = 1, + ) + NativeInputDiagnostics.add( + "post-loss keyframe requested rawLost=${snapshot.stats.packetsLostDelta} " + + "rawReceived=${snapshot.stats.packetsReceivedDelta} displayedLoss=${snapshot.stats.packetLossPct}", + ) + } + val decoderOverloaded = decoderRecoveryGate.observe( + stats = snapshot.stats, + requestedFps = settings.fps, + advancedCodecActive = settings.codec != VideoCodec.H264, + recoveryEligible = connected && + transportHasStableMedia && + rendererSinkLifecycle.isAttachRequested() && + iceRecoveryJob?.isActive != true && + !sessionRecoveryRequested, + ) + if ( + decoderOverloaded && + requestSelectedVideoProfileRetry( + message = "The decoder could not sustain ${settings.fps} FPS; retrying the selected profile without changing it", + diagnosticReason = "sustained decoder overload receivedFps=${snapshot.stats.receivedFps} " + + "decodedFps=${snapshot.stats.decodedFps} decodeMs=${snapshot.stats.decodeMs}", + ) + ) { + return + } + if ( + rendererSinkLifecycle.isAttachRequested() && + firstVideoFrameWatchdog.shouldRecover(SystemClock.elapsedRealtime(), snapshot.bytesReceived, connected) + ) { + when (firstFrameRecoveryStep(transportHasStableMedia, reconnectAttempts, selectedProfileRetryApplied)) { + FirstFrameRecoveryStep.RetryRequestedProfile -> { + NativeInputDiagnostics.add( + "first frame timeout requested profile retry codec=${settings.codec} resolution=${settings.resolution}", + ) + restartTransport("First video frame timed out", videoFailure = true) + return + } + FirstFrameRecoveryStep.RetrySelectedProfile -> { + if ( + requestSelectedVideoProfileRetry( + message = "Video packets arrived but no frame rendered; retrying the selected profile", + diagnosticReason = "first frame timeout", + ) + ) { + return + } + } + FirstFrameRecoveryStep.ContinueBoundedTransportRecovery -> Unit + } + } + when (action) { + StreamLivenessAction.None -> Unit + is StreamLivenessAction.RequestKeyframe -> { + signaling?.requestKeyframe( + reason = "media_stall", + backlogFrames = 0, + attempt = action.attempt, + ) + emitState("Recovering video") + NativeInputDiagnostics.add("media stall keyframe requested stalledMs=${action.stalledMs} attempt=${action.attempt}") + } + is StreamLivenessAction.RestartTransport -> { + if (transportHasStableMedia) { + stableMediaStallRestarts += 1 + NativeInputDiagnostics.add( + "stable media stall count=$stableMediaStallRestarts codec=${settings.codec} androidTv=$androidTvProfile", + ) + } + if ( + repeatedStableMediaStallShouldRetrySelectedProfile( + androidTvProfile = androidTvProfile, + transportCodec = settings.codec, + completedStableMediaStallRestarts = stableMediaStallRestarts, + selectedProfileRetryApplied = selectedProfileRetryApplied, + ) && + requestSelectedVideoProfileRetry( + message = "Decoder repeatedly stalled after stable playback; retrying the selected profile", + diagnosticReason = "repeated stable media stall", + ) + ) { + return + } + if ( + !transportHasStableMedia && + requestSelectedVideoProfileRetry( + message = "Decoder stalled; retrying the selected profile", + diagnosticReason = "media stall", + ) + ) { + return + } + NativeInputDiagnostics.add("media stall transport restart stalledMs=${action.stalledMs}") + restartTransport("Media stalled for ${action.stalledMs / 1000}s", videoFailure = true) + } + } + } + + private fun updateTransportRecoveryProgress(progressed: Boolean) { + if (!progressed) { + consecutiveTransportProgressSamples = 0 + return + } + firstVideoFrameWatchdog.markRendered() + consecutiveTransportProgressSamples += 1 + if (consecutiveTransportProgressSamples < STABLE_TRANSPORT_PROGRESS_SAMPLES) return + + if (!transportHasStableMedia && reconnectAttempts > 0) { + recordStreamDiagnostic( + "transport media stable; reconnect budget reset attempts=$reconnectAttempts generation=$transportGeneration", + ) + } + transportHasStableMedia = true + reconnectAttempts = 0 + } + + private fun requestSelectedVideoProfileRetry( + message: String, + diagnosticReason: String, + ): Boolean { + val currentSession = session ?: return false + val selectedSettings = settings + val hadStableMedia = transportHasStableMedia + if (selectedProfileRetryApplied) return false + selectedProfileRetryApplied = true + reconnectAttempts = (reconnectAttempts + 1).coerceAtMost(MAX_TRANSPORT_RECONNECT_ATTEMPTS) + NativeInputDiagnostics.add( + "$diagnosticReason selected profile retry codec=${selectedSettings.codec} " + + "resolution=${selectedSettings.resolution} fps=${selectedSettings.fps} bitrate=${selectedSettings.maxBitrateMbps}", + ) + transportGeneration += 1 + val generation = transportGeneration + closeTransport(clearInputState = false) + firstVideoFrameWatchdog.reset() + recordStreamDiagnostic( + "selected profile transport retry generation=$generation " + + "session=${streamDiagnosticId(currentSession.sessionId)} settings=${selectedSettings.resolution}/" + + "${selectedSettings.fps}/${selectedSettings.codec}/${selectedSettings.maxBitrateMbps} reason=$diagnosticReason", + ) + emitState(message) + val settleDelayMs = advancedCodecRestartSettleDelayMs(selectedSettings.codec, hadStableMedia) + if (settleDelayMs == 0L) { + startTransport(currentSession, selectedSettings, generation) + } else { + recordStreamDiagnostic( + "waiting ${settleDelayMs}ms for ${selectedSettings.codec} decoder release before selected profile retry generation=$generation", + ) + iceRecoveryJob = scope.launch { + delay(settleDelayMs) + if (generation != transportGeneration || session?.sessionId != currentSession.sessionId) return@launch + iceRecoveryJob = null + startTransport(currentSession, selectedSettings, generation) + } + } + return true + } + + private fun formatStatsCodec(value: Any?): String? { + val raw = value?.toString()?.substringAfter("/", value.toString())?.trim()?.uppercase(Locale.US) ?: return null + return when (raw) { + "AVC", "H264", "H.264" -> "H264" + "HEVC", "H265", "H.265" -> "H265" + "AV01", "AV1" -> "AV1" + else -> raw.takeIf { it.isNotBlank() } + } + } + + private fun dispatchJoystick(event: MotionEvent): Boolean { + val controllerId = controllerIdFor(event) + activeControllerId = controllerId + if (!physicalControllerActive) { + NativeInputDiagnostics.addRetained( + key = "controller.device.$controllerId", + message = "physical gamepad motion source=${event.source} device=${event.deviceId}:${event.device?.name.orEmpty()} slot=$controllerId", + ) + } + physicalControllerConnected = true + physicalControllerActive = true + val raw = event.rawGamepadAxes() + val axes = AndroidGamepadAxisMapping.resolve(raw, event.axisAvailability()) + if (!physicalGamepadAxisLogged) { + physicalGamepadAxisLogged = true + NativeInputDiagnostics.addRetained( + key = "controller.axes.$controllerId", + message = "physical gamepad axes left=${axes.leftSource} right=${axes.rightSource} hatAsLeft=${axes.hatUsedAsLeftStick} " + + "x=${raw.x.formatAxis()} y=${raw.y.formatAxis()} z=${raw.z.formatAxis()} rz=${raw.rz.formatAxis()} " + + "rx=${raw.rx.formatAxis()} ry=${raw.ry.formatAxis()} hatX=${raw.hatX.formatAxis()} hatY=${raw.hatY.formatAxis()}", + ) + } + val hasAnalogL = event.device?.getMotionRange(MotionEvent.AXIS_LTRIGGER) != null || + event.device?.getMotionRange(MotionEvent.AXIS_BRAKE) != null + val lt = if (hasAnalogL) { + max(event.getAxisValue(MotionEvent.AXIS_LTRIGGER), normalizeTriggerAxis(event.getAxisValue(MotionEvent.AXIS_BRAKE))) + } else { + if (physicalLeftTriggerButtonPressed) 1f else 0f + } + + val hasAnalogR = event.device?.getMotionRange(MotionEvent.AXIS_RTRIGGER) != null || + event.device?.getMotionRange(MotionEvent.AXIS_GAS) != null + val rt = if (hasAnalogR) { + max(event.getAxisValue(MotionEvent.AXIS_RTRIGGER), normalizeTriggerAxis(event.getAxisValue(MotionEvent.AXIS_GAS))) + } else { + if (physicalRightTriggerButtonPressed) 1f else 0f + } + val leftScale = radialDeadzoneScale(axes.leftX, axes.leftY) + val rightScale = radialDeadzoneScale(axes.rightX, axes.rightY) + val leftX = axes.leftX * leftScale + val leftY = axes.leftY * leftScale + val rightX = axes.rightX * rightScale + val rightY = axes.rightY * rightScale + physicalHatButtons = if (axes.hatUsedAsLeftStick) 0 else event.hatDpadButtons() + lastLeftTrigger = normalizeToUint8(lt) + lastRightTrigger = normalizeToUint8(rt) + // When left-stick mouse emulation is active, keep both sticks' deflection = 0 so the game + // receives no stick deflection. The actual motions are forwarded as mouse delta and mouse scroll instead. + if (controllerMouseEmulationActive) { + lastLeftStickX = 0 + lastLeftStickY = 0 + lastRightStickX = 0 + lastRightStickY = 0 + } else { + lastLeftStickX = normalizeToInt16(leftX) + lastLeftStickY = normalizeToInt16(-leftY) + lastRightStickX = normalizeToInt16(rightX) + lastRightStickY = normalizeToInt16(-rightY) + } + physicalLeftStickX = leftX + physicalLeftStickY = leftY + physicalRightStickX = rightX + physicalRightStickY = rightY + val sent = sendBurstLimitedGamepadState(controllerId = controllerId) + if ( + abs(leftX) > ANALOG_ACTIVITY_THRESHOLD || + abs(leftY) > ANALOG_ACTIVITY_THRESHOLD || + abs(rightX) > ANALOG_ACTIVITY_THRESHOLD || + abs(rightY) > ANALOG_ACTIVITY_THRESHOLD || + lt > ANALOG_ACTIVITY_THRESHOLD || + rt > ANALOG_ACTIVITY_THRESHOLD + ) { + NativeInputDiagnostics.retainThrottled( + key = "controller.last-analog.$controllerId", + minimumIntervalMs = ANALOG_DIAGNOSTIC_INTERVAL_MS, + ) { + "physical gamepad analog device=${event.deviceId}:${event.device?.name.orEmpty()} slot=$controllerId " + + "left=${leftX.formatAxis()},${leftY.formatAxis()} right=${rightX.formatAxis()},${rightY.formatAxis()} " + + "triggers=${lt.formatAxis()},${rt.formatAxis()} sources=${axes.leftSource}/${axes.rightSource} " + + "sent=$sent ${inputChannelStateSummary()}" + } + } + return sent + } + + private fun dispatchGamepadKey(event: KeyEvent): Boolean { + if (event.action != KeyEvent.ACTION_DOWN && event.action != KeyEvent.ACTION_UP) return false + val pressed = event.action == KeyEvent.ACTION_DOWN + val controllerInputDevice = event.isControllerInputDevice() + val mask = GamepadButtonMapping.maskForKeyCode( + event.keyCode, + controllerActivation = controllerInputDevice, + ) + if (mask != null) { + activeControllerId = controllerIdFor(event) + if (!physicalControllerActive) { + NativeInputDiagnostics.add("physical gamepad key source=${event.source} device=${event.deviceId} slot=$activeControllerId key=${event.keyCode}") + } + physicalControllerConnected = true + physicalControllerActive = true + if (handleControllerMouseEmulationButton(mask, pressed)) { + return true + } + if (handleControllerMouseButton(mask, pressed)) { + return true + } + physicalButtons = if (pressed) physicalButtons or mask else physicalButtons and mask.inv() + val steamOverlayChordActivated = physicalSteamOverlayChord.update(physicalButtons) + val sent = sendCurrentGamepadState(controllerId = activeControllerId) + updateGuideAutoRelease(mask, pressed, activeControllerId) + if (steamOverlayChordActivated) { + schedulePhysicalSteamOverlayChordRelease(activeControllerId) + } + return sent + } + when (event.keyCode) { + KeyEvent.KEYCODE_BUTTON_L2 -> { + activeControllerId = controllerIdFor(event) + if (!physicalControllerActive) { + NativeInputDiagnostics.add("physical gamepad key source=${event.source} device=${event.deviceId} slot=$activeControllerId key=${event.keyCode}") + } + physicalControllerConnected = true + physicalControllerActive = true + physicalLeftTriggerButtonPressed = pressed + val hasAnalogTrigger = event.device?.getMotionRange(MotionEvent.AXIS_LTRIGGER) != null || + event.device?.getMotionRange(MotionEvent.AXIS_BRAKE) != null + if (!hasAnalogTrigger) { + lastLeftTrigger = if (pressed) 255 else 0 + } + val mouseSent = handleControllerMouseTrigger(left = true, pressed = pressed) + if (mouseSent) { + return true + } + return if (!hasAnalogTrigger) { + sendCurrentGamepadState(controllerId = activeControllerId) + } else { + true + } + } + KeyEvent.KEYCODE_BUTTON_R2 -> { + activeControllerId = controllerIdFor(event) + if (!physicalControllerActive) { + NativeInputDiagnostics.add("physical gamepad key source=${event.source} device=${event.deviceId} slot=$activeControllerId key=${event.keyCode}") + } + physicalControllerConnected = true + physicalControllerActive = true + physicalRightTriggerButtonPressed = pressed + val hasAnalogTrigger = event.device?.getMotionRange(MotionEvent.AXIS_RTRIGGER) != null || + event.device?.getMotionRange(MotionEvent.AXIS_GAS) != null + if (!hasAnalogTrigger) { + lastRightTrigger = if (pressed) 255 else 0 + } + val mouseSent = handleControllerMouseTrigger(left = false, pressed = pressed) + if (mouseSent) { + return true + } + return if (!hasAnalogTrigger) { + sendCurrentGamepadState(controllerId = activeControllerId) + } else { + true + } + } + } + return false + } + + private fun sendControllerMouseMove(stickX: Float, stickY: Float): Boolean { + if (!controllerMouseAssistActive && !controllerMouseEmulationActive) return false + val delta = AndroidControllerMouseAssist.mouseDelta(stickX, stickY) ?: return false + val sent = sendTouchMouseMove(delta.dx, delta.dy) + if (sent && !controllerMouseMoveLogged) { + controllerMouseMoveLogged = true + NativeInputDiagnostics.add("controller mouse move sent dx=${delta.dx} dy=${delta.dy} auto=$controllerMouseAssistAutoArmed emulation=$controllerMouseEmulationActive") + } + return sent + } + + private fun sendControllerMouseScroll(stickY: Float) { + if (!controllerMouseEmulationActive) return + val (notches, nextAccumulator) = AndroidControllerMouseAssist.scrollNotches( + stickY = stickY, + scrollSensitivity = settings.mouseScrollSensitivity, + accumulator = controllerScrollAccumulator + ) + controllerScrollAccumulator = nextAccumulator + if (notches != 0) { + sendTouchMouseWheel(notches * 120) + } + } + + private fun handleControllerMouseButton(buttonMask: Int, pressed: Boolean): Boolean { + if (!controllerMouseAssistActive && !controllerMouseEmulationActive) return false + val mouseButton = AndroidControllerMouseAssist.mouseButtonForGamepad(buttonMask) ?: return false + setControllerMouseButton(mouseButton, pressed) + return true + } + + /** When emulation mode is on, intercept Gamepad A as a left mouse click (button 1). */ + private fun handleControllerMouseEmulationButton(buttonMask: Int, pressed: Boolean): Boolean { + if (!controllerMouseEmulationActive) return false + if (buttonMask != GamepadButtonMapping.A) return false + setControllerMouseButton(1, pressed) + return true + } + + private fun handleControllerMouseTrigger(left: Boolean, pressed: Boolean): Boolean { + if (!controllerMouseAssistActive) return false + val mouseButton = AndroidControllerMouseAssist.mouseButtonForTrigger(left) ?: return false + return setControllerMouseButton(mouseButton, pressed) + } + + private fun sendBurstLimitedGamepadState(controllerId: Int = activeControllerId): Boolean { + if (openInputChannel(partiallyReliable = false, fallbackToReliable = true) == null) return false + val immediateControllerId = synchronized(gamepadStateBurstLock) { + val immediate = gamepadStateBurstLimiter.offer( + controllerId = controllerId, + nowMs = SystemClock.elapsedRealtime(), + ) + if (immediate == null && gamepadStateBurstFlushJob?.isActive != true) { + scheduleGamepadStateBurstFlushLocked() + } + immediate + } + return immediateControllerId?.let(::sendCurrentGamepadState) ?: true + } + + /** Must be called with [gamepadStateBurstLock] held. */ + private fun scheduleGamepadStateBurstFlushLocked() { + gamepadStateBurstFlushJob = scope.launch { + while (true) { + val waitMs = synchronized(gamepadStateBurstLock) { + gamepadStateBurstLimiter.delayUntilFlushMs(SystemClock.elapsedRealtime()) + } + if (waitMs == null) { + synchronized(gamepadStateBurstLock) { gamepadStateBurstFlushJob = null } + return@launch + } + if (waitMs > 0L) delay(waitMs) + val controllerId = synchronized(gamepadStateBurstLock) { + gamepadStateBurstLimiter.flush(SystemClock.elapsedRealtime()).also { + gamepadStateBurstFlushJob = null + } + } + controllerId?.let(::sendCurrentGamepadState) + return@launch + } + } + } + + private fun sendCurrentGamepadState(controllerId: Int = activeControllerId): Boolean { + // A gamepad packet is a full-state snapshot. Keep snapshots ordered: an older packet that + // arrives late on the loss-tolerant channel can undo a newer button or stick state. + val partiallyReliable = false + val buttons = + physicalSteamOverlayChord.effectiveButtons(physicalButtons) or + physicalHatButtons or + virtualSteamOverlayChord.effectiveButtons(virtualButtons) or + steamMenuChordButtons + val leftTrigger = max(lastLeftTrigger, virtualLeftTrigger) + val rightTrigger = max(lastRightTrigger, virtualRightTrigger) + val leftStickX = effectiveLeftStickX() + val leftStickY = effectiveLeftStickY() + val rightStickX = effectiveRightStickX() + val rightStickY = effectiveRightStickY() + val bitmap = currentGamepadBitmap(controllerId) + val packet = inputEncoder.encodeGamepadState( + controllerId = controllerId, + buttons = buttons, + leftTrigger = leftTrigger, + rightTrigger = rightTrigger, + leftStickX = leftStickX, + leftStickY = leftStickY, + rightStickX = rightStickX, + rightStickY = rightStickY, + bitmap = bitmap, + partiallyReliable = partiallyReliable, + ) + val sent = sendInput(packet, partiallyReliable = partiallyReliable, fallbackToReliable = !partiallyReliable) + NativeInputDiagnostics.retainThrottled( + key = "controller.packet.$controllerId", + minimumIntervalMs = GAMEPAD_PACKET_DIAGNOSTIC_INTERVAL_MS, + ) { + "gamepad packet slot=$controllerId sent=$sent partialRequested=$partiallyReliable " + + "bitmap=$bitmap buttons=$buttons triggers=$leftTrigger,$rightTrigger " + + "left=$leftStickX,$leftStickY right=$rightStickX,$rightStickY " + + "physicalActive=$physicalControllerActive virtualVisible=$virtualControllerVisible " + + inputChannelStateSummary() + } + if (leftStickX != 0 || leftStickY != 0 || rightStickX != 0 || rightStickY != 0) { + NativeInputDiagnostics.retainThrottled( + key = "controller.last-stick.$controllerId", + minimumIntervalMs = ANALOG_DIAGNOSTIC_INTERVAL_MS, + ) { + "gamepad stick packet slot=$controllerId sent=$sent left=$leftStickX,$leftStickY right=$rightStickX,$rightStickY " + + "leftSource=${if (virtualLeftStickActive) "virtual" else "physical"} " + + "rightSource=${when { + virtualRightStickActive -> "virtual" + else -> "physical" + }} " + + inputChannelStateSummary() + } + } + return sent + } + + private fun updateGuideAutoRelease(mask: Int, pressed: Boolean, controllerId: Int) { + if (mask != GamepadButtonMapping.GUIDE) return + guideAutoReleaseJob?.cancel() + if (!pressed) { + guideAutoReleaseJob = null + return + } + guideAutoReleaseJob = scope.launch { + delay(GAMEPAD_GUIDE_AUTO_RELEASE_MS) + if ((physicalButtons and GamepadButtonMapping.GUIDE) == 0) return@launch + physicalButtons = physicalButtons and GamepadButtonMapping.GUIDE.inv() + sendCurrentGamepadState(controllerId = controllerId) + NativeInputDiagnostics.add("physical gamepad guide auto-release slot=$controllerId") + } + } + + private fun schedulePhysicalSteamOverlayChordRelease(controllerId: Int) { + physicalSteamOverlayChordReleaseJob?.cancel() + physicalSteamOverlayChordReleaseJob = scope.launch { + delay(GAMEPAD_GUIDE_AUTO_RELEASE_MS) + if (!physicalSteamOverlayChord.releaseChord()) return@launch + sendCurrentGamepadState(controllerId = controllerId) + NativeInputDiagnostics.add("physical View+Start sent Steam Menu Home+A chord slot=$controllerId") + } + } + + private fun scheduleVirtualSteamOverlayChordRelease() { + virtualSteamOverlayChordReleaseJob?.cancel() + virtualSteamOverlayChordReleaseJob = scope.launch { + delay(GAMEPAD_GUIDE_AUTO_RELEASE_MS) + if (!virtualSteamOverlayChord.releaseChord()) return@launch + sendCurrentGamepadState() + NativeInputDiagnostics.add("touch View+Start sent Steam Menu Home+A chord") + } + } + + private fun effectiveLeftStickX(): Int = if (virtualLeftStickActive) virtualLeftStickX else lastLeftStickX + private fun effectiveLeftStickY(): Int = if (virtualLeftStickActive) virtualLeftStickY else lastLeftStickY + private fun effectiveRightStickX(): Int = + when { + virtualRightStickActive -> virtualRightStickX + controllerMouseAssistActive -> 0 + else -> lastRightStickX + } + + private fun effectiveRightStickY(): Int = + when { + virtualRightStickActive -> virtualRightStickY + controllerMouseAssistActive -> 0 + else -> lastRightStickY + } + + private fun hasAnyControllerState(): Boolean = + physicalControllerConnected || + physicalControllerActive || + virtualControllerVisible || + physicalButtons != 0 || + physicalHatButtons != 0 || + virtualButtons != 0 || + steamMenuChordButtons != 0 || + lastLeftTrigger != 0 || + lastRightTrigger != 0 || + virtualLeftTrigger != 0 || + virtualRightTrigger != 0 || + lastLeftStickX != 0 || + lastLeftStickY != 0 || + lastRightStickX != 0 || + lastRightStickY != 0 || + virtualLeftStickActive || + virtualRightStickActive + + private fun hasActiveControllerInput(): Boolean = + physicalButtons != 0 || + physicalHatButtons != 0 || + virtualButtons != 0 || + steamMenuChordButtons != 0 || + lastLeftTrigger != 0 || + lastRightTrigger != 0 || + virtualLeftTrigger != 0 || + virtualRightTrigger != 0 || + effectiveLeftStickX() != 0 || + effectiveLeftStickY() != 0 || + effectiveRightStickX() != 0 || + effectiveRightStickY() != 0 + + private fun sendInput(bytes: ByteArray, partiallyReliable: Boolean): Boolean = + sendInput(bytes, partiallyReliable, fallbackToReliable = true) + + private fun sendReliableInput(bytes: ByteArray): Boolean { + if (sendInput(bytes, partiallyReliable = false)) return true + val sentPartial = sendInput(bytes, partiallyReliable = true, fallbackToReliable = false) + if (sentPartial) { + NativeInputDiagnostics.add("reliable input used partial fallback ${inputChannelStateSummary()} bytes=${bytes.size}") + } + return sentPartial + } + + private fun sendInput( + bytes: ByteArray, + partiallyReliable: Boolean, + fallbackToReliable: Boolean, + resultDiagnosticKey: String? = null, + ): Boolean { + val queuedChannel = openInputChannel(partiallyReliable, fallbackToReliable) + if (queuedChannel == null) { + resultDiagnosticKey?.let { key -> + NativeInputDiagnostics.retainResult(key, succeeded = false) { + "path=queue reason=noOpenChannel requestedPartial=$partiallyReliable ${inputChannelStateSummary()}" + } + } + if (!inputDropLogged) { + inputDropLogged = true + NativeInputDiagnostics.addRetained( + key = "input.last-drop", + message = "input dropped noOpenChannel requestedPartial=$partiallyReliable ${inputChannelStateSummary()} bytes=${bytes.size}", + ) + } + return false + } + // WebRTC normally accepts sends from the dedicated input worker. A small set of Android + // WebRTC builds instead returns false there without throwing; the old code ignored that + // Boolean and continued reporting every packet as sent. Fall back to the caller only after + // an observed worker rejection, and only after the old worker queue has drained so packet + // ordering is preserved. The direct fallback deliberately uses the cached OPEN state above + // and calls only send(), avoiding the state()/bufferedAmount() JNI calls implicated in the + // original input-dispatch ANR. + if (synchronousInputFallback.get() && pendingInputSends.get() == 0) { + return sendInputSynchronously(queuedChannel, bytes, partiallyReliable, resultDiagnosticKey) + } + val pending = pendingInputSends.incrementAndGet() + if (pending > MAX_PENDING_INPUT_SENDS) { + pendingInputSends.decrementAndGet() + resultDiagnosticKey?.let { key -> + NativeInputDiagnostics.retainResult(key, succeeded = false) { + "path=queue reason=senderBackpressure requestedPartial=$partiallyReliable pending=$pending" + } + } + NativeInputDiagnostics.retainThrottled( + key = "input.last-drop", + minimumIntervalMs = INPUT_BACKPRESSURE_DIAGNOSTIC_INTERVAL_MS, + ) { + "input dropped senderQueue pending=$pending limit=$MAX_PENDING_INPUT_SENDS " + + "requestedPartial=$partiallyReliable bytes=${bytes.size}" + } + return false + } + // Do not call any DataChannel JNI accessor from Android's input-dispatch thread. + // state(), bufferedAmount(), and send() can all contend on WebRTC/native locks; keeping + // the complete sequence on the dedicated sender prevents a slow network/native lock from + // turning a touch event into an Input dispatching timed out ANR. + inputScope.launch { + try { + sendInputOnWorker(queuedChannel, bytes, partiallyReliable, resultDiagnosticKey) + } finally { + pendingInputSends.decrementAndGet() + } + } + return true + } + + private fun openInputChannel(partiallyReliable: Boolean, fallbackToReliable: Boolean): DataChannel? = + when { + partiallyReliable && partiallyReliableInputState == DataChannel.State.OPEN -> partiallyReliableInput + partiallyReliable && !fallbackToReliable -> null + reliableInputState == DataChannel.State.OPEN -> reliableInput + else -> null + } + + private fun hasOpenInputChannel(): Boolean = + reliableInputState == DataChannel.State.OPEN || + partiallyReliableInputState == DataChannel.State.OPEN + + private fun hasReadyInputChannel(): Boolean = inputHandshakeReady && hasOpenInputChannel() + + private fun inputChannelStateSummary(): String = + "reliable=${reliableInputState?.name ?: "none"} partial=${partiallyReliableInputState?.name ?: "none"}" + + private fun sendInputOnWorker( + channel: DataChannel, + bytes: ByteArray, + partiallyReliable: Boolean, + resultDiagnosticKey: String?, + ) { + runCatching { + if (channel.state() != DataChannel.State.OPEN) { + resultDiagnosticKey?.let { key -> + NativeInputDiagnostics.retainResult(key, succeeded = false) { + "path=worker reason=channelClosed requestedPartial=$partiallyReliable" + } + } + return@runCatching + } + val bufferedAmount = channel.bufferedAmount() + // Inputs explicitly routed to the loss-tolerant channel may be dropped early instead + // of letting that channel accumulate lag. Ordered relative mouse deltas and critical + // one-shot events use the reliable threshold and are dropped only when the channel is + // genuinely backed up. Key this on requested reliability so a critical event using the + // partial fallback stays critical. + val dropThreshold = if (partiallyReliable) { + INPUT_PARTIAL_BACKPRESSURE_DROP_THRESHOLD + } else { + INPUT_RELIABLE_BACKPRESSURE_DROP_THRESHOLD + } + if (bufferedAmount > dropThreshold) { + resultDiagnosticKey?.let { key -> + NativeInputDiagnostics.retainResult(key, succeeded = false) { + "path=worker reason=dataChannelBackpressure requestedPartial=$partiallyReliable bufferedAmount=$bufferedAmount" + } + } + NativeInputDiagnostics.retainThrottled( + key = "input.last-drop", + minimumIntervalMs = INPUT_BACKPRESSURE_DIAGNOSTIC_INTERVAL_MS, + ) { + "input dropped backpressure requestedPartial=$partiallyReliable label=${channel.label()} " + + "bufferedAmount=$bufferedAmount threshold=$dropThreshold bytes=${bytes.size}" + } + return@runCatching + } + restampProtocolV3OuterTimestamp(bytes) + val accepted = channel.send(DataChannel.Buffer(java.nio.ByteBuffer.wrap(bytes), true)) + resultDiagnosticKey?.let { key -> + NativeInputDiagnostics.retainResult(key, accepted) { + "path=worker requestedPartial=$partiallyReliable" + } + } + if (accepted) { + if (workerInputSendConfirmed.compareAndSet(false, true)) { + NativeInputDiagnostics.addRetained( + key = "input.send-path", + message = "input data channel accepted path=worker requestedPartial=$partiallyReliable " + + "bytes=${bytes.size}", + ) + } + } else { + synchronousInputFallback.set(true) + NativeInputDiagnostics.retainThrottled( + key = "input.last-send-error", + minimumIntervalMs = INPUT_BACKPRESSURE_DIAGNOSTIC_INTERVAL_MS, + ) { + "input send rejected path=worker requestedPartial=$partiallyReliable " + + "bytes=${bytes.size}; directFallback=true" + } + } + }.onFailure { error -> + resultDiagnosticKey?.let { key -> + NativeInputDiagnostics.retainResult(key, succeeded = false) { + "path=worker reason=${error.javaClass.simpleName} requestedPartial=$partiallyReliable" + } + } + synchronousInputFallback.set(true) + NativeInputDiagnostics.retainThrottled( + key = "input.last-send-error", + minimumIntervalMs = INPUT_BACKPRESSURE_DIAGNOSTIC_INTERVAL_MS, + ) { + "input send failed requestedPartial=$partiallyReliable bytes=${bytes.size} " + + "error=${error.javaClass.simpleName}" + } + } + } + + private fun sendInputSynchronously( + channel: DataChannel, + bytes: ByteArray, + partiallyReliable: Boolean, + resultDiagnosticKey: String? = null, + ): Boolean = runCatching { + restampProtocolV3OuterTimestamp(bytes) + channel.send(DataChannel.Buffer(java.nio.ByteBuffer.wrap(bytes), true)) + }.fold( + onSuccess = { accepted -> + resultDiagnosticKey?.let { key -> + NativeInputDiagnostics.retainResult(key, accepted) { + "path=direct-fallback requestedPartial=$partiallyReliable" + } + } + if (accepted) { + if (directInputSendConfirmed.compareAndSet(false, true)) { + NativeInputDiagnostics.addRetained( + key = "input.send-path", + message = "input data channel accepted path=direct-fallback requestedPartial=$partiallyReliable " + + "bytes=${bytes.size}", + ) + } + } else { + NativeInputDiagnostics.retainThrottled( + key = "input.last-send-error", + minimumIntervalMs = INPUT_BACKPRESSURE_DIAGNOSTIC_INTERVAL_MS, + ) { + "input send rejected path=direct-fallback requestedPartial=$partiallyReliable " + + "bytes=${bytes.size}" + } + } + accepted + }, + onFailure = { error -> + resultDiagnosticKey?.let { key -> + NativeInputDiagnostics.retainResult(key, succeeded = false) { + "path=direct-fallback reason=${error.javaClass.simpleName} requestedPartial=$partiallyReliable" + } + } + NativeInputDiagnostics.retainThrottled( + key = "input.last-send-error", + minimumIntervalMs = INPUT_BACKPRESSURE_DIAGNOSTIC_INTERVAL_MS, + ) { + "input send failed path=direct-fallback requestedPartial=$partiallyReliable " + + "bytes=${bytes.size} error=${error.javaClass.simpleName}" + } + false + }, + ) + + private fun clearPhysicalControllerInputState() { + physicalControllerActive = false + physicalButtons = 0 + physicalHatButtons = 0 + physicalSteamOverlayChord.reset() + physicalSteamOverlayChordReleaseJob?.cancel() + physicalSteamOverlayChordReleaseJob = null + physicalLeftTriggerButtonPressed = false + physicalRightTriggerButtonPressed = false + lastLeftTrigger = 0 + lastRightTrigger = 0 + lastLeftStickX = 0 + lastLeftStickY = 0 + lastRightStickX = 0 + lastRightStickY = 0 + physicalLeftStickX = 0f + physicalLeftStickY = 0f + physicalRightStickX = 0f + physicalRightStickY = 0f + } + + private fun refreshConnectedPhysicalControllers() { + controllerAxisAvailability.clear() + val connectedDevices = connectedControllerDevices() + val connectedDeviceIds = connectedDevices.mapTo(mutableSetOf()) { it.id } + val removedControllerSlots = AndroidControllerSlotRegistry.retainConnected( + controllerSlots = controllerSlots, + connectedDeviceIds = connectedDeviceIds, + ) + if (removedControllerSlots.isNotEmpty()) { + removedControllerSlots.values.forEach(controllerFamiliesBySlot::remove) + NativeInputDiagnostics.add( + "physical gamepad slots released=${removedControllerSlots.entries.joinToString { "${it.key}:${it.value}" }}", + ) + } + connectedDevices.forEach { device -> + controllerSlots[device.id]?.let { slot -> + AndroidControllerInput.controllerFamily(device)?.let { family -> + controllerFamiliesBySlot[slot] = family + } + } + } + val activeControllerDisconnected = activeControllerId in removedControllerSlots.values + val connected = connectedDevices.isNotEmpty() + val connectionChanged = connected != physicalControllerConnected + val connectionMessage = + "physical gamepad connected=$connected devices=${connectedDevices.joinToString { device -> + val family = AndroidControllerInput.controllerFamily(device) + "${device.id}:${device.name}:family=$family:vendor=0x${device.vendorId.toString(16)}:product=0x${device.productId.toString(16)}" + }}" + if (connectionChanged) { + NativeInputDiagnostics.addRetained("controller.connection", connectionMessage) + } else { + NativeInputDiagnostics.retain("controller.connection", connectionMessage) + } + physicalControllerConnected = connected + if (connected && !physicalControllerActive && controllerSlots.isEmpty()) { + activeControllerId = controllerIdFor(connectedDevices.first().id) + } + if (physicalControllerActive && (!connected || activeControllerDisconnected)) { + clearPhysicalControllerInputState() + if (connected) { + activeControllerId = controllerIdFor(connectedDevices.first().id) + } + sendCurrentGamepadState() + } + updateHapticsAdvertisement(force = connectionChanged) + } + + private fun schedulePrimeConnectedGamepadState(reason: String) { + scope.launch { + primeConnectedGamepadState(reason) + } + } + + private fun primeConnectedGamepadState(reason: String) { + refreshConnectedPhysicalControllers() + if (!hasAnyControllerState()) return + val sent = sendCurrentGamepadState() + NativeInputDiagnostics.addRetained( + key = "controller.prime", + message = "gamepad state prime reason=$reason sent=$sent connected=$physicalControllerConnected active=$physicalControllerActive slot=$activeControllerId ${inputChannelStateSummary()}", + ) + } + + private fun updateHapticsAdvertisement(force: Boolean = false) { + if (!inputHandshakeReady || reliableInputState != DataChannel.State.OPEN) return + val now = SystemClock.elapsedRealtime() + // Periodically re-advertise: the controller can connect (or start reporting a vibrator) + // after the session began, and once advertised with enabled=false the server keeps + // haptics disabled for the whole session unless we re-advertise enabled=true. + if (!force && hapticsAdvertised != null && now - lastHapticsAdvertisementAtMs < HAPTICS_ADVERTISEMENT_REFRESH_MS) return + val enabled = hapticsOutputAvailable() + if (hapticsAdvertised == enabled && now - lastHapticsAdvertisementAtMs < HAPTICS_ADVERTISEMENT_REFRESH_MS) return + if (sendReliableInput(inputEncoder.encodeHapticsEnabled(enabled))) { + hapticsAdvertised = enabled + lastHapticsAdvertisementAtMs = now + NativeInputDiagnostics.add("gamepad haptics advertised enabled=$enabled force=$force") + } + } + + private fun hapticsOutputAvailable(): Boolean = + selectHapticsOutputTarget( + vibrationEnabled = vibrationEnabled, + controllerRumbleAvailable = hapticControllerDevices().isNotEmpty(), + deviceHapticsAvailable = hasDeviceHaptics(), + preference = hapticsOutputPreference, + ) != HapticsOutputTarget.None + + private fun hapticControllerDevices(): List = + buildList { + InputDevice.getDeviceIds().forEach { deviceId -> + val device = InputDevice.getDevice(deviceId) ?: return@forEach + if (!AndroidControllerInput.isControllerDevice(device)) return@forEach + if (device.hasControllerRumble()) add(device) + } + } + + private fun findHapticControllerDevice(controllerId: Int): InputDevice? { + val devices = hapticControllerDevices() + if (devices.isEmpty()) return null + devices.firstOrNull { controllerSlots[it.id] == controllerId }?.let { return it } + if (controllerId in 0 until GAMEPAD_MAX_CONTROLLERS) { + devices.getOrNull(controllerId)?.let { return it } + } + return devices.singleOrNull() + } + + @Suppress("DEPRECATION") + private fun applyGamepadRumble(controllerId: Int, weakMagnitude16: Int, strongMagnitude16: Int) { + val slot = controllerId.coerceIn(0, GAMEPAD_MAX_CONTROLLERS - 1) + val profile = buildRumbleEffectProfile(weakMagnitude16, strongMagnitude16) + val isStop = profile.isStop + val now = SystemClock.elapsedRealtime() + if (!isStop && lastRumbleEffectAtMs[slot] != 0L && now - lastRumbleEffectAtMs[slot] <= RUMBLE_THROTTLE_MS) { + return + } + val device = findHapticControllerDevice(slot) + val outputTarget = selectHapticsOutputTarget( + vibrationEnabled = vibrationEnabled, + controllerRumbleAvailable = device != null, + deviceHapticsAvailable = hasDeviceHaptics(), + preference = hapticsOutputPreference, + ) + if (outputTarget == HapticsOutputTarget.None) { + logHapticsWarning( + "input haptics unavailable controller=$controllerId vibrationEnabled=$vibrationEnabled " + + "output=$hapticsOutputPreference controllerRumble=${device != null} device=${hasDeviceHaptics()}", + ) + return + } + lastRumbleEffectAtMs[slot] = if (isStop) 0L else now + + if (isStop) { + when (outputTarget) { + HapticsOutputTarget.Controller -> device?.let(::cancelControllerRumble) + HapticsOutputTarget.Device -> cancelDeviceHaptics() + HapticsOutputTarget.None -> Unit + } + return + } + if (device != null && !hapticsSupportLogged[slot]) { + hapticsSupportLogged[slot] = true + NativeInputDiagnostics.add("gamepad haptics available controller=$slot device=${device.id}:${device.name}") + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + when (outputTarget) { + HapticsOutputTarget.Controller -> device?.let { vibrateController(it, profile) } + HapticsOutputTarget.Device -> vibrateDeviceHaptics(profile) + HapticsOutputTarget.None -> Unit + } + } else { + @Suppress("DEPRECATION") + when (outputTarget) { + HapticsOutputTarget.Controller -> device?.vibrator?.vibrate(RUMBLE_EFFECT_MS) + HapticsOutputTarget.Device -> vibrateDeviceHapticsLegacy() + HapticsOutputTarget.None -> Unit + } + } + } + + private fun stopAllGamepadRumble() { + hapticControllerDevices().forEach { device -> + cancelControllerRumble(device) + } + cancelDeviceHaptics() + for (index in 0 until GAMEPAD_MAX_CONTROLLERS) { + lastRumbleEffectAtMs[index] = 0L + hapticsSupportLogged[index] = false + } + deviceHapticsSupportLogged = false + lastHapticsWarningAtMs = 0L + } + + private fun logHapticsWarning(message: String) { + val now = SystemClock.elapsedRealtime() + if (now - lastHapticsWarningAtMs < HAPTICS_LOG_INTERVAL_MS) return + lastHapticsWarningAtMs = now + NativeInputDiagnostics.add(message) + } + + private fun buildRumbleEffectProfile(weakMagnitude16: Int, strongMagnitude16: Int): RumbleEffectProfile { + val weak = weakMagnitude16.coerceIn(0, 65535) / 65535f + val strong = strongMagnitude16.coerceIn(0, 65535) / 65535f + val combined = (strong * 0.78f + weak * 0.48f).coerceIn(0f, 1f) + return RumbleEffectProfile( + weakAmplitude = rumbleAmplitude(weak, weight = 0.72f), + strongAmplitude = rumbleAmplitude(strong, weight = 1f), + combinedAmplitude = rumbleAmplitude(combined, weight = 1f), + ) + } + + private fun rumbleAmplitude(value: Float, weight: Float): Int { + val scaled = (value.coerceIn(0f, 1f) * weight.coerceIn(0f, 1f) * 255f).roundToInt() + return if (scaled <= 0) 0 else scaled.coerceIn(1, 255) + } + + @Suppress("DEPRECATION") + private fun InputDevice.hasControllerRumble(): Boolean { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val manager = vibratorManager + if (manager.vibratorIds.any { manager.getVibrator(it).hasVibrator() }) return true + } + return vibrator.hasVibrator() + } + + // Dispatched only from the SDK_INT >= O branch of the haptics entry point. + @RequiresApi(Build.VERSION_CODES.O) + private fun vibrateController(device: InputDevice, profile: RumbleEffectProfile) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val manager = device.vibratorManager + val vibrators = manager.vibratorIds + .map(manager::getVibrator) + .filter(Vibrator::hasVibrator) + if (vibrators.size >= 2) { + // Input-device VibratorManager implementations vary across OEM builds. Drive the + // controller motors through their individual Vibrator handles, which is also the + // Android game-controller API's documented path, instead of relying on a combined + // stereo effect that some devices accept without producing physical rumble. + updateControllerVibrator(vibrators[0], profile.strongAmplitude) + updateControllerVibrator(vibrators[1], profile.weakAmplitude) + return + } + if (vibrators.isNotEmpty()) { + updateControllerVibrator(vibrators[0], profile.combinedAmplitude) + return + } + } + @Suppress("DEPRECATION") + device.vibrator.vibrateAsMedia(createRumbleEffect(profile.combinedAmplitude)) + } + + @RequiresApi(Build.VERSION_CODES.O) + private fun updateControllerVibrator(vibrator: Vibrator, amplitude: Int) { + if (amplitude <= 0) { + vibrator.cancel() + } else { + vibrator.vibrateAsMedia(createRumbleEffect(amplitude)) + } + } + + /** + * Rumble is media output, not touch feedback. + * + * A bare `vibrate(effect)` is classified `USAGE_UNKNOWN`, which the platform folds into the + * system "Touch feedback" setting — off by default on several gaming handhelds, and the reason + * rumble could arrive from the host and produce nothing at all. Tagging it `USAGE_MEDIA` puts + * it where game rumble belongs and takes it out from under that switch. + */ + @RequiresApi(Build.VERSION_CODES.O) + private fun Vibrator.vibrateAsMedia(effect: VibrationEffect) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + vibrate(effect, mediaVibrationAttributes()) + } else { + vibrate(effect, gameAudioAttributes()) + } + } + + @Suppress("DEPRECATION") + private fun cancelControllerRumble(device: InputDevice) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val manager = device.vibratorManager + if (manager.vibratorIds.isNotEmpty()) { + manager.vibratorIds.forEach { vibratorId -> + manager.getVibrator(vibratorId).cancel() + } + return + } + } + device.vibrator.cancel() + } + + private fun hasDeviceHaptics(): Boolean = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + appContext.getSystemService(VibratorManager::class.java)?.let { manager -> + manager.vibratorIds.any { manager.getVibrator(it).hasVibrator() } + } == true + } else { + @Suppress("DEPRECATION") + (appContext.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator)?.hasVibrator() == true + } + + // Only reached from the SDK_INT >= O branch of the haptics dispatcher. + @RequiresApi(Build.VERSION_CODES.O) + private fun createRumbleEffect(amplitude: Int): VibrationEffect = + VibrationEffect.createOneShot(RUMBLE_EFFECT_MS, amplitude.coerceIn(1, 255)) + + @RequiresApi(Build.VERSION_CODES.O) + private fun vibrateDeviceHaptics(profile: RumbleEffectProfile) { + if (!deviceHapticsSupportLogged) { + deviceHapticsSupportLogged = true + NativeInputDiagnostics.add("gamepad haptics using device fallback") + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val manager = appContext.getSystemService(VibratorManager::class.java) + if (manager != null && manager.vibratorIds.any { manager.getVibrator(it).hasVibrator() }) { + // VibratorManager is API 31+, so VibrationAttributes is always available here. + manager.vibrate( + CombinedVibration.createParallel(createRumbleEffect(profile.combinedAmplitude)), + mediaVibrationAttributes(), + ) + return + } + } + @Suppress("DEPRECATION") + (appContext.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator) + ?.vibrateAsMedia(createRumbleEffect(profile.combinedAmplitude)) + } + + @Suppress("DEPRECATION") + private fun vibrateDeviceHapticsLegacy() { + if (!deviceHapticsSupportLogged) { + deviceHapticsSupportLogged = true + NativeInputDiagnostics.add("gamepad haptics using device fallback") + } + (appContext.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator)?.vibrate(RUMBLE_EFFECT_MS) + } + + private fun cancelDeviceHaptics() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + appContext.getSystemService(VibratorManager::class.java)?.cancel() + return + } + @Suppress("DEPRECATION") + (appContext.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator)?.cancel() + } + + private fun controllerIdFor(event: KeyEvent): Int = controllerIdFor(event.deviceId) + private fun controllerIdFor(event: MotionEvent): Int = controllerIdFor(event.deviceId) + + private fun controllerIdFor(deviceId: Int): Int { + val connectedDevices = connectedControllerDevices() + val connectedDeviceIds = connectedDevices.mapTo(mutableSetOf()) { it.id } + val assignment = AndroidControllerSlotRegistry.assign( + controllerSlots = controllerSlots, + deviceId = deviceId, + connectedDeviceIds = connectedDeviceIds, + maxControllers = GAMEPAD_MAX_CONTROLLERS, + ) + if (physicalControllerActive && activeControllerId in assignment.removedDevices.values) { + clearPhysicalControllerInputState() + } + if (assignment.removedDevices.isNotEmpty()) { + assignment.removedDevices.values.forEach(controllerFamiliesBySlot::remove) + NativeInputDiagnostics.add( + "physical gamepad slots reconciled removed=${assignment.removedDevices.entries.joinToString { "${it.key}:${it.value}" }} " + + "device=$deviceId slot=${assignment.slot}", + ) + } + connectedDevices + .firstOrNull { controllerSlots[it.id] == assignment.slot } + ?.let(AndroidControllerInput::controllerFamily) + ?.let { controllerFamiliesBySlot[assignment.slot] = it } + return assignment.slot + } + + private fun connectedControllerDevices(): List = + InputDevice.getDeviceIds() + .map(InputDevice::getDevice) + .filterNotNull() + .filter(AndroidControllerInput::isControllerDevice) + + private fun currentGamepadBitmap(controllerId: Int): Int { + val connected = physicalControllerConnected || + physicalControllerActive || + virtualControllerVisible || + virtualButtons != 0 || + virtualLeftTrigger != 0 || + virtualRightTrigger != 0 || + virtualLeftStickActive || + virtualRightStickActive + if (!connected) return 0 + val id = controllerId.coerceIn(0, 3) + val physicalFamily = if (physicalControllerConnected || physicalControllerActive) { + controllerFamiliesBySlot[id] + } else { + null + } + return androidGamepadConnectionBitmap( + controllerId = id, + connected = true, + physicalControllerFamily = physicalFamily, + playStationRumbleCompatibility = usesPlayStationRumbleCompatibility( + vibrationEnabled = vibrationEnabled, + preference = hapticsOutputPreference, + ), + ) + } + + private fun MotionEvent.isFromSource(source: Int): Boolean = (this.source and source) == source + private fun MotionEvent.isMouseLikePointer(): Boolean { + val controllerSource = isFromSource(InputDevice.SOURCE_JOYSTICK) || isFromSource(InputDevice.SOURCE_GAMEPAD) + return isFromSource(InputDevice.SOURCE_MOUSE) || + isFromSource(InputDevice.SOURCE_MOUSE_RELATIVE) || + (isFromSource(InputDevice.SOURCE_TOUCHPAD) && !controllerSource) + } + + private fun MotionEvent.isRelativeMousePointer(): Boolean = + isFromSource(InputDevice.SOURCE_MOUSE_RELATIVE) + + private fun MotionEvent.primaryMouseButton(): Int = + when { + actionButton != 0 -> actionButton.toGfnMouseButton() + buttonState != 0 -> buttonState.toGfnMouseButton() + else -> 1 + } + + private fun MotionEvent.hatDpadButtons(): Int { + var mask = 0 + val hatX = getAxisValue(MotionEvent.AXIS_HAT_X) + val hatY = getAxisValue(MotionEvent.AXIS_HAT_Y) + if (hatY <= -0.5f) mask = mask or GamepadButtonMapping.DPAD_UP + if (hatY >= 0.5f) mask = mask or GamepadButtonMapping.DPAD_DOWN + if (hatX <= -0.5f) mask = mask or GamepadButtonMapping.DPAD_LEFT + if (hatX >= 0.5f) mask = mask or GamepadButtonMapping.DPAD_RIGHT + return mask + } + + private fun MotionEvent.rawGamepadAxes(): AndroidGamepadRawAxes = + AndroidGamepadRawAxes( + x = getAxisValue(MotionEvent.AXIS_X), + y = getAxisValue(MotionEvent.AXIS_Y), + z = getAxisValue(MotionEvent.AXIS_Z), + rz = getAxisValue(MotionEvent.AXIS_RZ), + rx = getAxisValue(MotionEvent.AXIS_RX), + ry = getAxisValue(MotionEvent.AXIS_RY), + hatX = getAxisValue(MotionEvent.AXIS_HAT_X), + hatY = getAxisValue(MotionEvent.AXIS_HAT_Y), + ) + + private fun MotionEvent.axisAvailability(): AndroidGamepadAxisAvailability { + val cacheKey = deviceId + if (cacheKey >= 0) { + controllerAxisAvailability[cacheKey]?.let { return it } + } + return AndroidGamepadAxisAvailability( + x = hasMotionAxis(MotionEvent.AXIS_X), + y = hasMotionAxis(MotionEvent.AXIS_Y), + z = hasMotionAxis(MotionEvent.AXIS_Z), + rz = hasMotionAxis(MotionEvent.AXIS_RZ), + rx = hasMotionAxis(MotionEvent.AXIS_RX), + ry = hasMotionAxis(MotionEvent.AXIS_RY), + hatX = hasMotionAxis(MotionEvent.AXIS_HAT_X), + hatY = hasMotionAxis(MotionEvent.AXIS_HAT_Y), + ).also { availability -> + if (cacheKey >= 0) { + controllerAxisAvailability[cacheKey] = availability + } + } + } + + private fun MotionEvent.hasMotionAxis(axis: Int): Boolean { + val inputDevice = device ?: return false + return inputDevice.getMotionRange(axis, source) != null || + inputDevice.getMotionRange(axis) != null + } + + private fun KeyEvent.isGamepadEvent(): Boolean { + val controllerInputDevice = isControllerInputDevice() + return (controllerInputDevice && + (GamepadButtonMapping.maskForKeyCode(keyCode, controllerActivation = true) != null || + AndroidControllerInput.isPrimaryActivationKey(keyCode) || + keyCode == KeyEvent.KEYCODE_BUTTON_L2 || + keyCode == KeyEvent.KEYCODE_BUTTON_R2)) || + GamepadButtonMapping.isControllerButtonKeyCode(keyCode) + } + + private fun KeyEvent.isHardwareKeyboardSource(): Boolean = + !isControllerInputDevice() && + ((source and InputDevice.SOURCE_KEYBOARD) == InputDevice.SOURCE_KEYBOARD || + InputDevice.getDevice(deviceId)?.keyboardType == InputDevice.KEYBOARD_TYPE_ALPHABETIC) + + private fun MotionEvent.isGamepadMotionEvent(): Boolean = + isFromSource(InputDevice.SOURCE_JOYSTICK) || + isFromSource(InputDevice.SOURCE_GAMEPAD) || + (AndroidControllerInput.isControllerEvent(source, deviceId) && !isMouseLikePointer()) + + private fun KeyEvent.isControllerInputDevice(): Boolean = + AndroidControllerInput.isControllerEvent(source, deviceId) + + private fun Int.toGfnMouseButton(): Int = when { + this and MotionEvent.BUTTON_PRIMARY != 0 -> 1 + this and MotionEvent.BUTTON_TERTIARY != 0 -> 2 + this and MotionEvent.BUTTON_SECONDARY != 0 -> 3 + this and MotionEvent.BUTTON_BACK != 0 -> 4 + this and MotionEvent.BUTTON_FORWARD != 0 -> 5 + else -> 1 + } + + private companion object { + private const val ANDROID_TV_CODEC_RELEASE_SETTLE_MS = 180L + private const val LIVE_BITRATE_UPDATE_DEBOUNCE_MS = 350L + private const val EXTERNAL_MOUSE_ABSOLUTE_DELTA_LIMIT_PX = 240f + private const val GAMEPAD_MAX_CONTROLLERS = 4 + private const val RUMBLE_EFFECT_MS = 90L + private const val RUMBLE_THROTTLE_MS = 35L + private const val HAPTICS_LOG_INTERVAL_MS = 5000L + private const val HAPTICS_ADVERTISEMENT_REFRESH_MS = 5000L + private const val ANALOG_ACTIVITY_THRESHOLD = 0.01f + private const val ANALOG_DIAGNOSTIC_INTERVAL_MS = 250L + private const val GAMEPAD_PACKET_DIAGNOSTIC_INTERVAL_MS = 1_000L + private const val INPUT_BACKPRESSURE_DIAGNOSTIC_INTERVAL_MS = 1_000L + private const val MOUSE_MOVE_MIN_SEND_INTERVAL_MS = 8L + private const val GAMEPAD_STATE_MIN_SEND_INTERVAL_MS = 16L + private const val MAX_PENDING_INPUT_SENDS = 256 + // State inputs are superseded by newer packets, so they are dropped well before the queue + // can grow into lag; one-shot critical events keep the generous reliable threshold. + private const val INPUT_PARTIAL_BACKPRESSURE_DROP_THRESHOLD = 16_384L + private const val INPUT_RELIABLE_BACKPRESSURE_DROP_THRESHOLD = 65_536L + } + + private fun radialDeadzoneScale(x: Float, y: Float, deadzone: Float = 0.15f): Float { + val magnitude = kotlin.math.sqrt((x * x + y * y).toDouble()).toFloat() + if (magnitude < deadzone) return 0f + val scaled = ((magnitude - deadzone) / (1f - deadzone)).coerceIn(0f, 1f) + return scaled / magnitude + } + + private fun normalizeToInt16(value: Float): Int = (value.coerceIn(-1f, 1f) * 32767).roundToInt().coerceIn(-32768, 32767) + private fun normalizeToUint8(value: Float): Int = (value.coerceIn(0f, 1f) * 255).roundToInt().coerceIn(0, 255) + private fun normalizeTriggerAxis(value: Float): Float = if (value < 0f) ((value + 1f) / 2f).coerceIn(0f, 1f) else value.coerceIn(0f, 1f) + private fun Float.formatAxis(): String = String.format(Locale.US, "%.3f", this) +} + +open class SimpleSdpObserver : SdpObserver { + override fun onCreateSuccess(description: SessionDescription?) = Unit + override fun onSetSuccess() = Unit + override fun onCreateFailure(error: String?) = Unit + override fun onSetFailure(error: String?) = Unit +} diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/TouchControllerSkin.kt b/android/app/src/main/java/com/opencloudgaming/opennow/TouchControllerSkin.kt new file mode 100644 index 000000000..f3ddf6869 --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/TouchControllerSkin.kt @@ -0,0 +1,465 @@ +package com.opencloudgaming.opennow + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +/** + * Everything the on-screen controller needs to paint one control. + * + * The button composables used to each carry their own `if (style == V2)` ladder, which meant a new + * skin was a six-file edit and the skins could — and did — drift out of agreement with each other. + * They now read a single palette resolved once per frame. + */ +internal data class TouchSkinColors( + val fill: Color, + val pressedFill: Color, + val border: Color, + val pressedBorder: Color, + val borderWidth: Dp, + val pressedBorderWidth: Dp, + val glyph: Color, + val pressedGlyph: Color, + /** The ring a stick travels inside. */ + val stickTrack: Color, + val stickKnob: Color, + /** Null draws the knob as a plain disc. */ + val stickKnobBorder: Color?, + /** [Color.Transparent] leaves the d-pad cross unfilled and outline-only. */ + val dpadFill: Color, + /** + * The reader's opacity slider, kept alongside the palette so the shading a [TouchSkinForm] adds + * — gloss, glow, the rim on a domed cap — fades with everything else instead of surviving at + * full strength on a controller the reader asked to be nearly invisible. + */ + val opacity: Float = 1f, +) { + fun fillFor(pressed: Boolean): Color = if (pressed) pressedFill else fill + + fun borderFor(pressed: Boolean): Color = if (pressed) pressedBorder else border + + fun borderWidthFor(pressed: Boolean): Dp = if (pressed) pressedBorderWidth else borderWidth + + fun glyphFor(pressed: Boolean): Color = if (pressed) pressedGlyph else glyph + + /** White at [strength], faded by the reader's slider. Used for gloss and rim highlights. */ + fun sheen(strength: Float): Color = Color.White.copy(alpha = (opacity * strength).coerceIn(0f, 1f)) +} + +/** The outline of a face button, a thumb-stick click, or a start/select cap. */ +internal enum class TouchCapShape { + Circle, + + /** A rounded square; how round is [TouchSkinForm.capCornerPercent]. */ + Rounded, + + /** Flat-topped six-sided cap — the label still sits in the wide middle. */ + Hexagon, +} + +/** How the four directions of the d-pad are drawn. The hit test is angular in every case. */ +internal enum class TouchDpadShape { + /** One continuous plus, the arms joined at the hub. */ + Cross, + + /** Four separate keys with a gap between them; how round is [TouchSkinForm.dpadCornerPercent]. */ + Segmented, + + /** A single round pad with the directions grooved into it and pressed quadrants lit as pie slices. */ + Disc, + + /** Four triangular wedges radiating from a gap at the hub — the blade is its own arrowhead. */ + Blades, +} + +/** How a stick's track and its travelling cap are drawn. */ +internal enum class TouchStickShape { + /** A hairline ring and a plain disc. */ + Ring, + + /** A recessed bowl with a domed cap. */ + Dish, + + /** An open sight: dashed ring, axis ticks, and a cap you can see the game through. */ + Crosshair, + + /** A six-sided gate matching a hexagonal cap set. */ + Hex, + + /** A restrictor plate: an octagon the cap corners into, with a square-ish cap. */ + Gate, + + /** A ball top on a visible shaft. */ + Ball, +} + +/** How the shoulders and the thumb-click pills are cut. */ +internal enum class TouchShoulderShape { + Pill, + Slab, + + /** Points at both ends. */ + Wedge, +} + +/** The arrowheads a d-pad marks its directions with. [TouchDpadShape.Blades] needs none. */ +internal enum class TouchDpadArrow { + Triangle, + Chevron, + None, +} + +/** + * The silhouette half of a skin. + * + * A skin that only recolours the same shapes is not a skin — every style here changes what the + * controls actually *are*: the cut of a cap, whether the d-pad is one cross or four keys, what the + * stick travels inside. [touchSkinColors] then decides how that silhouette is painted. + */ +internal data class TouchSkinForm( + val capShape: TouchCapShape, + /** Corner radius of a [TouchCapShape.Rounded] cap, as a percentage of its half-extent. */ + val capCornerPercent: Int = 30, + /** A second ring set inside the cap edge — the plunger rim of an arcade button. */ + val capRim: Boolean = false, + val dpadShape: TouchDpadShape, + /** Corner radius of a [TouchDpadShape.Segmented] key; 100 makes the keys round. */ + val dpadCornerPercent: Int = 24, + val dpadArrow: TouchDpadArrow = TouchDpadArrow.Triangle, + val stickShape: TouchStickShape, + /** Multiplies the reader's own knob-size slider so a ball top can be a ball top. */ + val stickKnobScale: Float = 1f, + val shoulderShape: TouchShoulderShape, + /** Bloom drawn outside the edge. [Dp.Unspecified]-free: 0.dp draws none. */ + val glow: Dp = 0.dp, + /** Strength of the highlight sweep that makes a cap read as domed. 0f draws none. */ + val gloss: Float = 0f, + /** Caps shrink to this fraction under a finger. 1f keeps them still. */ + val pressScale: Float = 1f, + val glyphFamily: FontFamily = FontFamily.Default, + val glyphWeight: FontWeight = FontWeight.SemiBold, + val glyphLetterSpacing: TextUnit = 0.sp, + val glyphScale: Float = 1f, + val glyphUppercase: Boolean = false, +) { + /** What the styles are actually distinguished by — see `everySkinHasItsOwnSilhouette`. */ + val silhouette: List + get() = listOf( + capShape, + capCornerPercent, + capRim, + dpadShape, + dpadCornerPercent, + stickShape, + shoulderShape, + ) +} + +/** Used when [AndroidTouchSettings.touchSkinTint] is unset. */ +internal fun defaultTouchSkinAccent(style: TouchControllerStyle): Color = when (style) { + TouchControllerStyle.Neon -> Color(0xff42c9ff) + TouchControllerStyle.Retro -> Color(0xffa685ff) + TouchControllerStyle.Frost -> Color(0xffdff1ff) + TouchControllerStyle.Arcade -> Color(0xffff4d5e) + TouchControllerStyle.V1, TouchControllerStyle.V2, TouchControllerStyle.Contrast -> Color.White +} + +internal fun touchSkinAccent(settings: AndroidTouchSettings): Color = + settings.touchSkinTint + ?.let { Color(it.r.coerceIn(0, 255), it.g.coerceIn(0, 255), it.b.coerceIn(0, 255)) } + ?: defaultTouchSkinAccent(settings.touchControllerStyle) + +/** + * The shapes a style is built from. Independent of the reader's opacity and tint, which only ever + * touch [touchSkinColors] — the silhouette does not move when someone fades the controller out. + */ +internal fun touchSkinForm(style: TouchControllerStyle): TouchSkinForm = when (style) { + // The original, kept exactly as it was drawn: this is the one people already have muscle + // memory for, so it gains no dome, no glow and no press travel. + TouchControllerStyle.V1 -> TouchSkinForm( + capShape = TouchCapShape.Circle, + dpadShape = TouchDpadShape.Cross, + stickShape = TouchStickShape.Ring, + shoulderShape = TouchShoulderShape.Pill, + ) + // A heads-up display rather than a gamepad: nothing is filled, the d-pad is a grooved ring and + // the stick is a sight you aim through. + TouchControllerStyle.V2 -> TouchSkinForm( + capShape = TouchCapShape.Circle, + dpadShape = TouchDpadShape.Disc, + dpadArrow = TouchDpadArrow.Chevron, + stickShape = TouchStickShape.Crosshair, + stickKnobScale = 0.86f, + shoulderShape = TouchShoulderShape.Pill, + glyphWeight = FontWeight.Medium, + glyphLetterSpacing = 1.4.sp, + glyphScale = 0.94f, + ) + // Hexagons, blades and bloom. + TouchControllerStyle.Neon -> TouchSkinForm( + capShape = TouchCapShape.Hexagon, + dpadShape = TouchDpadShape.Blades, + dpadArrow = TouchDpadArrow.None, + stickShape = TouchStickShape.Hex, + shoulderShape = TouchShoulderShape.Wedge, + glow = 7.dp, + pressScale = 0.96f, + glyphWeight = FontWeight.Bold, + glyphLetterSpacing = 1.8.sp, + glyphUppercase = true, + ) + // Soft glass: squircle caps, a single round pad, a stick sunk into a bowl. + TouchControllerStyle.Frost -> TouchSkinForm( + capShape = TouchCapShape.Rounded, + capCornerPercent = 46, + dpadShape = TouchDpadShape.Disc, + stickShape = TouchStickShape.Dish, + shoulderShape = TouchShoulderShape.Slab, + gloss = 0.30f, + pressScale = 0.97f, + glyphWeight = FontWeight.Medium, + ) + // Chunky and unambiguous: every control is a separate block with a rim around it. + TouchControllerStyle.Contrast -> TouchSkinForm( + capShape = TouchCapShape.Circle, + capRim = true, + dpadShape = TouchDpadShape.Segmented, + dpadCornerPercent = 14, + stickShape = TouchStickShape.Dish, + shoulderShape = TouchShoulderShape.Slab, + glyphWeight = FontWeight.Black, + glyphScale = 1.08f, + ) + // A handheld from before analogue sticks: square keys, a restrictor gate, a monospaced legend. + TouchControllerStyle.Retro -> TouchSkinForm( + capShape = TouchCapShape.Rounded, + capCornerPercent = 26, + dpadShape = TouchDpadShape.Segmented, + dpadCornerPercent = 22, + stickShape = TouchStickShape.Gate, + shoulderShape = TouchShoulderShape.Slab, + gloss = 0.20f, + pressScale = 0.94f, + glyphFamily = FontFamily.Monospace, + glyphWeight = FontWeight.Bold, + glyphLetterSpacing = 0.8.sp, + glyphUppercase = true, + glyphScale = 0.92f, + ) + // A cabinet panel: domed convex buttons on chrome rims, round d-pad keys, a ball top on a shaft. + TouchControllerStyle.Arcade -> TouchSkinForm( + capShape = TouchCapShape.Circle, + capRim = true, + dpadShape = TouchDpadShape.Segmented, + dpadCornerPercent = 100, + stickShape = TouchStickShape.Ball, + stickKnobScale = 1.18f, + shoulderShape = TouchShoulderShape.Pill, + gloss = 0.42f, + pressScale = 0.90f, + glyphWeight = FontWeight.Bold, + ) +} + +/** + * [opacity] is the reader's own slider and multiplies every alpha here, so a skin's relative + * contrast survives being faded — a skin never hard-codes a final alpha. + */ +internal fun touchSkinColors( + style: TouchControllerStyle, + opacity: Float, + accent: Color, +): TouchSkinColors { + val alpha = opacity.coerceIn(0f, 1f) + fun white(a: Float) = Color.White.copy(alpha = alpha * a) + fun black(a: Float) = Color.Black.copy(alpha = alpha * a) + fun tint(a: Float) = accent.copy(alpha = alpha * a) + val palette = when (style) { + TouchControllerStyle.V1 -> TouchSkinColors( + fill = black(0.6f), + pressedFill = white(0.2f), + border = white(0.4f), + pressedBorder = white(0.4f), + borderWidth = 1.dp, + pressedBorderWidth = 1.dp, + glyph = white(0.9f), + pressedGlyph = white(1f), + stickTrack = white(0.3f), + stickKnob = Color.LightGray.copy(alpha = alpha * 0.8f), + stickKnobBorder = null, + dpadFill = black(0.6f), + ) + TouchControllerStyle.V2 -> TouchSkinColors( + fill = Color.Transparent, + pressedFill = white(0.15f), + border = white(0.5f), + pressedBorder = white(0.9f), + borderWidth = 1.dp, + pressedBorderWidth = 2.dp, + glyph = white(0.9f), + pressedGlyph = white(1f), + stickTrack = white(0.3f), + stickKnob = white(0.2f), + stickKnobBorder = white(0.5f), + dpadFill = Color.Transparent, + ) + TouchControllerStyle.Neon -> TouchSkinColors( + fill = black(0.28f), + pressedFill = tint(0.34f), + border = tint(0.85f), + pressedBorder = tint(1f), + borderWidth = 2.dp, + pressedBorderWidth = 3.dp, + glyph = tint(0.95f), + pressedGlyph = white(1f), + stickTrack = tint(0.5f), + stickKnob = tint(0.32f), + stickKnobBorder = tint(0.9f), + dpadFill = black(0.28f), + ) + TouchControllerStyle.Frost -> TouchSkinColors( + fill = white(0.14f), + pressedFill = white(0.34f), + border = white(0.3f), + pressedBorder = white(0.62f), + borderWidth = 1.dp, + pressedBorderWidth = 2.dp, + glyph = white(0.86f), + pressedGlyph = white(1f), + stickTrack = white(0.26f), + stickKnob = white(0.3f), + stickKnobBorder = white(0.44f), + dpadFill = white(0.14f), + ) + TouchControllerStyle.Contrast -> TouchSkinColors( + fill = black(0.9f), + pressedFill = white(0.9f), + border = white(0.96f), + pressedBorder = white(1f), + borderWidth = 2.dp, + pressedBorderWidth = 3.dp, + glyph = white(1f), + // The cap inverts under a finger, so the glyph has to invert with it. + pressedGlyph = black(1f), + stickTrack = white(0.8f), + stickKnob = white(0.9f), + stickKnobBorder = black(0.7f), + dpadFill = black(0.9f), + ) + TouchControllerStyle.Retro -> TouchSkinColors( + fill = tint(0.82f), + pressedFill = tint(1f), + border = black(0.55f), + pressedBorder = black(0.75f), + borderWidth = 2.dp, + pressedBorderWidth = 2.dp, + glyph = black(0.86f), + pressedGlyph = black(1f), + stickTrack = tint(0.6f), + stickKnob = tint(0.9f), + stickKnobBorder = black(0.6f), + dpadFill = tint(0.82f), + ) + TouchControllerStyle.Arcade -> TouchSkinColors( + fill = tint(0.88f), + pressedFill = tint(0.52f), + // The rim is chrome, not ink — it is what makes the cap read as a convex plunger. + border = white(0.82f), + pressedBorder = white(1f), + borderWidth = 2.dp, + pressedBorderWidth = 2.dp, + glyph = black(0.8f), + pressedGlyph = black(1f), + stickTrack = black(0.55f), + stickKnob = tint(0.95f), + stickKnobBorder = white(0.75f), + dpadFill = tint(0.88f), + ) + } + return palette.copy(opacity = alpha) +} + +internal val LocalTouchSkin = androidx.compose.runtime.staticCompositionLocalOf { + touchSkinColors(TouchControllerStyle.V1, opacity = 0.82f, accent = Color.White) +} + +internal val LocalTouchSkinForm = androidx.compose.runtime.staticCompositionLocalOf { + touchSkinForm(TouchControllerStyle.V1) +} + +/** Off blanks the caps; the d-pad arrowheads stay, since a blank cross is unusable. */ +internal val LocalTouchButtonLabels = androidx.compose.runtime.staticCompositionLocalOf { true } + +/** The movable cap stays independently adjustable without making every stick call site carry it. */ +internal val LocalTouchStickKnobScale = androidx.compose.runtime.staticCompositionLocalOf { 0.44f } + +@Composable +internal fun touchControllerStyleLabel(style: TouchControllerStyle): String = when (style) { + TouchControllerStyle.V1 -> "Classic" + TouchControllerStyle.V2 -> "Outline" + TouchControllerStyle.Neon -> "Neon" + TouchControllerStyle.Frost -> "Frost" + TouchControllerStyle.Contrast -> "High contrast" + TouchControllerStyle.Retro -> "Retro" + TouchControllerStyle.Arcade -> "Arcade" +} + +internal fun nextTouchControllerStyle(current: TouchControllerStyle): TouchControllerStyle { + val all = TouchControllerStyle.entries + return all[(all.indexOf(current) + 1) % all.size] +} + +internal data class TouchSkinTintOption( + val id: String, + val label: String, + /** Null means "whatever the chosen skin ships with" — see [defaultTouchSkinAccent]. */ + val rgb: ControllerThemeRgb?, +) + +internal const val TOUCH_SKIN_TINT_DEFAULT_ID = "default" + +/** + * A short preset list rather than a full colour wheel. + * + * These are picked in the stream overlay, one-handed, often mid-game and often on a controller, + * where a hue/saturation surface is the wrong instrument. + */ +internal val TOUCH_SKIN_TINTS: List = listOf( + TouchSkinTintOption(TOUCH_SKIN_TINT_DEFAULT_ID, "Skin default", null), + TouchSkinTintOption("white", "White", ControllerThemeRgb(255, 255, 255)), + TouchSkinTintOption("cyan", "Cyan", ControllerThemeRgb(66, 201, 255)), + TouchSkinTintOption("green", "Green", ControllerThemeRgb(124, 241, 177)), + TouchSkinTintOption("magenta", "Magenta", ControllerThemeRgb(255, 92, 190)), + TouchSkinTintOption("violet", "Violet", ControllerThemeRgb(166, 133, 255)), + TouchSkinTintOption("red", "Red", ControllerThemeRgb(255, 82, 82)), +) + +private val LegacyAmberTouchTint = ControllerThemeRgb(255, 176, 32) +private val LegacyOrangeTouchTint = ControllerThemeRgb(255, 106, 43) +private val ReplacementTouchTint = ControllerThemeRgb(255, 92, 190) + +/** Preserves old settings while ensuring removed warm presets cannot remain active. */ +internal fun ControllerThemeRgb?.withoutRemovedWarmTint(): ControllerThemeRgb? = when (this) { + LegacyAmberTouchTint, LegacyOrangeTouchTint -> ReplacementTouchTint + else -> this +} + +/** Falls back to the default entry for a colour saved by a build that offered a wider list. */ +internal fun touchSkinTintId(tint: ControllerThemeRgb?): String = + TOUCH_SKIN_TINTS.firstOrNull { it.rgb == tint }?.id ?: TOUCH_SKIN_TINT_DEFAULT_ID + +internal fun touchSkinTintForId(id: String): ControllerThemeRgb? = + TOUCH_SKIN_TINTS.firstOrNull { it.id == id }?.rgb + +internal fun nextTouchSkinTint(current: ControllerThemeRgb?): ControllerThemeRgb? { + val currentIndex = TOUCH_SKIN_TINTS.indexOfFirst { it.rgb == current }.coerceAtLeast(0) + return TOUCH_SKIN_TINTS[(currentIndex + 1) % TOUCH_SKIN_TINTS.size].rgb +} + +internal fun touchSkinTintLabel(current: ControllerThemeRgb?): String = + TOUCH_SKIN_TINTS.firstOrNull { it.rgb == current }?.label ?: TOUCH_SKIN_TINTS.first().label diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/TouchControllerSkinPaint.kt b/android/app/src/main/java/com/opencloudgaming/opennow/TouchControllerSkinPaint.kt new file mode 100644 index 000000000..a100ee1aa --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/TouchControllerSkinPaint.kt @@ -0,0 +1,785 @@ +package com.opencloudgaming.opennow + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.RoundRect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.PathOperation +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.clipPath +import androidx.compose.ui.graphics.drawscope.rotate +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.min +import kotlin.math.sin + +/** + * The painting half of a touch skin. + * + * These composables know how to draw a control and nothing else — no gamepad, no pointer input, no + * settings. That split is what lets the settings screen show a live preview of a skin without + * standing up a stream, and it keeps [OpenNowTouchControls] about input rather than about pixels. + */ + +/** A real blur needs API 31, so a skin's bloom is this many fading strokes instead. */ +private const val GLOW_PASSES = 3 + +/** A d-pad is this many times its own arm across; the arms sit one arm-and-a-bit from the hub. */ +private const val DPAD_BOX_PER_ARM = 3.1f + +internal fun touchDpadBoxSize(arm: Dp): Dp = arm * DPAD_BOX_PER_ARM + +// region shapes + +private fun Path.addPolygon( + center: Offset, + radiusX: Float, + radiusY: Float, + sides: Int, + startDegrees: Float, +) { + for (index in 0 until sides) { + val angle = ((startDegrees + 360f / sides * index) * PI / 180f).toFloat() + val x = center.x + radiusX * cos(angle) + val y = center.y + radiusY * sin(angle) + if (index == 0) moveTo(x, y) else lineTo(x, y) + } + close() +} + +/** [inset] pulls the outline in, so the same shape serves as fill, gloss clip, border and rim. */ +private fun capPath(bounds: Size, form: TouchSkinForm, inset: Float): Path { + val path = Path() + val width = bounds.width - inset * 2f + val height = bounds.height - inset * 2f + if (width <= 0f || height <= 0f) return path + val rect = Rect(inset, inset, inset + width, inset + height) + when (form.capShape) { + TouchCapShape.Circle -> path.addOval(rect) + TouchCapShape.Rounded -> { + val radius = min(width, height) / 2f * (form.capCornerPercent.coerceIn(0, 100) / 100f) + path.addRoundRect(RoundRect(rect, CornerRadius(radius, radius))) + } + // Flat-topped, so a two-letter legend still has the widest part of the cap to sit in. + TouchCapShape.Hexagon -> path.addPolygon(rect.center, width / 2f, height / 2f, sides = 6, startDegrees = 0f) + } + return path +} + +private fun shoulderPath(bounds: Size, form: TouchSkinForm, inset: Float): Path { + val path = Path() + val width = bounds.width - inset * 2f + val height = bounds.height - inset * 2f + if (width <= 0f || height <= 0f) return path + val rect = Rect(inset, inset, inset + width, inset + height) + when (form.shoulderShape) { + TouchShoulderShape.Pill -> { + val radius = height / 2f + path.addRoundRect(RoundRect(rect, CornerRadius(radius, radius))) + } + TouchShoulderShape.Slab -> { + val radius = min(width, height) * 0.24f + path.addRoundRect(RoundRect(rect, CornerRadius(radius, radius))) + } + TouchShoulderShape.Wedge -> { + val cut = min(width * 0.24f, height * 0.7f) + path.moveTo(rect.left + cut, rect.top) + path.lineTo(rect.right - cut, rect.top) + path.lineTo(rect.right, rect.center.y) + path.lineTo(rect.right - cut, rect.bottom) + path.lineTo(rect.left + cut, rect.bottom) + path.lineTo(rect.left, rect.center.y) + path.close() + } + } + return path +} + +// endregion + +// region painters + +/** + * Paints one closed control — a cap, a shoulder, a d-pad key. + * + * [pathFor] is handed an inset rather than a finished path because the bloom, the fill, the gloss + * clip and the rim are all the same outline at four different sizes. + */ +private fun DrawScope.drawTouchSurface( + colors: TouchSkinColors, + form: TouchSkinForm, + pressed: Boolean, + fill: Color = colors.fillFor(pressed), + rim: Boolean = form.capRim, + pathFor: (Float) -> Path, +) { + val border = colors.borderFor(pressed) + val borderWidth = colors.borderWidthFor(pressed).toPx() + val outline = pathFor(borderWidth / 2f) + + val glow = form.glow.toPx() + if (glow > 0f && border.alpha > 0f) { + repeat(GLOW_PASSES) { pass -> + drawPath( + path = outline, + color = border.copy(alpha = border.alpha * 0.22f / (pass + 1)), + style = Stroke(width = borderWidth + glow * 2f * (pass + 1) / GLOW_PASSES), + ) + } + } + + if (fill.alpha > 0f) drawPath(pathFor(0f), fill) + + if (form.gloss > 0f) { + // Light from above and a little shade underneath is the whole trick behind a domed cap. + clipPath(pathFor(borderWidth)) { + drawRect( + brush = Brush.verticalGradient( + 0f to colors.sheen(form.gloss), + 0.5f to Color.Transparent, + 1f to Color.Black.copy(alpha = (colors.opacity * form.gloss * 0.34f).coerceIn(0f, 1f)), + ), + ) + } + } + + if (borderWidth > 0f && border.alpha > 0f) { + drawPath(outline, border, style = Stroke(width = borderWidth)) + } + + if (rim) { + drawPath( + path = pathFor(borderWidth + size.minDimension * 0.10f), + color = colors.sheen(0.28f), + style = Stroke(width = (borderWidth * 0.7f).coerceAtLeast(1f)), + ) + } +} + +/** [rotationDegrees] is clockwise from "points up". */ +private fun DrawScope.drawDirectionMark( + center: Offset, + rotationDegrees: Float, + extent: Float, + color: Color, + style: TouchDpadArrow, +) { + if (style == TouchDpadArrow.None || color.alpha <= 0f || extent <= 0f) return + rotate(rotationDegrees, center) { + when (style) { + TouchDpadArrow.Triangle -> drawPath( + path = Path().apply { + moveTo(center.x, center.y - extent) + lineTo(center.x + extent * 0.88f, center.y + extent * 0.6f) + lineTo(center.x - extent * 0.88f, center.y + extent * 0.6f) + close() + }, + color = color, + ) + TouchDpadArrow.Chevron -> drawPath( + path = Path().apply { + moveTo(center.x - extent * 0.85f, center.y + extent * 0.45f) + lineTo(center.x, center.y - extent * 0.5f) + lineTo(center.x + extent * 0.85f, center.y + extent * 0.45f) + }, + color = color, + style = Stroke( + width = extent * 0.34f, + cap = StrokeCap.Round, + join = StrokeJoin.Round, + ), + ) + TouchDpadArrow.None -> Unit + } + } +} + +private fun DrawScope.drawTouchDpad( + colors: TouchSkinColors, + form: TouchSkinForm, + armPx: Float, + up: Boolean, + down: Boolean, + left: Boolean, + right: Boolean, +) { + val width = size.width + val height = size.height + val center = Offset(width / 2f, height / 2f) + val border = colors.border + val borderWidth = colors.borderWidth.toPx() + val armDistance = (min(width, height) - armPx) / 2f + val markExtent = armPx * 0.2f + // up, right, down, left — the order the rotations below step through. + val pressedFlags = listOf(up, right, down, left) + + when (form.dpadShape) { + TouchDpadShape.Cross -> { + val corner = CornerRadius(8.dp.toPx(), 8.dp.toPx()) + fun crossPath(inset: Float): Path { + val vertical = Path().apply { + addRoundRect( + RoundRect( + left = (width - armPx) / 2f + inset, + top = inset, + right = (width + armPx) / 2f - inset, + bottom = height - inset, + cornerRadius = corner, + ), + ) + } + val horizontal = Path().apply { + addRoundRect( + RoundRect( + left = inset, + top = (height - armPx) / 2f + inset, + right = width - inset, + bottom = (height + armPx) / 2f - inset, + cornerRadius = corner, + ), + ) + } + // Stroking two overlapping rectangles leaves their complete outlines visible at + // the hub, which looks like a sharp square sitting on top of the d-pad. Union them + // first so the classic skin has one continuous silhouette and one outer border. + return Path.combine(PathOperation.Union, vertical, horizontal) + } + + // Keep the stroke inside the canvas and reuse this one union for fill, clipping and + // outline. D-pad presses only invalidate paint; they should not rebuild extra paths. + val cross = crossPath(borderWidth / 2f) + if (colors.dpadFill.alpha > 0f) drawPath(cross, colors.dpadFill) + + val pressedPath = Path() + if (up) { + pressedPath.addRect( + Rect( + left = (width - armPx) / 2f, + top = 0f, + right = (width + armPx) / 2f, + bottom = center.y, + ), + ) + } + if (down) { + pressedPath.addRect( + Rect( + left = (width - armPx) / 2f, + top = center.y, + right = (width + armPx) / 2f, + bottom = height, + ), + ) + } + if (left) { + pressedPath.addRect( + Rect( + left = 0f, + top = (height - armPx) / 2f, + right = center.x, + bottom = (height + armPx) / 2f, + ), + ) + } + if (right) { + pressedPath.addRect( + Rect( + left = center.x, + top = (height - armPx) / 2f, + right = width, + bottom = (height + armPx) / 2f, + ), + ) + } + clipPath(cross) { + drawPath(pressedPath, colors.pressedFill) + } + drawPath(cross, border, style = Stroke(width = borderWidth)) + } + + TouchDpadShape.Segmented -> { + val keyRadius = armPx / 2f * (form.dpadCornerPercent.coerceIn(0, 100) / 100f) + pressedFlags.forEachIndexed { index, isPressed -> + rotate(90f * index, center) { + val key = { inset: Float -> + Path().apply { + val rect = Rect( + center = Offset(center.x, center.y - armDistance), + radius = armPx / 2f - inset, + ) + if (rect.width > 0f) { + addRoundRect(RoundRect(rect, CornerRadius(keyRadius, keyRadius))) + } + } + } + drawTouchSurface( + colors = colors, + form = form, + // Keep the four-key silhouette steady. Expanding one pressed outline (and + // its glow) into the center gap makes neighbouring directions collide. + pressed = false, + fill = if (isPressed) colors.pressedFill else colors.dpadFill, + rim = false, + pathFor = key, + ) + } + } + } + + TouchDpadShape.Disc -> { + val radius = min(width, height) / 2f - borderWidth / 2f + val pad = { inset: Float -> + Path().apply { + val r = radius - inset + borderWidth / 2f + if (r > 0f) addOval(Rect(center = center, radius = r)) + } + } + drawTouchSurface( + colors = colors, + form = form, + pressed = false, + fill = colors.dpadFill, + rim = false, + pathFor = pad, + ) + // A pressed direction lights its quadrant, which is exactly what the hit test measures. + // Held inside the outline: a slice drawn out to it would repaint half the stroke and + // leave a pressed disc with a heavier edge than every other control in the skin. + val sliceRadius = radius - borderWidth / 2f + pressedFlags.forEachIndexed { index, isPressed -> + if (!isPressed) return@forEachIndexed + drawArc( + color = colors.pressedFill, + startAngle = -135f + 90f * index, + sweepAngle = 90f, + useCenter = true, + topLeft = Offset(center.x - sliceRadius, center.y - sliceRadius), + size = Size(sliceRadius * 2f, sliceRadius * 2f), + ) + } + } + + TouchDpadShape.Blades -> { + val outer = min(width, height) / 2f - borderWidth / 2f + val inner = outer * 0.32f + pressedFlags.forEachIndexed { index, isPressed -> + rotate(90f * index, center) { + val blade = { inset: Float -> + Path().apply { + moveTo(center.x, center.y - outer + inset) + lineTo(center.x + outer * 0.44f - inset, center.y - inner) + lineTo(center.x - outer * 0.44f + inset, center.y - inner) + close() + } + } + drawTouchSurface( + colors = colors, + form = form, + // The fill and glyph carry directional feedback; a thicker pressed edge + // would visually run into the adjacent blades at the hub. + pressed = false, + fill = if (isPressed) colors.pressedFill else colors.dpadFill, + rim = false, + pathFor = blade, + ) + } + } + } + } + + if (form.dpadArrow != TouchDpadArrow.None) { + val markDistance = when (form.dpadShape) { + TouchDpadShape.Disc -> min(width, height) / 2f * 0.64f + else -> armDistance + } + pressedFlags.forEachIndexed { index, isPressed -> + drawDirectionMark( + center = Offset(center.x, center.y - markDistance), + rotationDegrees = 90f * index, + extent = markExtent, + color = colors.glyphFor(isPressed), + style = form.dpadArrow, + ) + } + } +} + +private fun DrawScope.drawTouchStick( + colors: TouchSkinColors, + form: TouchSkinForm, + knobScale: Float, + baseOffset: Offset, + knobOffset: Offset, +) { + val extent = min(size.width, size.height) + val center = Offset(size.width / 2f, size.height / 2f) + baseOffset + val hairline = 1.dp.toPx() + val radius = extent / 2f - hairline + val knobRadius = extent * knobScale / 2f + val knobCenter = center + knobOffset + val knobBorder = colors.stickKnobBorder + + fun drawKnobGloss() { + if (form.gloss <= 0f) return + drawCircle( + brush = Brush.radialGradient( + colors = listOf(colors.sheen(form.gloss + 0.14f), Color.Transparent), + center = knobCenter - Offset(knobRadius * 0.3f, knobRadius * 0.36f), + radius = knobRadius * 1.15f, + ), + radius = knobRadius, + center = knobCenter, + ) + } + + when (form.stickShape) { + TouchStickShape.Ring -> { + drawCircle(colors.stickTrack, radius, center, style = Stroke(width = hairline)) + drawCircle(colors.stickKnob, knobRadius, knobCenter) + knobBorder?.let { drawCircle(it, knobRadius, knobCenter, style = Stroke(width = hairline)) } + } + + TouchStickShape.Dish -> { + drawCircle(colors.fill, radius, center) + drawCircle( + color = colors.stickTrack.copy(alpha = colors.stickTrack.alpha * 0.5f), + radius = radius * 0.66f, + center = center, + style = Stroke(width = hairline), + ) + drawCircle(colors.stickTrack, radius, center, style = Stroke(width = hairline * 1.5f)) + drawCircle(colors.stickKnob, knobRadius, knobCenter) + drawKnobGloss() + knobBorder?.let { drawCircle(it, knobRadius, knobCenter, style = Stroke(width = hairline * 1.5f)) } + } + + TouchStickShape.Crosshair -> { + // Drawn as arcs rather than a dashed stroke: path effects are not reliable on a + // hardware-accelerated canvas, and this is on top of live video. + val segments = 12 + repeat(segments) { index -> + drawArc( + color = colors.stickTrack, + startAngle = 360f / segments * index, + sweepAngle = 360f / segments * 0.55f, + useCenter = false, + topLeft = Offset(center.x - radius, center.y - radius), + size = Size(radius * 2f, radius * 2f), + style = Stroke(width = hairline), + ) + } + repeat(4) { index -> + rotate(90f * index, center) { + drawLine( + color = colors.stickTrack, + start = Offset(center.x, center.y - radius), + end = Offset(center.x, center.y - radius + extent * 0.09f), + strokeWidth = hairline * 1.5f, + cap = StrokeCap.Round, + ) + } + } + drawCircle(colors.stickKnob, knobRadius, knobCenter) + drawCircle( + color = knobBorder ?: colors.stickTrack, + radius = knobRadius, + center = knobCenter, + style = Stroke(width = hairline * 1.5f), + ) + repeat(2) { index -> + rotate(90f * index, knobCenter) { + drawLine( + color = knobBorder ?: colors.stickTrack, + start = Offset(knobCenter.x - knobRadius * 0.55f, knobCenter.y), + end = Offset(knobCenter.x + knobRadius * 0.55f, knobCenter.y), + strokeWidth = hairline, + ) + } + } + } + + TouchStickShape.Hex -> { + drawPath( + path = Path().apply { addPolygon(center, radius, radius, sides = 6, startDegrees = 0f) }, + color = colors.stickTrack, + style = Stroke(width = hairline * 1.5f), + ) + val knob = Path().apply { addPolygon(knobCenter, knobRadius, knobRadius, sides = 6, startDegrees = 0f) } + drawPath(knob, colors.stickKnob) + knobBorder?.let { drawPath(knob, it, style = Stroke(width = hairline * 1.5f)) } + } + + TouchStickShape.Gate -> { + // A restrictor plate: the cap corners into the eight notches instead of sweeping freely. + drawPath( + path = Path().apply { addPolygon(center, radius, radius, sides = 8, startDegrees = 22.5f) }, + color = colors.stickTrack, + style = Stroke(width = hairline * 2f), + ) + drawPath( + path = Path().apply { + addPolygon(center, radius * 0.55f, radius * 0.55f, sides = 8, startDegrees = 22.5f) + }, + color = colors.stickTrack.copy(alpha = colors.stickTrack.alpha * 0.45f), + style = Stroke(width = hairline), + ) + val knobRect = Rect(center = knobCenter, radius = knobRadius) + val knob = Path().apply { + addRoundRect(RoundRect(knobRect, CornerRadius(knobRadius * 0.38f, knobRadius * 0.38f))) + } + drawPath(knob, colors.stickKnob) + drawKnobGloss() + knobBorder?.let { drawPath(knob, it, style = Stroke(width = hairline * 2f)) } + } + + TouchStickShape.Ball -> { + val trackWidth = hairline * 3f + drawCircle(colors.stickTrack, radius - trackWidth / 2f, center, style = Stroke(width = trackWidth)) + // The shaft is what sells a ball top; it is hidden under the ball at rest. + drawLine( + color = colors.stickTrack, + start = center, + end = knobCenter, + strokeWidth = knobRadius * 0.55f, + cap = StrokeCap.Round, + ) + drawCircle(colors.stickKnob, knobRadius, knobCenter) + drawKnobGloss() + knobBorder?.let { drawCircle(it, knobRadius, knobCenter, style = Stroke(width = hairline * 1.5f)) } + } + } +} + +// endregion + +// region composables + +/** Blank caps are a supported look; the d-pad arrowheads are not optional, a bare cross is unusable. */ +@Composable +private fun TouchButtonLabel(label: String, pressed: Boolean, sizeSp: Float) { + if (!LocalTouchButtonLabels.current) return + val colors = LocalTouchSkin.current + val form = LocalTouchSkinForm.current + Text( + text = if (form.glyphUppercase) label.uppercase() else label, + color = colors.glyphFor(pressed), + fontFamily = form.glyphFamily, + fontWeight = form.glyphWeight, + fontSize = (sizeSp * form.glyphScale).sp, + letterSpacing = form.glyphLetterSpacing, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) +} + +/** A face button, a thumb-stick click, or a start/select cap. Input belongs to the caller. */ +@Composable +internal fun TouchCapFace( + label: String, + pressed: Boolean, + diameter: Dp, + modifier: Modifier = Modifier, +) { + val colors = LocalTouchSkin.current + val form = LocalTouchSkinForm.current + val scale = if (pressed) form.pressScale else 1f + Box( + modifier + .size(diameter) + .graphicsLayer { + scaleX = scale + scaleY = scale + } + .drawBehind { + drawTouchSurface(colors, form, pressed) { inset -> capPath(size, form, inset) } + }, + contentAlignment = Alignment.Center, + ) { + // The legend tracks the cap so the size sliders move the whole control, not just its outline. + TouchButtonLabel(label, pressed, sizeSp = (diameter.value * 0.3f).coerceIn(7f, 20f)) + } +} + +/** A trigger, a bumper, or a thumb-click pill. Input belongs to the caller. */ +@Composable +internal fun TouchShoulderFace( + label: String, + pressed: Boolean, + width: Dp, + height: Dp, + modifier: Modifier = Modifier, +) { + val colors = LocalTouchSkin.current + val form = LocalTouchSkinForm.current + val scale = if (pressed) form.pressScale else 1f + Box( + modifier + .width(width) + .height(height) + .graphicsLayer { + scaleX = scale + scaleY = scale + } + .drawBehind { + drawTouchSurface(colors, form, pressed, rim = false) { inset -> + shoulderPath(size, form, inset) + } + }, + contentAlignment = Alignment.Center, + ) { + TouchButtonLabel(label, pressed, sizeSp = (height.value * 0.44f).coerceIn(7f, 18f)) + } +} + +/** The whole d-pad, sized from one arm. Direction sensing belongs to the caller. */ +@Composable +internal fun TouchDpadFace( + arm: Dp, + up: Boolean, + down: Boolean, + left: Boolean, + right: Boolean, + modifier: Modifier = Modifier, +) { + val colors = LocalTouchSkin.current + val form = LocalTouchSkinForm.current + Canvas(modifier.size(touchDpadBoxSize(arm))) { + drawTouchDpad(colors, form, arm.toPx(), up, down, left, right) + } +} + +/** + * A stick's track and its travelling cap. + * + * [base] and [knob] are read inside the draw pass on purpose: a moving stick then only repaints, + * never recomposes, which matters when it is riding on top of a live 60 fps video surface. + */ +@Composable +internal fun TouchStickFace( + diameter: Dp, + base: () -> Offset, + knob: () -> Offset, + modifier: Modifier = Modifier, +) { + val colors = LocalTouchSkin.current + val form = LocalTouchSkinForm.current + val knobScale = (LocalTouchStickKnobScale.current * form.stickKnobScale).coerceIn(0.28f, 0.86f) + Canvas(modifier.size(diameter)) { + drawTouchStick(colors, form, knobScale, base(), knob()) + } +} + +/** + * A still life of a skin for the settings screen. + * + * Opacity is floored well below the slider's own range but above invisible: this is a picker, and a + * skin faded out to a hint is one nobody can choose between. + */ +@Composable +internal fun TouchControllerSkinPreview( + style: TouchControllerStyle, + tint: ControllerThemeRgb?, + opacity: Float, + showLabels: Boolean, + modifier: Modifier = Modifier, +) { + val accent = remember(style, tint) { + tint?.let { Color(it.r.coerceIn(0, 255), it.g.coerceIn(0, 255), it.b.coerceIn(0, 255)) } + ?: defaultTouchSkinAccent(style) + } + val colors = remember(style, opacity, accent) { + touchSkinColors(style, opacity.coerceAtLeast(0.6f), accent) + } + val form = remember(style) { touchSkinForm(style) } + CompositionLocalProvider( + LocalTouchSkin provides colors, + LocalTouchSkinForm provides form, + LocalTouchButtonLabels provides showLabels, + LocalTouchStickKnobScale provides 0.44f, + ) { + Box( + modifier + .fillMaxWidth() + .height(118.dp) + .clip(RoundedCornerShape(18.dp)) + // A dark ground, because the real controller sits on top of a game, not on a sheet. + .background(Brush.linearGradient(listOf(Color(0xff0d1219), Color(0xff1e2836)))) + .padding(horizontal = 14.dp), + contentAlignment = Alignment.Center, + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + TouchShoulderFace(label = "LB", pressed = false, width = 42.dp, height = 18.dp) + // A directional press in a still preview reads as a stuck input, so keep all + // four directions neutral. Live play still lights the direction being held. + TouchDpadFace(arm = 20.dp, up = false, down = false, left = false, right = false) + } + TouchStickFace(diameter = 62.dp, base = { Offset.Zero }, knob = { Offset.Zero }) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + TouchShoulderFace(label = "RB", pressed = false, width = 42.dp, height = 18.dp) + Box(Modifier.size(62.dp)) { + val cap = 20.dp + val spread = 21.dp + Box(Modifier.align(Alignment.Center).offset(y = -spread)) { + TouchCapFace("Y", pressed = false, diameter = cap) + } + Box(Modifier.align(Alignment.Center).offset(y = spread)) { + TouchCapFace("A", pressed = false, diameter = cap) + } + Box(Modifier.align(Alignment.Center).offset(x = -spread)) { + TouchCapFace("X", pressed = false, diameter = cap) + } + Box(Modifier.align(Alignment.Center).offset(x = spread)) { + // One cap held down, so the picker also shows what a press looks like. + TouchCapFace("B", pressed = true, diameter = cap) + } + } + } + } + } + } +} + +// endregion diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/ui/controls/ControlRow.kt b/android/app/src/main/java/com/opencloudgaming/opennow/ui/controls/ControlRow.kt new file mode 100644 index 000000000..cf869a4f1 --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/ui/controls/ControlRow.kt @@ -0,0 +1,343 @@ +package com.opencloudgaming.opennow.ui.controls + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.onFocusChanged +import com.opencloudgaming.opennow.focusMoveHaptics +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.opencloudgaming.opennow.LocalSettingsControllerNavigationEnabled +import com.opencloudgaming.opennow.InteractionFocusFrame +import com.opencloudgaming.opennow.LocalAbsoluteCinemaEverywhere +import com.opencloudgaming.opennow.handleVerticalDpadFocusMove +import com.opencloudgaming.opennow.isTvActivateKey +import com.opencloudgaming.opennow.ui.theme.OpenNowPalette +import com.opencloudgaming.opennow.ui.theme.OpenNowRadius +import com.opencloudgaming.opennow.ui.theme.OpenNowSpacing + +/** + * How a settings-style row is painted. + * + * The settings screen and the in-stream controls panel used to own two entirely separate sets of + * row widgets — five verbatim copies of the same clip/focus/background/border/clickable chain. The + * difference between them was always purely presentational plus one focus policy, so it lives here + * and is pushed down through [LocalControlRowStyle]; no call site passes it. + * + * Content differences are *not* modelled here. A row's always-visible `value` subtitle and its + * collapsible `description` are independent optional parameters, so a row can carry neither, + * either, or both regardless of which surface it is on. + */ +@Immutable +data class ControlRowStyle( + val shape: Shape, + val containerRest: Color, + val containerFocused: Color, + val borderRestWidth: Dp, + val borderFocusWidth: Dp, + val horizontalPadding: Dp, + val verticalPadding: Dp, + val contentGap: Dp, + val labelStyle: TextStyle, + val labelWeight: FontWeight?, + val supportingStyle: TextStyle, + val supportingColor: Color, + val indentStep: Dp, + /** Whether rows take D-pad focus at all. */ + val focusable: Boolean, + /** Whether the focus ring is drawn. Same value as [focusable] today, kept separate so it can diverge. */ + val showFocusRing: Boolean, +) { + companion object { + /** Settings screen: opaque card-like rows, focus signalled by the ring alone. */ + @Composable + fun settings(): ControlRowStyle { + val controllerNavigation = LocalSettingsControllerNavigationEnabled.current + val container = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.76f) + return ControlRowStyle( + shape = RoundedCornerShape(SETTINGS_ROW_RADIUS), + containerRest = container, + containerFocused = container, + borderRestWidth = 1.dp, + borderFocusWidth = 2.dp, + horizontalPadding = OpenNowSpacing.md, + verticalPadding = OpenNowSpacing.sm, + contentGap = 3.dp, + labelStyle = MaterialTheme.typography.bodyLarge, + labelWeight = null, + supportingStyle = MaterialTheme.typography.bodySmall, + supportingColor = MaterialTheme.colorScheme.onSurfaceVariant, + indentStep = OpenNowSpacing.xl, + focusable = controllerNavigation, + showFocusRing = controllerNavigation, + ) + } + + /** + * In-stream controls panel: denser, and focusable unconditionally. + * + * The panel's own rows used to rely on `clickable`'s implicit focusability with no + * `isTvActivateKey` handling at all, which made controller navigation inside a stream + * noticeably more fragile than in settings. Going through the shared row fixes that. + */ + @Composable + fun stream(): ControlRowStyle = ControlRowStyle( + shape = RoundedCornerShape(OpenNowRadius.md), + containerRest = OpenNowPalette.PanelRowRest, + containerFocused = OpenNowPalette.PanelRowFocused, + borderRestWidth = 1.dp, + borderFocusWidth = 2.dp, + horizontalPadding = OpenNowSpacing.md, + verticalPadding = 10.dp, + contentGap = 2.dp, + labelStyle = MaterialTheme.typography.titleSmall, + labelWeight = FontWeight.SemiBold, + supportingStyle = MaterialTheme.typography.labelSmall, + supportingColor = OpenNowPalette.TextMuted, + indentStep = OpenNowSpacing.xl, + focusable = true, + showFocusRing = true, + ) + } +} + +/** Settings rows have always been 14dp — between [OpenNowRadius.md] and [OpenNowRadius.lg]. */ +private val SETTINGS_ROW_RADIUS = 14.dp + +internal val LocalControlRowStyle = compositionLocalOf { null } + +@Composable +internal fun controlRowStyle(): ControlRowStyle = + LocalControlRowStyle.current ?: ControlRowStyle.settings() + +/** + * The container every control row is built on. + * + * The modifier order matters and is copied from the original settings row: `border` comes *before* + * `clip`, which is why the focus ring sits outside the fill rather than being clipped by it. + */ +@Composable +internal fun ControlRow( + modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null, + enabled: Boolean = true, + indentLevel: Int = 0, + style: ControlRowStyle = controlRowStyle(), + verticalAlignment: Alignment.Vertical = Alignment.CenterVertically, + content: @Composable RowScope.() -> Unit, +) { + val focusManager = LocalFocusManager.current + var focused by remember { mutableStateOf(false) } + val hoverInteraction = remember { MutableInteractionSource() } + val hovered by hoverInteraction.collectIsHoveredAsState() + val showFocus = style.showFocusRing && focused + val bonanzaActive = LocalAbsoluteCinemaEverywhere.current + Box( + modifier + .fillMaxWidth() + .padding(start = style.indentStep * indentLevel), + ) { + Row( + Modifier + .fillMaxWidth() + .hoverable(hoverInteraction, enabled = enabled) + .onFocusChanged { focused = style.focusable && (it.isFocused || it.hasFocus) } + .focusMoveHaptics() + .border( + width = if (showFocus) style.borderFocusWidth else style.borderRestWidth, + color = Color.Transparent, + shape = style.shape, + ) + .clip(style.shape) + .background(if (showFocus) style.containerFocused else style.containerRest) + .then( + if (onClick != null) Modifier.clickable(enabled = enabled, onClick = onClick) + else Modifier, + ) + .onPreviewKeyEvent { event -> + when { + style.focusable && enabled && onClick != null && isTvActivateKey(event) -> { + onClick() + true + } + style.focusable -> handleVerticalDpadFocusMove(event, focusManager) + else -> false + } + } + .focusable(enabled = style.focusable) + .padding(horizontal = style.horizontalPadding, vertical = style.verticalPadding), + verticalAlignment = verticalAlignment, + content = content, + ) + InteractionFocusFrame( + visible = focused || (hovered && bonanzaActive), + cornerRadius = SETTINGS_ROW_RADIUS, + cinemaEffectEnabled = bonanzaActive, + ) + } +} + +/** + * The border/clip/background/padding chain on its own, for controls that need the row's look but + * not its `Row` layout or click behaviour — the slider, which is a `Column` and whose focus lives + * on the `Slider` itself. + */ +@Composable +internal fun Modifier.controlRowContainer(style: ControlRowStyle, showFocus: Boolean): Modifier = this + .border( + // The sibling InteractionFocusFrame is the single visible focus owner. + width = if (showFocus) style.borderFocusWidth else style.borderRestWidth, + color = Color.Transparent, + shape = style.shape, + ) + .clip(style.shape) + .background(if (showFocus) style.containerFocused else style.containerRest) + .padding(horizontal = style.horizontalPadding, vertical = style.verticalPadding) + +/** + * The leading label block shared by every row: a label, an optional always-visible value line, and + * an optional expanded description. + */ +@Composable +internal fun RowScope.ControlRowLabels( + label: String, + value: String?, + expandedDescription: String?, + enabled: Boolean, + style: ControlRowStyle, +) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(style.contentGap)) { + Text( + label, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = if (enabled) 1f else DISABLED_ALPHA), + style = style.labelStyle, + fontWeight = style.labelWeight, + maxLines = 2, + overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, + ) + if (!value.isNullOrBlank()) { + Text( + value, + color = style.supportingColor.copy(alpha = if (enabled) 1f else DISABLED_ALPHA), + style = style.supportingStyle, + maxLines = 2, + overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, + ) + } + if (!expandedDescription.isNullOrBlank()) { + Text( + expandedDescription, + color = style.supportingColor.copy(alpha = if (enabled) 0.86f else DISABLED_ALPHA), + style = MaterialTheme.typography.bodySmall, + maxLines = 3, + overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, + ) + } + } +} + +internal const val DISABLED_ALPHA = 0.45f + +/** + * Section wrapper. Settings groups rows inside a card; the stream panel, floating over video, uses + * a bare caption so it does not stack a second surface on top of the panel itself. + */ +@Immutable +data class ControlSectionStyle( + val usesCard: Boolean, + val titleStyle: TextStyle, + val titleWeight: FontWeight, + val titleColor: Color, + val itemSpacing: Dp, +) { + companion object { + @Composable + fun settings(): ControlSectionStyle = ControlSectionStyle( + usesCard = true, + titleStyle = MaterialTheme.typography.titleMedium, + titleWeight = FontWeight.Bold, + titleColor = MaterialTheme.colorScheme.onSurface, + itemSpacing = 10.dp, + ) + + @Composable + fun stream(): ControlSectionStyle = ControlSectionStyle( + usesCard = false, + titleStyle = MaterialTheme.typography.labelMedium, + titleWeight = FontWeight.Bold, + titleColor = OpenNowPalette.TextMuted, + itemSpacing = OpenNowSpacing.sm, + ) + } +} + +internal val LocalControlSectionStyle = compositionLocalOf { null } + +@Composable +internal fun controlSectionStyle(): ControlSectionStyle = + LocalControlSectionStyle.current ?: ControlSectionStyle.settings() + +@Composable +internal fun ControlSection( + title: String, + modifier: Modifier = Modifier, + style: ControlSectionStyle = controlSectionStyle(), + content: @Composable ColumnScope.() -> Unit, +) { + val body: @Composable ColumnScope.() -> Unit = { + Text(title, color = style.titleColor, style = style.titleStyle, fontWeight = style.titleWeight) + content() + } + if (style.usesCard) { + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + shape = RoundedCornerShape(SETTINGS_ROW_RADIUS), + ) { + Column( + Modifier.fillMaxWidth().padding(14.dp), + verticalArrangement = Arrangement.spacedBy(style.itemSpacing), + content = body, + ) + } + } else { + Column( + modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(style.itemSpacing), + content = body, + ) + } +} diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/ui/controls/ControlRows.kt b/android/app/src/main/java/com/opencloudgaming/opennow/ui/controls/ControlRows.kt new file mode 100644 index 000000000..0cc24be5d --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/ui/controls/ControlRows.kt @@ -0,0 +1,269 @@ +package com.opencloudgaming.opennow.ui.controls + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.opencloudgaming.opennow.R +import com.opencloudgaming.opennow.InteractionFocusFrame +import com.opencloudgaming.opennow.LocalAbsoluteCinemaEverywhere +import com.opencloudgaming.opennow.formatSliderValue +import com.opencloudgaming.opennow.handleSliderDpadInput +import com.opencloudgaming.opennow.ui.theme.numeric +import kotlin.math.roundToInt + +/** + * A labelled toggle. + * + * [value] is an always-visible subtitle reflecting the current state ("Muted", "Fixed") — the + * stream panel uses it. [description] is long-form help hidden behind an info button — settings + * uses that. They are independent; a row may have both. + */ +@Composable +internal fun ControlSwitchRow( + label: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + value: String? = null, + description: String? = null, + enabled: Boolean = true, + indentLevel: Int = 0, + style: ControlRowStyle = controlRowStyle(), +) { + var descriptionExpanded by remember(label) { mutableStateOf(false) } + val toggle = { if (enabled) onCheckedChange(!checked) } + ControlRow( + modifier = modifier, + onClick = toggle, + enabled = enabled, + indentLevel = indentLevel, + style = style, + ) { + ControlRowLabels( + label = label, + value = value, + expandedDescription = description?.takeIf { descriptionExpanded }, + enabled = enabled, + style = style, + ) + if (!description.isNullOrBlank()) { + IconButton( + onClick = { descriptionExpanded = !descriptionExpanded }, + modifier = Modifier.width(40.dp), + ) { + Icon( + painter = painterResource(R.drawable.ic_help), + contentDescription = stringResource( + if (descriptionExpanded) R.string.control_hide_description + else R.string.control_show_description, + ), + tint = MaterialTheme.colorScheme.primary, + ) + } + } + Switch(checked = checked, enabled = enabled, onCheckedChange = onCheckedChange) + } +} + +/** A row that opens a sub-page, showing the current selection and a chevron. */ +@Composable +internal fun ControlNavigationRow( + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + value: String? = null, + enabled: Boolean = true, + indentLevel: Int = 0, + style: ControlRowStyle = controlRowStyle(), +) { + ControlRow( + modifier = modifier, + onClick = onClick, + enabled = enabled, + indentLevel = indentLevel, + style = style, + ) { + ControlRowLabels(label, value, expandedDescription = null, enabled = enabled, style = style) + Icon( + painter = painterResource(R.drawable.ic_chevron_right), + contentDescription = null, + tint = style.supportingColor, + modifier = Modifier.width(22.dp), + ) + } +} + +/** A row whose trailing slot is a verb — "Open", "Reset" — rather than a control. */ +@Composable +internal fun ControlActionRow( + label: String, + actionLabel: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + value: String? = null, + enabled: Boolean = true, + indentLevel: Int = 0, + style: ControlRowStyle = controlRowStyle(), +) { + ControlRow( + modifier = modifier, + onClick = onClick, + enabled = enabled, + indentLevel = indentLevel, + style = style, + ) { + ControlRowLabels(label, value, expandedDescription = null, enabled = enabled, style = style) + Text( + actionLabel, + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.labelMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +/** + * A labelled slider with a formatted readout. + * + * [onChange] commits — it fires on release, and on each D-pad step. [onChangePreview] fires on + * every drag frame and exists for the touch-layout sliders, where watching the overlay move while + * dragging *is* the feature. Leave it null and the value is only written once, on release. + */ +@Composable +internal fun ControlSliderRow( + label: String, + value: Float, + min: Float, + max: Float, + step: Float, + onChange: (Float) -> Unit, + modifier: Modifier = Modifier, + unit: String? = null, + valueFormatter: ((Float) -> String)? = null, + description: String? = null, + descriptionProvider: ((Float) -> String?)? = null, + onChangePreview: ((Float) -> Unit)? = null, + style: ControlRowStyle = controlRowStyle(), +) { + var local by remember(value) { mutableFloatStateOf(value) } + var descriptionExpanded by remember(label) { mutableStateOf(false) } + val focusManager = LocalFocusManager.current + var focused by remember { mutableStateOf(false) } + val hoverInteraction = remember { MutableInteractionSource() } + val hovered by hoverInteraction.collectIsHoveredAsState() + val showFocus = style.showFocusRing && focused + val bonanzaActive = LocalAbsoluteCinemaEverywhere.current + val quantize = { raw: Float -> ((raw / step).roundToInt() * step).coerceIn(min, max) } + Box(modifier.fillMaxWidth()) { + Column( + Modifier + .fillMaxWidth() + .hoverable(hoverInteraction) + .controlRowContainer(style = style, showFocus = showFocus), + ) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text( + label, + Modifier.weight(1f), + color = MaterialTheme.colorScheme.onSurface, + style = style.labelStyle, + fontWeight = style.labelWeight, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Text( + formatSliderValue(local, min, max, step, unit, valueFormatter), + color = style.supportingColor, + // Tabular figures so the readout does not reflow while the thumb is dragged. + style = MaterialTheme.typography.labelLarge.numeric(), + ) + if (!description.isNullOrBlank()) { + IconButton( + onClick = { descriptionExpanded = !descriptionExpanded }, + modifier = Modifier.width(40.dp), + ) { + Icon( + painter = painterResource(R.drawable.ic_help), + contentDescription = stringResource( + if (descriptionExpanded) R.string.control_hide_description + else R.string.control_show_description, + ), + tint = MaterialTheme.colorScheme.primary, + ) + } + } + } + if (descriptionExpanded && !description.isNullOrBlank()) { + Text( + description, + color = style.supportingColor.copy(alpha = 0.86f), + style = MaterialTheme.typography.bodySmall, + maxLines = 4, + overflow = TextOverflow.Ellipsis, + ) + } + Slider( + modifier = Modifier + .onFocusChanged { focused = style.focusable && it.isFocused } + .onPreviewKeyEvent { + handleSliderDpadInput(it, local, min, max, step, focusManager) { next -> + local = quantize(next) + onChange(local) + } + }, + value = local, + onValueChange = { + local = quantize(it) + onChangePreview?.invoke(local) + }, + onValueChangeFinished = { onChange(local) }, + valueRange = min..max, + ) + descriptionProvider?.invoke(local)?.let { description -> + Spacer(Modifier.height(2.dp)) + Text( + description, + color = style.supportingColor.copy(alpha = 0.8f), + style = MaterialTheme.typography.labelSmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + InteractionFocusFrame( + visible = focused || (hovered && bonanzaActive), + cornerRadius = 14.dp, + cinemaEffectEnabled = bonanzaActive, + ) + } +} diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/ui/theme/Color.kt b/android/app/src/main/java/com/opencloudgaming/opennow/ui/theme/Color.kt new file mode 100644 index 000000000..bef7755cd --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/ui/theme/Color.kt @@ -0,0 +1,101 @@ +package com.opencloudgaming.opennow.ui.theme + +import androidx.compose.ui.graphics.Color +import com.opencloudgaming.opennow.StreamQualityLevel + +/** + * The single source of truth for every colour in the app. + * + * Before this existed the palette was declared twice (once in `OpenNowScreens.kt`, once in + * `OpenNowSettingsScreens.kt` with identical hex under different names) and another ~75 one-off + * `Color(0x..)` literals were scattered inline. Anything that appears more than once belongs here. + */ +object OpenNowPalette { + // Core surfaces + val Background = Color(0xff090b0d) + val Panel = Color(0xff11161a) + val PanelAlt = Color(0xff171d22) + + // Text + val TextPrimary = Color(0xffeef3f5) + val TextMuted = Color(0xff98a4aa) + + /** Sits on top of the accent — near-black so bright accents stay legible. */ + val OnAccent = Color(0xff08090c) + + // Accents (mirrors UiAccent in Models.kt) + val AccentDefault = Color(0xff6af0a0) + val AccentDefaultSecondary = Color(0xfff4fff7) + val AccentPixel = Color(0xff8ab4f8) + val AccentHotPink = Color(0xffff4fb8) + val AccentLime = Color(0xffc7ef6b) + val AccentCoral = Color(0xffff8d7a) + val AccentViolet = Color(0xffc7a4ff) + /** Reserved for the animated Absolute Cinema focus/hover energy, not ordinary theme chrome. */ + val AccentCinemaOrange = Color(0xffff6a2b) + val AccentCinemaBlue = Color(0xff42c9ff) + val AccentSwitchRed = Color(0xffff4554) + val AccentSwitchBlue = Color(0xff66d9ff) + + // Chrome + /** Translucent wash behind the top bar so content scrolls under it legibly. */ + val ChromeScrim = Color.Black.copy(alpha = 0.16f) + + // Feedback + val ErrorContainer = Color(0xff33181c) + val OnErrorContainer = Color(0xffffb8bf) + + /** + * The quality ladder, shared by the in-stream stats pill and the post-session report so the two + * stop disagreeing about what "bad" looks like. Good deliberately has no tint of its own — + * colouring the normal case just makes the abnormal one harder to spot. + */ + val StatusGood = AccentDefault + val StatusFair = Color(0xffffc95a) + val StatusPoor = AccentCoral + + /** Advisory notices that are neither an error nor a quality reading — privacy disclosures. */ + val StatusNotice = Color(0xffffc266) + + // Chrome that sits on top of live video + /** + * Panel fill. Deliberately not opaque — the whole point of the controls panel is to be usable + * without leaving the game — but firm enough that TextMuted still clears 4.5:1 over bright + * gameplay, which it does not at the old 0.93. + */ + val PanelOverVideo = Panel.copy(alpha = 0.96f) + + /** + * Row fills inside a panel over video. Opaque tones rather than translucent white, which used + * to composite differently against every frame of the game behind it. + */ + val PanelRowRest = Color(0xff1b2228) + val PanelRowFocused = Color(0xff28323a) + + /** Hairline that keeps an overlay's edge visible against a bright frame. */ + val PanelHairline = Color.White.copy(alpha = 0.08f) + + /** Full-screen wash behind a stream overlay. */ + val StreamScrim = Color.Black.copy(alpha = 0.55f) + + // Imagery + /** Backdrop for box art that is still loading, empty, or failed. */ + val ImagePlaceholder = Color(0xff0e1317) + + /** Base tone the shimmer band sweeps across. */ + val ShimmerBase = Color(0xff0d1216) + + /** Backdrop behind the catalog wallpaper. */ + val WallpaperBackdrop = Color(0xff07100b) +} + +/** + * Colour for a quality reading, or `null` when the metric is fine and should simply render in the + * normal text colour. Returning null rather than a "good" green is deliberate: if every number is + * tinted, none of them stand out. + */ +fun StreamQualityLevel.tint(): Color? = when (this) { + StreamQualityLevel.Good -> null + StreamQualityLevel.Fair -> OpenNowPalette.StatusFair + StreamQualityLevel.Poor -> OpenNowPalette.StatusPoor +} diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/ui/theme/Motion.kt b/android/app/src/main/java/com/opencloudgaming/opennow/ui/theme/Motion.kt new file mode 100644 index 000000000..8d18ac1b9 --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/ui/theme/Motion.kt @@ -0,0 +1,30 @@ +package com.opencloudgaming.opennow.ui.theme + +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.runtime.staticCompositionLocalOf + +/** + * Duration and easing tokens, replacing the assorted `tween(1_100)` / `tween(900)` / `tween(820)` + * values that were picked independently across the UI. + */ +object OpenNowMotion { + /** Press, toggle, ripple — anything that must feel instantaneous. */ + const val DurationFast = 120 + + /** Focus, hover, chip and tab changes. */ + const val DurationStandard = 260 + + /** Sheets and page transitions, where the movement itself carries meaning. */ + const val DurationEmphasized = 420 + + val EasingStandard = CubicBezierEasing(0.2f, 0f, 0f, 1f) + val EasingEmphasizedDecel = CubicBezierEasing(0.05f, 0.7f, 0.1f, 1f) + val EasingEmphasizedAccel = CubicBezierEasing(0.3f, 0f, 0.8f, 0.15f) +} + +/** + * True when the user has turned animations off system-wide, or disabled background animations in + * app settings. Infinite transitions (shimmer, focus pulse, carousel auto-advance) must check this + * — an animation that never ends is the one that actually hurts. + */ +val LocalReduceMotion = staticCompositionLocalOf { false } diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/ui/theme/Shape.kt b/android/app/src/main/java/com/opencloudgaming/opennow/ui/theme/Shape.kt new file mode 100644 index 000000000..8785eedaa --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/ui/theme/Shape.kt @@ -0,0 +1,27 @@ +package com.opencloudgaming.opennow.ui.theme + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Shapes +import androidx.compose.ui.unit.dp + +/** + * Six radii, down from the thirteen distinct values that were previously spread across ~109 + * inline `RoundedCornerShape(...)` call sites. + */ +object OpenNowRadius { + val xs = 4.dp + val sm = 8.dp + val md = 12.dp + val lg = 16.dp + val xl = 24.dp + val full = 999.dp +} + +/** Wired into `MaterialTheme(shapes = ...)`, which previously received no shapes at all. */ +val OpenNowShapes = Shapes( + extraSmall = RoundedCornerShape(OpenNowRadius.xs), + small = RoundedCornerShape(OpenNowRadius.sm), + medium = RoundedCornerShape(OpenNowRadius.md), + large = RoundedCornerShape(OpenNowRadius.lg), + extraLarge = RoundedCornerShape(OpenNowRadius.xl), +) diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/ui/theme/Spacing.kt b/android/app/src/main/java/com/opencloudgaming/opennow/ui/theme/Spacing.kt new file mode 100644 index 000000000..ac550f76a --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/ui/theme/Spacing.kt @@ -0,0 +1,22 @@ +package com.opencloudgaming.opennow.ui.theme + +import androidx.compose.ui.unit.dp + +/** One spacing scale, so gutters and padding stop being decided independently at each call site. */ +object OpenNowSpacing { + val xs = 4.dp + val sm = 8.dp + val md = 12.dp + val lg = 16.dp + val xl = 24.dp + val xxl = 32.dp + + /** Distance from content to the edge of the screen. */ + val ScreenEdge = 16.dp + + /** Horizontal gap between cards in the catalog grid. */ + val GridGutter = 12.dp + + /** Vertical gap between rows in the catalog grid — larger than the gutter to separate captions. */ + val GridRowGap = 16.dp +} diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/ui/theme/Type.kt b/android/app/src/main/java/com/opencloudgaming/opennow/ui/theme/Type.kt new file mode 100644 index 000000000..468d77c9c --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/ui/theme/Type.kt @@ -0,0 +1,128 @@ +package com.opencloudgaming.opennow.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.ExperimentalTextApi +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontVariation +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp +import com.opencloudgaming.opennow.R + +/** + * Inter Variable, SIL Open Font License 1.1 (see `app/licenses/Inter-OFL-1.1.txt`). + * + * Chosen over Roboto for three reasons that matter to this app specifically: it ships tabular + * figures and a slashed zero (the UI is full of numeric readouts), its tall x-height and open + * apertures read far better at TV viewing distance, and one variable file covers every weight. + * + * `minSdk` is 23 but variable-font axes need API 26. On 23–25 the file loads at its default + * instance and Compose applies synthetic bolding — an acceptable degradation on a shrinking + * slice of devices. + */ +@OptIn(ExperimentalTextApi::class) +private fun interWeight(weight: FontWeight) = Font( + resId = R.font.inter_variable, + weight = weight, + variationSettings = FontVariation.Settings(FontVariation.weight(weight.weight)), +) + +val Inter = FontFamily( + interWeight(FontWeight.Normal), + interWeight(FontWeight.Medium), + interWeight(FontWeight.SemiBold), + interWeight(FontWeight.Bold), + interWeight(FontWeight.ExtraBold), +) + +/** + * Weight and tracking are baked into each style, so call sites stop appending + * `fontWeight = FontWeight.ExtraBold` by hand — which is how the previous UI ended up with the + * same visual role rendered at three different weights on three different screens. + * + * **Every `letterSpacing` here must be `sp`, and every style must set one explicitly.** + * `TextUnit` arithmetic throws `IllegalArgumentException: Cannot perform operation for Sp and Em` + * when the two operands use different units, and Material components lerp between typography + * styles — `OutlinedTextField` animates its label between `bodyLarge` and `bodySmall`, for one. + * An earlier revision expressed tracking in `em` but left `titleSmall` and `bodyLarge` on + * Material's `sp` defaults, which crashed the Stream settings page the moment a text field with a + * label was composed. Material also lerps against its own hardcoded `sp` styles, so `sp` + * throughout is the only combination that cannot collide. `TypographyUnitsTest` enforces this. + */ +val OpenNowTypography = Typography().run { + copy( + displayLarge = displayLarge.copy( + fontFamily = Inter, fontWeight = FontWeight.ExtraBold, + fontSize = 44.sp, lineHeight = 50.sp, letterSpacing = (-0.88).sp, + ), + displayMedium = displayMedium.copy( + fontFamily = Inter, fontWeight = FontWeight.ExtraBold, + fontSize = 36.sp, lineHeight = 42.sp, letterSpacing = (-0.72).sp, + ), + displaySmall = displaySmall.copy( + fontFamily = Inter, fontWeight = FontWeight.ExtraBold, + fontSize = 30.sp, lineHeight = 36.sp, letterSpacing = (-0.54).sp, + ), + headlineLarge = headlineLarge.copy( + fontFamily = Inter, fontWeight = FontWeight.Bold, + fontSize = 27.sp, lineHeight = 33.sp, letterSpacing = (-0.38).sp, + ), + headlineMedium = headlineMedium.copy( + fontFamily = Inter, fontWeight = FontWeight.Bold, + fontSize = 24.sp, lineHeight = 30.sp, letterSpacing = (-0.29).sp, + ), + headlineSmall = headlineSmall.copy( + fontFamily = Inter, fontWeight = FontWeight.Bold, + fontSize = 22.sp, lineHeight = 28.sp, letterSpacing = (-0.24).sp, + ), + titleLarge = titleLarge.copy( + fontFamily = Inter, fontWeight = FontWeight.Bold, + fontSize = 19.sp, lineHeight = 25.sp, letterSpacing = (-0.15).sp, + ), + titleMedium = titleMedium.copy( + fontFamily = Inter, fontWeight = FontWeight.SemiBold, + fontSize = 16.sp, lineHeight = 22.sp, letterSpacing = (-0.06).sp, + ), + titleSmall = titleSmall.copy( + fontFamily = Inter, fontWeight = FontWeight.SemiBold, + fontSize = 14.sp, lineHeight = 20.sp, letterSpacing = 0.1.sp, + ), + bodyLarge = bodyLarge.copy( + fontFamily = Inter, fontWeight = FontWeight.Normal, + fontSize = 16.sp, lineHeight = 24.sp, letterSpacing = 0.5.sp, + ), + bodyMedium = bodyMedium.copy( + fontFamily = Inter, fontWeight = FontWeight.Normal, + fontSize = 14.sp, lineHeight = 20.sp, letterSpacing = 0.1.sp, + ), + bodySmall = bodySmall.copy( + fontFamily = Inter, fontWeight = FontWeight.Normal, + fontSize = 12.sp, lineHeight = 17.sp, letterSpacing = 0.12.sp, + ), + labelLarge = labelLarge.copy( + fontFamily = Inter, fontWeight = FontWeight.SemiBold, + fontSize = 13.sp, lineHeight = 16.sp, letterSpacing = 0.26.sp, + ), + labelMedium = labelMedium.copy( + fontFamily = Inter, fontWeight = FontWeight.Medium, + fontSize = 12.sp, lineHeight = 15.sp, letterSpacing = 0.3.sp, + ), + labelSmall = labelSmall.copy( + fontFamily = Inter, fontWeight = FontWeight.Medium, + fontSize = 11.sp, lineHeight = 14.sp, letterSpacing = 0.44.sp, + ), + ) +} + +/** + * Tabular, slashed-zero figures for anything that updates in place — queue position, FPS, bitrate, + * latency, slider values. Proportional digits make those readouts visibly jitter on every tick. + */ +val OpenNowNumericStyle = TextStyle( + fontFamily = Inter, + fontFeatureSettings = "tnum, zero", +) + +/** Applies tabular figures while keeping whatever size/weight the caller already chose. */ +fun TextStyle.numeric(): TextStyle = copy(fontFeatureSettings = "tnum, zero") diff --git a/android/app/src/main/res/drawable-nodpi/catalog_absolute_cinema_background.jpg b/android/app/src/main/res/drawable-nodpi/catalog_absolute_cinema_background.jpg new file mode 100644 index 000000000..e20649768 Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/catalog_absolute_cinema_background.jpg differ diff --git a/android/app/src/main/res/drawable-nodpi/catalog_colorful_abstract_background.jpg b/android/app/src/main/res/drawable-nodpi/catalog_colorful_abstract_background.jpg new file mode 100644 index 000000000..4769ea300 Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/catalog_colorful_abstract_background.jpg differ diff --git a/android/app/src/main/res/drawable-nodpi/catalog_default_background.webp b/android/app/src/main/res/drawable-nodpi/catalog_default_background.webp new file mode 100644 index 000000000..06d272169 Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/catalog_default_background.webp differ diff --git a/android/app/src/main/res/drawable/ic_arrow_back.xml b/android/app/src/main/res/drawable/ic_arrow_back.xml new file mode 100644 index 000000000..c1d24c8be --- /dev/null +++ b/android/app/src/main/res/drawable/ic_arrow_back.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_arrow_up.xml b/android/app/src/main/res/drawable/ic_arrow_up.xml new file mode 100644 index 000000000..59605489b --- /dev/null +++ b/android/app/src/main/res/drawable/ic_arrow_up.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_chevron_right.xml b/android/app/src/main/res/drawable/ic_chevron_right.xml new file mode 100644 index 000000000..afafd9942 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_chevron_right.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_clear.xml b/android/app/src/main/res/drawable/ic_clear.xml new file mode 100644 index 000000000..194fa93eb --- /dev/null +++ b/android/app/src/main/res/drawable/ic_clear.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_help.xml b/android/app/src/main/res/drawable/ic_help.xml new file mode 100644 index 000000000..945800aa9 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_help.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_keyboard.xml b/android/app/src/main/res/drawable/ic_keyboard.xml new file mode 100644 index 000000000..0145e8044 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_keyboard.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_mic.xml b/android/app/src/main/res/drawable/ic_mic.xml new file mode 100644 index 000000000..37939c87b --- /dev/null +++ b/android/app/src/main/res/drawable/ic_mic.xml @@ -0,0 +1,13 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_save.xml b/android/app/src/main/res/drawable/ic_save.xml new file mode 100644 index 000000000..e5e76c0bc --- /dev/null +++ b/android/app/src/main/res/drawable/ic_save.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_save_filled.xml b/android/app/src/main/res/drawable/ic_save_filled.xml new file mode 100644 index 000000000..fa736fab2 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_save_filled.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_search.xml b/android/app/src/main/res/drawable/ic_search.xml new file mode 100644 index 000000000..1de4f7cd0 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_search.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_sort_filter.xml b/android/app/src/main/res/drawable/ic_sort_filter.xml new file mode 100644 index 000000000..6699f58a3 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_sort_filter.xml @@ -0,0 +1,14 @@ + + + + diff --git a/android/app/src/main/res/drawable/ic_store_amazon.xml b/android/app/src/main/res/drawable/ic_store_amazon.xml new file mode 100644 index 000000000..9826e24c7 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_store_amazon.xml @@ -0,0 +1,7 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_store_battlenet.xml b/android/app/src/main/res/drawable/ic_store_battlenet.xml new file mode 100644 index 000000000..c2d50e89b --- /dev/null +++ b/android/app/src/main/res/drawable/ic_store_battlenet.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_store_ea.xml b/android/app/src/main/res/drawable/ic_store_ea.xml new file mode 100644 index 000000000..444ac6b62 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_store_ea.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_store_epic.xml b/android/app/src/main/res/drawable/ic_store_epic.xml new file mode 100644 index 000000000..38da7c7bf --- /dev/null +++ b/android/app/src/main/res/drawable/ic_store_epic.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_store_gog.xml b/android/app/src/main/res/drawable/ic_store_gog.xml new file mode 100644 index 000000000..89fab7bb6 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_store_gog.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_store_google_play.xml b/android/app/src/main/res/drawable/ic_store_google_play.xml new file mode 100644 index 000000000..3abd1bae0 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_store_google_play.xml @@ -0,0 +1,7 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_store_hoyo.xml b/android/app/src/main/res/drawable/ic_store_hoyo.xml new file mode 100644 index 000000000..8337fad87 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_store_hoyo.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_store_microsoft.xml b/android/app/src/main/res/drawable/ic_store_microsoft.xml new file mode 100644 index 000000000..62f95ef78 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_store_microsoft.xml @@ -0,0 +1,7 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_store_riot.xml b/android/app/src/main/res/drawable/ic_store_riot.xml new file mode 100644 index 000000000..bb1792b0b --- /dev/null +++ b/android/app/src/main/res/drawable/ic_store_riot.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_store_rockstar.xml b/android/app/src/main/res/drawable/ic_store_rockstar.xml new file mode 100644 index 000000000..eed8b9e59 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_store_rockstar.xml @@ -0,0 +1,7 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_store_steam.xml b/android/app/src/main/res/drawable/ic_store_steam.xml new file mode 100644 index 000000000..244ea3f62 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_store_steam.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_store_ubisoft.xml b/android/app/src/main/res/drawable/ic_store_ubisoft.xml new file mode 100644 index 000000000..502155b2e --- /dev/null +++ b/android/app/src/main/res/drawable/ic_store_ubisoft.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_store_xbox.xml b/android/app/src/main/res/drawable/ic_store_xbox.xml new file mode 100644 index 000000000..618d0b999 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_store_xbox.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_tab_library.xml b/android/app/src/main/res/drawable/ic_tab_library.xml new file mode 100644 index 000000000..bebcf3be9 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_tab_library.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_tab_settings.xml b/android/app/src/main/res/drawable/ic_tab_settings.xml new file mode 100644 index 000000000..0dc925109 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_tab_settings.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_tab_store.xml b/android/app/src/main/res/drawable/ic_tab_store.xml new file mode 100644 index 000000000..7d9ac9efb --- /dev/null +++ b/android/app/src/main/res/drawable/ic_tab_store.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_tab_stream.xml b/android/app/src/main/res/drawable/ic_tab_stream.xml new file mode 100644 index 000000000..9c89a82a5 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_tab_stream.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_volume_off.xml b/android/app/src/main/res/drawable/ic_volume_off.xml new file mode 100644 index 000000000..a56690272 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_volume_off.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/android/app/src/main/res/drawable/opennow_banner.png b/android/app/src/main/res/drawable/opennow_banner.png new file mode 100644 index 000000000..b86426566 Binary files /dev/null and b/android/app/src/main/res/drawable/opennow_banner.png differ diff --git a/android/app/src/main/res/drawable/opennow_icon.png b/android/app/src/main/res/drawable/opennow_icon.png new file mode 100644 index 000000000..1c29e3ed6 Binary files /dev/null and b/android/app/src/main/res/drawable/opennow_icon.png differ diff --git a/android/app/src/main/res/drawable/opennow_logo_mark.png b/android/app/src/main/res/drawable/opennow_logo_mark.png new file mode 100644 index 000000000..adbdff2b7 Binary files /dev/null and b/android/app/src/main/res/drawable/opennow_logo_mark.png differ diff --git a/android/app/src/main/res/font/inter_variable.ttf b/android/app/src/main/res/font/inter_variable.ttf new file mode 100644 index 000000000..e8262219d Binary files /dev/null and b/android/app/src/main/res/font/inter_variable.ttf differ diff --git a/android/app/src/main/res/raw/nerd_queue_ready.mp3 b/android/app/src/main/res/raw/nerd_queue_ready.mp3 new file mode 100644 index 000000000..9637e553a Binary files /dev/null and b/android/app/src/main/res/raw/nerd_queue_ready.mp3 differ diff --git a/android/app/src/main/res/raw/nerd_stream_intro.mp3 b/android/app/src/main/res/raw/nerd_stream_intro.mp3 new file mode 100644 index 000000000..b559df0c0 Binary files /dev/null and b/android/app/src/main/res/raw/nerd_stream_intro.mp3 differ diff --git a/android/app/src/main/res/values-ar/strings.xml b/android/app/src/main/res/values-ar/strings.xml new file mode 100644 index 000000000..9048eb2cc --- /dev/null +++ b/android/app/src/main/res/values-ar/strings.xml @@ -0,0 +1,840 @@ + + + OpenNOW + جارٍ تشغيل OpenNOW + تسجيل الدخول باستخدام %1$s + تسجيل الدخول على جهاز آخر باستخدام %1$s + استخدم هذا الرمز لتسجيل الدخول + %1$s + في انتظار تسجيل الدخول + تنتهي صلاحية الرمز خلال %1$d:%2$02d + المتجر + بحث + المكتبة + الإعدادات + البحث عن ألعاب + البحث في الإعدادات + عام + التحديثات والخصوصية وبيانات التطبيق + اللغة + لغة التطبيق + إعداد النظام الافتراضي + الإنجليزية + البث + الدقة وFPS وبرنامج الترميز وHDR والوكيل + الإدخال + الميكروفون والماوس ولوحة المفاتيح وعناصر اللمس والاهتزاز + الواجهة + المظهر والمكتبة وشريط الحالة والأصوات + الحساب + تسجيل الدخول والتخزين والمتاجر المتصلة + متقدم + الخيارات المتقدمة والميزات التجريبية والتشخيص والسجلات + حول + الإصدار والمساهمون والدعم + إظهار عناوين الألعاب + مسح البحث + البحث الصوتي + %1$d لعبة + لم يتم تحميل أي ألعاب + لا توجد ألعاب مطابقة في المكتبة + امسح البحث لإظهار جميع الألعاب في مكتبتك. + امسح عوامل التصفية لإظهار جميع الألعاب في مكتبتك. + امسح البحث أو عوامل التصفية لإظهار جميع الألعاب في مكتبتك. + لا توجد ألعاب مطابقة في المتجر + امسح البحث لإظهار المزيد من الألعاب. + امسح عوامل التصفية لإظهار المزيد من الألعاب. + امسح البحث أو عوامل التصفية لإظهار المزيد من الألعاب. + العودة إلى اللعب + قريبًا + أحدث الألعاب على GeForce NOW + متابعة اللعب + في قائمة الانتظار + المفضلة + التوصيات + عرض الكل + تشغيل + متابعة + استئناف + حفظ + تم الحفظ + إضافة إلى المفضلة + إزالة من المفضلة + إلغاء + تشغيل + إيقاف + ظاهر + مخفي + رجوع + فتح + إعادة ضبط + إغلاق + عناصر التحكم في البث + خروج + تم + فتح إرسال لوحة المفاتيح + العرض + الإدخال + الدعم + وحدة التحكم + تخطيط اللمس + الصوت + مكتوم + شريط الحالة + %1$s · %2$d عناصر + زيادة حدة البث + مستوى الحدة + تمديد للملاءمة + مباشر + %1$d ميغابت/ث نشطة في هذه الجلسة + الإعدادات › البث لا تنطبق إلا على الجلسة التالية + الميكروفون + الإذن مطلوب + قائمة Steam + إرسال زر Home إلى الكمبيوتر البعيد + Esc + Enter + + ماوس وحدة التحكم + العصا اليمنى · A للنقر · B للنقر بزر الماوس الأيمن + ماوس بالإصبع + نقر مباشر + وحدة تحكم باللمس + تم اكتشاف عناصر لمس مدمجة في اللعبة + تدعم هذه اللعبة عناصر تحكم باللمس مدمجة. لا يزال بإمكانك تشغيل وحدة التحكم باللمس من OpenNOW أدناه إذا كنت تفضل ذلك. + تدعم هذه اللعبة عناصر تحكم باللمس مدمجة. تم تمكين وحدة التحكم باللمس من OpenNOW لهذه الجلسة. + العناصر المدمجة نشطة + عصي التحكم + ثابت + ديناميكي + استخدام اهتزاز الهاتف كبديل + وضع ماوس وحدة التحكم + العصا اليسرى للتحريك · اليمنى للتمرير · A للنقر · B للنقر الأيمن + وضع الماوس + إعداد محاكاة الماوس + عناصر التحكم باللمس + تخطيط وحدة التحكم وعصي التحكم والاهتزاز + الإبلاغ عن مشكلة + تشغيل الفحوصات وإرسال بيانات تشخيص منقحة + وضع التحرير بالسحب + إعادة ضبط تخطيط اللمس + إعادة المواضع إلى الإعداد الافتراضي + مقياس التخطيط + حجم الأزرار + التعتيم + تباعد الحواف + التباعد السفلي + الموضع الأيسر + الموضع الأيمن + عصي التحكم + ضبط عناصر التحكم التناظرية باللمس + وضع ديناميكي + يبدأ في المنتصف تحت إبهامك + يستخدم المركز الثابت المحفوظ + حجم العصا + المنطقة الميتة + يحتفظ الوضع الديناميكي بمنطقة العصا المحفوظة، لكنه يعتبر أول موضع يلمسه إبهامك نقطة محايدة. يمنع ذلك الحركة المفاجئة إذا لم تلمس المركز بدقة. + شريط الحالة + اختر التخطيط والمعلومات + المظهر + الموضع + العناصر + FPS + Ping + معدل البت + البطارية + الاتصال + الدقة + برنامج الترميز + الخادم + فك / تفاوت + الفقد + لوحة المفاتيح + %1$d/100 + %1$s + عدم إظهار تقارير الجلسات مجددًا + الاتصال + لم يتم القياس + زمن الاستجابة + سرعة البث + فقد الحزم + التفاوت + معدل الإطارات + فك الترميز + متوسط %1$d مللي ثانية + ذروة %1$d مللي ثانية + ذروة %1$s + مستقر + قد يؤثر في الوضوح + تفاوت التوقيت + متوسط / هدف FPS + لكل إطار فيديو + التحكم في الجلسة + الخروج من البث؟ + هل تريد فعلًا الخروج من %1$s؟ + سيتم إغلاق جلسة اللعب السحابي الحالية. + متابعة اللعب + الخروج من البث + الإبلاغ عن الأخطاء + الإبلاغ عن خطأ + الإبلاغ عن خطأ في البث + إرسال المشكلة وبيانات التشخيص المنقحة + إظهار الوصف + إخفاء الوصف + Ping %1$s + فك %1$s مللي ثانية + تفاوت %1$s مللي ثانية + فقد %1$s%% + %1$d إطارًا في الثانية + Ping‏ %1$d مللي ثانية + زمن فك الترميز %1$s مللي ثانية لكل إطار + التفاوت %1$s مللي ثانية + فقد الحزم بنسبة %1$s بالمئة + جيدة + مقبولة + ضعيفة + إغلاق + اللعب على %1$s + مسح عوامل التصفية + العودة إلى الأعلى + تلقائي + قريبًا + اختيار منصة التشغيل + منصات التشغيل + افتراضي + محدد + منصة تشغيل متاحة + عدم السؤال مجددًا — جعل هذا المتجر افتراضيًا + المتابعة باستخدام المتجر الافتراضي: %1$s + تلميح: اضغط مطولًا على تشغيل لاختيار متجر مختلف لاحقًا. + اضغط مطولًا على تشغيل لاختيار متجر + البث + الواجهة + الجودة + الفيديو + الاتصال + الصوت ولوحة المفاتيح + إدخال المؤشر + قفل الماوس + يبقي الماوس الخارجي محصورًا داخل اللعبة أثناء البث. يؤدي فتح عناصر التحكم في البث إلى تحريره. + وحدة التحكم واللمس + المظهر + المكتبة والتنقل + شريط الحالة + الأصوات والجلسات + إظهار تقرير الجلسة + إظهار ملخص الجودة بعد كل بث. + أدوات متقدمة + شكر وتقدير + الدقة + نسبة العرض إلى الارتفاع + إعداد البث المسبق + موصى به + مخصص + منخفض (توفير البيانات) + متوسط + مرتفع + FPS + معدل البت بالميغابت/ث + برنامج الترميز + اللون + H.264/H.265 فقط + يستخدم AV1 ألوان 8 بت. اختر H.265 لاستخدام 10 بت؛ يقتصر HDR على أوضاع Android TV المتوافقة. + يستخدم AV1 ألوان 8 بت في OpenNOW. تم التبديل إلى 8 بت وتعطيل HDR. اختر H.265 أو H.264 لاستخدام 10 بت. + HDR ‏(Performance & Ultimate) + لا يتوفر بث HDR على أجهزة Android المحمولة. يظل SDR ‏10 بت متاحًا مع H.265. + يتطلب HDR على Android TV استخدام H.265 بمعدل 60 FPS أو أقل ودقة تصل إلى 3840 × 2160. + المنطقة + وكيل الجلسة + يوجه إنشاء جلسة GFN واستعلامات قائمة الانتظار عبر هذا الوكيل. اتركه متوقفًا للطلبات المباشرة. + عنوان URL للوكيل + نسخ تشخيص برنامج الترميز + تم نسخ تشخيص برنامج الترميز + لم يتم تشغيل اختبار برنامج الترميز بعد. + تمكين وكيل الجلسة؟ + سيتم توجيه إنشاء جلسة GFN واستعلامات قائمة الانتظار والاستئناف والإيقاف وطلبات تحديث إعلانات قائمة الانتظار عبر الوكيل الذي تدخله. + قد يتسبب وكيل غير صالح أو محظور في تعطيل التشغيل أو تقدم قائمة الانتظار أو استئناف جلسة نشطة أو إنهاء الجلسة. + استخدم وكيلًا تثق به فقط. قد يتمكن مشغل الوكيل من رؤية توقيت الطلبات والمضيفين الوجهة والبيانات الوصفية الحساسة لحركة الجلسة. + تمكين الوكيل + بث تجريبي + قد يتسبب في فشل تشغيل الجلسات. + L4S + يطلب مسار النقل منخفض التأخير والفقد من NVIDIA عندما يدعمه الخادم والشبكة. اتركه متوقفًا إذا أصبحت شبكتك غير مستقرة. + طلب Cloud G-Sync / VRR + يطلب من الجلسة السحابية استخدام توقيت تحديث متغير عندما يدعمه جهازك وشاشتك وخطتك وجلسة GFN. + الميكروفون + يرسل ميكروفون Android الافتراضي إلى اللعبة البعيدة. يمكنك كتمه من عناصر التحكم في البث. + لم يتم منح إذن الميكروفون. سيبقي OpenNOW بث الميكروفون متوقفًا. + استخدام ألوان النظام + لون التمييز + صفحة البدء + المتجر + المكتبة + تعطيل البحث عن التحديثات + خيارات متقدمة + يعرض خيارات تجريبية للكتالوج والضبط. تظل علامة تبويب التشخيص المتقدم متاحة. + نمط بطاقات تعبيري + يستخدم أسطح بطاقات أكثر سطوعًا وزوايا أكثر نعومة. أوقفه للحصول على نمط Material أبسط وأكثر هدوءًا. + خلفية الكتالوج + يعرض صورة خلفية خلف المتجر والمكتبة على شاشات الأجهزة المحمولة. + صورة الخلفية + صورة مخصصة + خلفية مدمجة + تجريدي ملون (افتراضي) + OpenNOW الأصلي + Absolute Cinema + اختيار صورة + استخدام الافتراضي + تباعد حواف الشاشة + بطاقات ألعاب مدمجة + إظهار أسماء المتاجر + حجم بطاقات الألعاب + إخفاء أزرار البث + زر لوحة المفاتيح على الشاشة + يعرض رمز لوحة مفاتيح صغيرًا في شريط حالة البث. + إظهار شريط الحالة افتراضيًا + موضع طبقة الإحصاءات + إخفاء محدد الخادم + أصوات ضغط الأزرار + يشغل صوتًا قصيرًا عند التنقل بوحدة التحكم والضغط على عناصر التحكم على الشاشة. + تشغيل موسيقى المقدمة + تبدأ موسيقى المقدمة + مكتومة + قيد التشغيل + تشغيل الموسيقى عند انتهاء قائمة الانتظار + كتم الموسيقى + تمديد البث لملء الشاشة + مؤقت جلسة ذكي + شكرًا لكل من يساعد في تحسين OpenNOW للجميع. + DarkevilPT + دعم المجتمع + تبرع + تم نسخ رابط التبرع + OpenNOW + أزرق Pixel + وردي فاقع + ليموني + مرجاني + بنفسجي + أداة البث الأصلية (تجريبية) + يعترض وحدة فك الترميز المادية لتطبيق خصائص زمن الاستجابة المنخفض الخاصة بالشركة المصنعة. قد يكون غير مستقر. + يستخدم اللمس الأصلي التلقائي وضع لوحة الألعاب للبث عالي الدقة أو FPS المرتفع للحفاظ على وضع البث المحدد. اختر كل لعبة لإعطاء الأولوية للمس الأصلي. + تصغير + عرض + اللعب على التلفزيون + جارٍ بدء البث + الموضع في قائمة الانتظار: %1$d + في انتظار جهاز لعب + جارٍ توصيل البث + جارٍ استئناف الجلسة + جارٍ إعداد جهاز اللعب + جارٍ بدء الجلسة + حالة قائمة الانتظار + %1$s جاهزة للعب! + انتهى انتظارك في قائمة GFN. انقر للعودة إلى التطبيق. + يستمر البث أثناء إيقاف تشغيل الشاشة + غير مملوكة + ناشر غير معروف + استئناف الجلسة السحابية + التطبيق %1$s + قائمة الانتظار %1$d + جارٍ البدء + لا يتوفر وصف لهذه اللعبة حتى الآن. + تخطيط لوحة المفاتيح + لغة اللعبة + اللصق من الحافظة + التالي + إعادة المحاولة + تشغيل + تخطي + تحديث + إرسال + موافق + تراجع + تثبيت + إدارة + سماح + نشط + جاهز + جارٍ التحقق + أفضل مسار متاح + أمامك + انتظار + مؤقت الجلسة + مسح ذاكرة التخزين المؤقت + إعادة تعيين البرنامج التعليمي + إعادة تعيين الإعدادات + إعادة التعيين وإعادة التشغيل + تبديل + إضافة حساب + تسجيل الخروج + تسجيل الخروج من جميع الحسابات + اختيار المزوّد + إحصاءات وقت اللعب + التخزين السحابي + إضافة مساحة تخزين + تغيير موقع التخزين + لا يوجد بث نشط + العودة إلى المكتبة + إنهاء الجلسة السحابية + الخطوة %1$d من %2$d + اضغط على «تم» + تم اكتشاف وحدة تحكم + تم إخفاء وحدة التحكم على الشاشة لأن وحدة تحكم فعلية متصلة. + عدم العرض مرة أخرى + جاري بدء اقتران الهاتف… + الاقتران بتطبيق OpenNOW على الهاتف + ثبّت OpenNOW وافتحه على هاتف Android أولاً. صِل الهاتف والتلفزيون بشبكة Wi‑Fi نفسها، ثم امسح رمز QR بكاميرا الهاتف. تنتهي صلاحية الرابط بعد خمس دقائق. + إقران التلفزيون + الاقتران بتلفزيون + أبقِ الهاتف والتلفزيون على شبكة Wi‑Fi نفسها. امسح رمز QR الخاص بالتلفزيون هنا، أو ابحث عن التلفزيون وأدخل الرمز المكوّن من 4 أرقام. + تم الاتصال بـ %1$s. تعرض الألعاب الآن خيار اللعب على التلفزيون. + متصل بـ %1$s + تسجيل الدخول على التلفزيون + نسيان التلفزيون + امسح رمز QR أو ابحث عن تلفزيون على شبكتك + مسح QR التلفزيون + تعذر فتح ماسح QR + البحث عن تلفزيون + جارٍ البحث… + رمز التلفزيون + أدخل الرمز المكوّن من 4 أرقام الظاهر على هذا التلفزيون. + إقران + الحسابات والخدمات + الملفات الشخصية والعضوية والتخزين ومتاجر الألعاب + الألعاب الجديدة + النتائج + لافتة المكتبة المميزة + مظهر وحدة التحكم اللمسية + لون وحدة التحكم اللمسية + أحرف الأزرار + التصويب بالجيروسكوب + لم يتم العثور على تلفزيون OpenNOW على هذه الشبكة. + فتح ملف الحساب + اسم المستخدم + الفئة + البريد الإلكتروني + خيارات الحساب + %1$s • %2$s + غير متوفر + خيارات المطوّر + إعادة تعيين المسارات وفحص بيئة التشغيل وإعادة بناء الحالة المحلية + للتطوير والدعم + هذه الإجراءات تعيد تعيين الحالة المحلية الخاصة بـ OpenNOW فقط، وتعرض معلومات موجودة أصلًا في تصدير التشخيص. الإجراءات المدمّرة تطلب تأكيدًا أولًا. يمكنك إخفاء هذه الصفحة مرة أخرى من أسفل القائمة. + المسارات والمطالبات + الكتالوج والمتاجر + البث + الواجهة + التشخيص + مدمّر + إعادة تعيين + محو + تشغيل + تطبيق + نسخ + إعادة تشغيل + إخفاء + إعادة تشغيل أول إطلاق + الإعداد والأدلة والمطالبات والموافقة وحالة التصفح في وقت واحد + سيُعاد تشغيل الإعداد، وستعود كل المطالبات التي تظهر لمرة واحدة، وستُسحب الموافقة على التحليلات حتى تجيب عنها مرة أخرى، وسيُعاد ترتيب المتجر والمكتبة. الحسابات والمفضّلة وإعدادات البث لا تتأثر. + إعادة تشغيل الإعداد + يعرض شاشات أول إطلاق في التشغيل القادم + إظهار دليل البث مرة أخرى + يظهر مرة أخرى عند بدء البث القادم + إظهار مطالبة وحدة التحكم مرة أخرى + تظهر مرة أخرى عند توصيل وحدة تحكم لاحقًا + طلب الموافقة على التحليلات مرة أخرى + يبقى معطّلًا حتى تُجاب الأسئلة مرة أخرى + إعادة تشغيل ترحيلات الترقية + يعيد تطبيق إعدادات العرض وتخطيط التلفاز التي تُضبط لمرة واحدة + محو ذاكرة الألعاب المؤقتة + يحذف نتائج المتجر والمكتبة والبحث المخزّنة + إعادة جلب الكتالوج + يعيد تحميل المتجر والمكتبة من المزوّد الآن + إعادة تعيين حالة التصفح + يعيد ترتيب المتجر والمكتبة والمرشّحات إلى الإعدادات الافتراضية + نسيان اختيارات المُشغّل + كل اختيارات المتجر المحفوظة لكل لعبة + محو المفضّلة + %1$d محفوظة + ستُزال كل لعبة مُضافة إلى المفضّلة. لا يمكن التراجع عن ذلك. + إفراغ رفّ التطبيقات + %1$d تطبيقًا مثبّتًا مثبّتة على الرفّ + تطبيق التوصية المقيسة + إعادة تعيين تخطيط اللمس + حجم الطبقة وشفافيتها وكل إزاحات الأزرار + تحديث طوابير الخوادم + يعيد استعلام قائمة مناطق PrintedWaste وقيم الاستجابة + إعادة تعيين الواجهة + لون التمييز والخلفية وتخطيط البطاقات وإعدادات الحركة الافتراضية + نسخ سجل التشخيص + مُنقّى، وهو النص نفسه الذي يرفقه تقرير الخطأ + نسخ ملخّص بيئة التشغيل + الجدول أعلاه، كنص + البحث عن تحديث + يشغّل التحقق من التحديثات فورًا + تسجيل الخروج من كل الحسابات + %1$d محفوظة + سيُزال كل حساب محفوظ من هذا الجهاز وسيعود OpenNOW إلى شاشة تسجيل الدخول. + محو بيانات التطبيق وإعادة التشغيل + يعيد OpenNOW إلى حالة التثبيت الجديد + ستُحذف الحسابات والإعدادات والألعاب المخزّنة والملفات المحلية، وسيُعاد تشغيل OpenNOW كتثبيت جديد. لا يمكن التراجع عن ذلك. + إخفاء خيارات المطوّر + انقر على رقم الإصدار في «حول» عشر مرات لإعادتها + الإصدار + النسخة + الجهاز + Android + ملف التخطيط + العضوية + المزوّد + ملف البث + مفكّكات الترميز العتادية + ألعاب المتجر / المكتبة + لا شيء + تم تسجيل الخروج + Android TV + جهاز محمول + تم استعادة حالة أول إطلاق + سيُشغّل الإعداد في التشغيل القادم + سيُعرض دليل البث مرة أخرى + ستُعرض مطالبة وحدة التحكم مرة أخرى + ستُطلب الموافقة على التحليلات مرة أخرى + ستُعاد ترحيلات الترقية + تم إعادة تعيين حالة التصفح + تم نسيان اختيارات المُشغّل + تم محو المفضّلة + تم إفراغ رفّ التطبيقات + تم تطبيق التوصية المقيسة + تم إعادة تعيين تخطيط اللمس + تم إعادة تعيين الواجهة + تم نسخ سجل التشخيص + تم نسخ ملخّص بيئة التشغيل + تم إخفاء خيارات المطوّر + تبقّى %1$d نقرة لإظهار خيارات المطوّر + خيارات المطوّر متوفرة الآن في الإعدادات + خيارات المطوّر معروضة بالفعل + التالي + غير متوفر + الاهتزاز + اهتزاز وحدة التحكم عند توفره، وإلا اهتزاز الجهاز + مخرج الاهتزاز + بعض الأجهزة المحمولة تُبلّغ عن محرّك اهتزاز في وحدة التحكم المدمجة غير موصول بشيء. أجبر استخدام محرّك الهاتف إذا بقي الاهتزاز داخل اللعبة صامتًا. + تلقائي + وحدة التحكم + الهاتف + التصويب باللمس + تثبيت عصا التحكم + تثبيت المنطقة / التصويب باللمس + اسحب في أي مكان داخل المنطقة اليمنى للتصويب بحركة فأرة نسبية. + منطقة التصويب + مجتمع Discord + احصل على مساعدة وتابع تقارير الأخطاء مع مجتمع OpenNOW. + دعم المجتمع ومتابعة تقارير الأخطاء + انضم + تم نسخ دعوة Discord + الترتيب والتصفية + الترتيب والتصفية، %1$d نشطة + الترتيب + الأكثر شعبية + آخر ما لُعب + المرشّحات + التحكم + تحكم لمسي للهواتف + عدد البكسلات التي يرسلها الحاسوب السحابي. الدقات الأعلى تبدو أوضح لكنها تتطلب قدرة أكبر من مفكّك الترميز ووحدة الرسوميات والشبكة. + يطابق شكل البث مع الشاشة. النسبة غير المتوافقة قد تضيف أشرطة سوداء أو تمديدًا، ولا تجعل فكّ الترميز أسرع. + «موصى به» يستخدم شاشة هذا الجهاز وذاكرته وعدد معالجاته وملف Android ومفكّكات ترميز WebRTC العتادية المتحقّق منها. «مخصّص» يحفظ اختياراتك اليدوية. + التوصية المكتشفة: %1$s + عدد الإطارات في الثانية يحدّد سلاسة الحركة. الإطارات الأعلى تمنح مفكّك الترميز وقتًا أقل لكل إطار وقد تسبّب تقطيعًا على العتاد الأبطأ. + أقصى معدل بيانات للفيديو. معدل البت الأعلى قد يحسّن التفاصيل، لكن فقط عندما يتوفر للاتصال اتساع نطاق ثابت كافٍ؛ وهو لا يزيد عدد الإطارات. + H.264 هو الأكثر توافقًا. H.265 يستخدم اتساع النطاق بكفاءة أعلى ويُفضّل للدقات العالية عند وجود مفكّك ترميز عتادي متحقّق منه. AV1 هنا بعمق 8 بت ويُستخدم فقط على الأجهزة التي لديها مسار عتادي متوافق. + 8 بت 4:2:0 هو الأخف والأكثر توافقًا. 10 بت يحسّن التدرّجات لكنه يزيد متطلبات فكّ الترميز واتساع النطاق. يعالج Android التوليفات غير المدعومة تلقائيًا. + يحتاج HDR إلى شاشة Android TV متوافقة وH.265 وفيديو 10 بت وعضوية مدعومة. وهو يزيد حِمل المعالجة ولا يُنصح به عند تتبّع مشكلات التأخير. + إضافة تطبيقاتي + يعرض رفًّا في المكتبة يمكنك فيه إضافة وتشغيل وإزالة تطبيقات وألعاب Android المثبّتة. + جعله المُشغّل الافتراضي + يفتح أداة اختيار المُشغّل في Android حتى يصبح OpenNOW الشاشة الرئيسية. يمكنك تغيير ذلك مرة أخرى من إعدادات Android. + اختيار المُشغّل + OpenNOW هو الافتراضي + إدارة + تطبيقاتي + إضافة تطبيق + اختر تطبيقًا + جارٍ تحميل التطبيقات المثبّتة… + إظهار تطبيقاتي، %1$d مثبّتة + إخفاء تطبيقاتي، %1$d مثبّتة + لم يُعثر على تطبيقات أخرى قابلة للتشغيل. + إزالة %1$s + هذا يزيل الاختصار من رفّك فقط. يبقى التطبيق مثبّتًا. + إزالة + مميّز + كل مظهر هو وحدة تحكم مختلفة، لا مجرّد لون مختلف: شكل الأزرار، وما إذا كانت أزرار الاتجاهات صليبًا واحدًا أم أربعة مفاتيح منفصلة، والمساحة التي تتحرك فيها العصا، كلها تتغيّر معه. + يعيد تلوين المظاهر المبنية حول لون تمييز. «كلاسيكي» و«محدّد» و«صقيع» و«تباين عالٍ» أحادية اللون بحكم التصميم. + «إيقاف» يترك الأزرار خالية بعد أن يصبح التخطيط ذاكرة عضلية. + المظهر + حجم الأزرار الأمامية + حجم أزرار الاتجاهات + حجم الزنادات والأزرار الجانبية + حجم القائمة ونقر العصا + حجم العصا اليسرى + حجم العصا اليمنى + حجم رأس العصا + أمِل الهاتف للتصويب بحركة فأرة نسبية. + هذا الجهاز لا يُبلّغ عن وجود جيروسكوب. + حساسية الجيروسكوب + المنطقة الميتة للجيروسكوب + تنعيم الجيروسكوب + عكس الجيروسكوب أفقيًا + عكس الجيروسكوب رأسيًا + التصويب بالحركة + لافتة متغيّرة أعلى شبكة المكتبة على الهواتف في الوضع الرأسي. + هذا التطبيق لم يعد مثبّتًا أو لا يمكن فتحه. + ترتيب المكتبة + آخر ما لُعب + العنوان أ–ي + إطارات تحديد متحركة + يحرّك الألعاب المحدّدة وعناصر القوائم واختيارات الخوادم وخيارات المُشغّل. أوقفه لتحديد أهدأ. + مؤثرات Absolute Cinema + يستخدم حلقات تركيز متحركة برتقالية وزرقاء مع الحفاظ على لون الواجهة الذي اخترته. + أنا مجنون + أطلق Absolute Cinema على الواجهة بأكملها. الصور الفنية عند التمرير والتركيز والأوصاف وعناصر التحكم وغيرها تحصل على المؤثر. + إظهار رمز المفضّلة على بطاقات الألعاب + يعرض زر المفضّلة على بطاقات الألعاب في الهاتف والجهاز المحمول والتلفاز. + مفعّل افتراضيًا. يملأ الشاشة بدلًا من ترك أشرطة سوداء، بتمديد الصورة على المحور غير المتوافق فقط — دون قصّها أبدًا. أوقفه للحصول على أبعاد دقيقة. + يطبّق مرشّح وحدة رسوميات إضافيًا بعد فكّ الترميز. قد يحسّن التفاصيل المُدركة لكنه يزيد حِمل العرض على الأجهزة الأبطأ. + يتحكم في قوة مرشّح الحدّة في المعالجة اللاحقة. وهو لا يغيّر دقة البث الأصلي. + Absolute Cinema + Switch + لنبدأ + التالي + رجوع + تخطٍّ + إنهاء + GeForce NOW أصلي على Android + اجعله على ذوقك + كل ما هنا يُطبّق أثناء اختيارك. + لون التمييز + حركات الواجهة + اللمعان وتوهّج التركيز وحركة الشريط الدوّار. إيقافها يحترم أيضًا إعداد الحركة في النظام. + معاينة + التخطيط + كل خيار يعيد رسم المعاينة + أسماء الألعاب أسفل الصور + «إيقاف» يترك الشبكة صورًا فنية خالصة. + بطاقات مربّعة + يقصّ صورة الغلاف إلى مربّع ليظهر عدد أكبر من الألعاب على الشاشة. + زر المفضّلة على الصورة + يحفظ لعبة في مكتبتك دون فتحها. + زوايا دائرية + حواف أنعم للبطاقات واللوحات في كل أنحاء التطبيق. + Absolute Cinema + إطارات طاقة متحركة حول العنصر المُركّز عليه. عادةً ما تكون معالجة لوحدة التحكم والتلفاز. + الاستجابة + كلاهما يعمل عند نقرتك التالية + الاهتزاز + اهتزازة قصيرة عند اختيار عنصر، واهتزاز وحدة التحكم داخل اللعبة حيث يدعمه الجهاز. + أصوات الواجهة + نغمة عند الضغط على الأزرار والتنقل في القوائم. لا تؤثر في صوت اللعبة. + الخلفية + إيقاف + افتراضية + بلا خلفية + خلفية التطبيق + صورة الخلفية + صورتك + جودة البث + مقيسة من شاشة هذا الجهاز ومعالجه ومفكّكات الترميز فيه. + جارٍ قياس هذا الجهاز + موصى بها + توفير البيانات + 720p، 30 إطارًا/ث، 12 ميغابت/ث + أفضل جودة + حتى %1$s بمعدل %2$d إطارًا/ث في باقتك + سأضبطها بنفسي + اختر الدقة ومعدل الإطارات ومعدل البت أدناه + عضوية %1$s + تبثّ باقتك حتى %1$s بمعدل %2$d إطارًا/ث. الخيارات الأعلى مُدرجة مع الفئة التي تفتحها. + ترقية عضوية GeForce NOW ترفع هذا الحد — وOpenNOW لا يقيّده. + أثناء اللعب + اختر إحساس البث. + معاينة + فأرة باللمس + مباشر + انقر حيث تريد النقر + لوحة لمس + اسحب للتحريك ثم انقر + إيقاف + استخدم وحدة تحكم أو فأرة فعلية + نقرة = تحريك + نقر + اسحب ثم انقر + وحدة تحكم / فأرة + العب + شريط الحالة + الإطارات والاستجابة والبطارية والاتصال بلمحة واحدة. + الموضع + 60 إطارًا/ث • 24 مللي ثانية • Wi-Fi + عندما يتعطّل شيء + تقطيع، شاشات سوداء، وحدات تحكم لا تستجيب. + مُبلِّغ الأخطاء المدمج + افتحه من أدوات التحكم أثناء البث، أو من التقرير المعروض بعد انتهاء الجلسة. + يقارن إعداداتك بالجلسة أولًا ويشير إلى التوليفات المعروفة بالمشاكل، عادةً مع حلّ. + يرسل وصفك بالإضافة إلى تشخيص مُنقّى — الإعدادات وقياسات مفكّك الترميز والشبكة وطراز الجهاز. دون أي بيانات حساب. + تقرير جلسة بعد كل بث + التأخير وانتظام الإطارات وفقد الحزم عند انتهاء الجلسة، مع اختصار إلى مُبلِّغ الأخطاء. + مشاركة تشخيص مجهول المصدر + يساعد على اكتشاف أنماط بين الأعطال ومشكلات الأداء. تُزال البيانات الحساسة ولا يُباع شيء. مع إيقافه، قد لا يحمل تقرير العُطل ما يكفي للتحقيق. + تم + يمكنك تغيير أي من ذلك من الإعدادات. + اختياراتك + جودة البث + فأرة باللمس + شريط الحالة + مفعّل + موقوف + الإعداد + إعادة تشغيل الإعداد + راجع المظهر وجودة البث وأدوات التحكم أثناء اللعب وشريط الحالة والإبلاغ عن الأخطاء + يتطلب %1$s + يُدرِج GeForce NOW لعبة %1$s ضمن %2$s فقط، وهذا الحساب على %3$s. الأرجح أن تُرفض الجلسة أو تُخفَّض إلى ملف أدنى. + أحقّية اللعب يقرّرها GeForce NOW لا OpenNOW، والكتالوج قد يكون قديمًا أحيانًا — لذا لا يزال بإمكانك المحاولة. + المحاولة على أي حال + نسخ الخطأ + قطع الاتصال + رجوع + تسجيل الدخول + مشاركة التحليلات + شارك تشخيصًا مجهول المصدر لمساعدتنا في اكتشاف أنماط الأخطاء والأعطال ومشكلات الأداء. تُزال البيانات الحساسة، ولا نبيع بياناتك. + إذا كانت المشاركة موقوفة أثناء عُطل، فقد لا تتوفر لدينا معلومات كافية للتحقيق في تقريرك. وهي موقوفة افتراضيًا ويمكن تغييرها من إعدادات الخصوصية. + إبقاؤها موقوفة + مشاركة التشخيص؟ + جارٍ التحقق من أحدث إصدار… + جارٍ التحقق من Google Play… + جارٍ فحص هذه الجلسة… + الفحوصات + يُرفق تلقائيًا السجل نفسه المزوّد بطوابع زمنية والمتاح من الإعدادات > متقدّم > سجلات التصحيح. ولا تُضاف ملفات أخرى. + بياناتك لا تُباع وتُستخدم فقط للتحقيق في الأخطاء وإصلاحها. + يزيل السجل التلقائي أسماء الحسابات وبيانات الاعتماد ومعرّفات الجلسات وعناوين الشبكة قبل الرفع. ولا يُرسل معرّف الجهاز الخام. + ما الذي يُجمع؟ + يُرسل العنوان والوصف اللذان تكتبهما كما هما تمامًا، لذا لا تُدرج معلومات شخصية أو حساسة. + يمكن للقائمين على PrintedWaste وOpenNOW الاطلاع على نص التقرير وإصدار التطبيق وطراز الجهاز وإصدار Android والمزوّد وفئة العضوية واللعبة الحالية وحالة البث وإعداداته ومعرّف تثبيت مستعار لمنع إساءة الاستخدام وسجل تشخيص مُنقّى. + هل تريد إرسال هذا التقرير والتشخيص المُنقّى المرفق إلى واجهة PrintedWaste؟ + إرسال تقرير الخطأ؟ + أوافق على إرسال هذا التقرير. + أفهم ما سيُرفع وأوافق على إرساله إلى واجهة PrintedWaste. + صِف الخطأ بالإنجليزية. تشخيص الجلسة مرفق. + ماذا حدث؟ + ماذا كنت تفعل، وما الذي حدث خطأً، وهل يمكنك تكراره؟ + الإنجليزية مطلوبة + اضبط OpenNOW أو لغة الجهاز على الإنجليزية قبل الإبلاغ. + صِف المشكلة دون مغادرة لعبتك. + أفهم أن OpenNOW وجد سببًا محتملًا. أرسِل على أي حال؛ قد أفقد إمكانية الإبلاغ مستقبلًا. + الاقتراحات المطابقة + لا تُقترح حلول غير ذات صلة لهذا الفحص. + فحوصات مباشرة من هذا الجهاز وهذه الجلسة + قبل أن تُبلّغ + إعادة محاولة التحقق من الإصدار + مراجعة وإرسال + إرسال تقرير آخر + إرسال على أي حال + جارٍ الإرسال… + تم إرسال تقرير الخطأ + ما زال يحدث بعد أي اقتراح مطابق؟ تابِع وستُرفق الأدلة المقيسة تلقائيًا. + عنوان المشكلة + تجمّد البث بعد إعادة الاتصال + التحديث من Google Play + رفع التقرير + رفع تقرير الخطأ؟ + جارٍ رفع التقرير… + استخدام الإنجليزية في OpenNOW + جارٍ فحص طوابير PrintedWaste والتأخير + الوصف + المرشّحات + توجيه طابور الفئة المجانية + لقطات الشاشة + زر الرجوع في جهاز التحكم + التفاصيل + تم نسخ الجهاز ونوع الحساب وملف البث والحالة الراهنة والرابط المؤقت إلى الحافظة. + تم نسخ التشخيص + سيزيل OpenNOW الرموز ومعرّفات الحسابات وعناوين البريد الإلكتروني ومعرّفات الجلسات وعناوين الشبكة قبل الرفع. + الرابط العشوائي غير مُدرج لكنه غير مشفّر، وتحذف الخدمة الملفات المرفوعة خلال 24 ساعة. + إنشاء رابط تشخيص مؤقت؟ + جارٍ إزالة القيم الحساسة وإنشاء رابط مؤقت… + جارٍ تحضير التشخيص + تعذّر إنشاء رمز QR. أغلق هذه النافذة وحاول مرة أخرى. + امسح رمز QR هذا بهاتفك. ينتهي الرابط المُنقّى خلال 24 ساعة. + مسح رابط التشخيص + تنقية ورفع + لا يتوفر متصفّح + تعذّر فتح صفحة المتجر + تعذّر بدء الاتصال بالمتجر + تعذّر قطع الاتصال بالمتجر + رمز الوصول + تعذّر تصدير السجلات + تم تصدير السجلات + رمز الاقتران + عميل GeForce NOW أصلي على Android + الصق رمز وصول NVIDIA أو استجابة الرمز بصيغة JSON. يتحقق OpenNOW من رمز الوصول قبل حفظ الحساب. + تسجيل الدخول برمز + استخدم فقط بيانات اعتماد حساب تملكه. + سجّل الدخول برمز دون متصفّح، أو صدّر التشخيص قبل تسجيل الدخول. + أدوات تسجيل الدخول + استخدام تسجيل الدخول بالرمز + إعلان + الترتيب المباشر + الطابور + رجوع + أبلِغ عنه + واجهت خطأً؟ + الملف المُقدَّم + كانت هذه جلسة قصيرة، لذا قد تتفاوت النتيجة أكثر من المعتاد. + تقرير الجلسة + ما الذي يمكن فعله بعد ذلك + سبب تغيّر الملف + هذه الإعدادات أعلى من التوصية المكتشفة + النشاط في الخلفية + مُحسَّن (قد تنتهي المهلة في الخلفية) + بلا حدود (مسموح في الخلفية) + يقيّد تحسين البطارية في Android نشاط التطبيق في الخلفية، ما قد يسبّب انتهاء مهلة الاتصال أو إيقاف التقدّم في طابور GFN عند تصغير التطبيق. + ستُزال نتائج المتجر والمكتبة والبحث المخزّنة. ويبقى حسابك وإعداداتك دون تغيير. + محو ذاكرة الألعاب المؤقتة؟ + مساعدة الاتصال + يصدّر حالة الإطلاق وحالة الطابور وتحديثات البث وأحداث الاستعادة والإعدادات وقدرات الترميز واستجابات CloudMatch بصيغة JSON المُنقّاة الأخيرة. + المطوّر + لا توجد إضافة تخزين دائم مفعّلة لهذا الحساب. + ملاحظات الإصدار + ستُزال الحسابات والإعدادات والألعاب المخزّنة وحالة الدليل التعليمي وملفات التطبيق المحلية. وسيُعاد تشغيل OpenNOW كتثبيت جديد. + «إعادة تعيين الدليل التعليمي» تجعل دليل البث يظهر مجددًا فقط. أما «إعادة تعيين الإعدادات» فمدمّرة: تمحو بيانات التطبيق المحلية وتعيد تشغيل OpenNOW. + إعادة تعيين الإعدادات وبيانات التطبيق؟ + اختر مزوّد GeForce NOW الذي سيُستخدم للحساب الجديد. + استخدام التخزين + اتصالات متاجر الألعاب + تُزال القيم الحساسة قبل إنشاء رابط مؤقت غير مُدرج. امسح رمز QR بهاتفك لمشاركته. + رفع السجلات وعرض رمز QR + يرسل هذا ملخّص تغيّر الملف والسبب المرجّح إلى القائمين على PrintedWaste وOpenNOW ليتمكّنوا من التحقيق فيه. + إرسال تشخيص البث؟ + إرسال التشخيص + لا يوجد بث محلي مرتبط بـ OpenNOW في الوقت الحالي. + تغيّر ملف البث + لماذا حدث ذلك + جارٍ إرسال التقرير والتشخيص المُنقّى… + لم تتغيّر إعدادات البث المحفوظة لديك. + جلسة سحابية نشطة بالفعل + إنهاؤها وبدء جلسة جديدة + اكتب نص البث أو حرّره + كانت ذاكرة الألعاب المؤقتة فارغة أصلًا + تم محو ذاكرة الألعاب المؤقتة + جارٍ محو بيانات التطبيق وإعادة تشغيل OpenNOW + تم تسجيل الدخول بأمان من الهاتف + تم قطع الاتصال بالمتجر + سيظهر الدليل التعليمي في البث التالي + سحب + + + %1$d خادم + %1$d خادم + %1$d خادمان + %1$d خوادم + %1$d خادمًا + %1$d خادم + + diff --git a/android/app/src/main/res/values-b+zh+Hans/strings.xml b/android/app/src/main/res/values-b+zh+Hans/strings.xml new file mode 100644 index 000000000..c687eb916 --- /dev/null +++ b/android/app/src/main/res/values-b+zh+Hans/strings.xml @@ -0,0 +1,835 @@ + + + OpenNOW + 正在启动 OpenNOW + 使用 %1$s 登录 + 在另一台设备上使用 %1$s 登录 + 使用此代码登录 + %1$s + 正在等待登录 + 代码将在 %1$d:%2$02d 后过期 + 商店 + 搜索 + 游戏库 + 设置 + 搜索游戏 + 搜索设置 + 常规 + 更新、隐私和应用数据 + 语言 + 应用语言 + 系统默认 + 英语 + 串流 + 分辨率、FPS、编解码器、HDR、代理 + 输入 + 麦克风、鼠标、键盘、触控和振动 + 界面 + 外观、游戏库、状态栏和声音 + 账号 + 登录、存储和已连接的商店 + 高级 + 高级选项、实验功能、诊断和日志 + 关于 + 版本、致谢和支持 + 显示游戏标题 + 清除搜索 + 语音搜索 + %1$d 款游戏 + 未加载游戏 + 游戏库中没有匹配的游戏 + 清除搜索以显示游戏库中的所有游戏。 + 清除筛选条件以显示游戏库中的所有游戏。 + 清除搜索或筛选条件以显示游戏库中的所有游戏。 + 商店中没有匹配的游戏 + 清除搜索以显示更多游戏。 + 清除筛选条件以显示更多游戏。 + 清除搜索或筛选条件以显示更多游戏。 + 继续畅玩 + 即将推出 + GeForce NOW 新上架游戏 + 继续游戏 + 排队中 + 收藏 + 推荐 + 查看全部 + 开始游戏 + 继续 + 恢复 + 保存 + 已保存 + 添加收藏 + 取消收藏 + 取消 + + + 显示 + 隐藏 + 返回 + 打开 + 重置 + 关闭 + 串流控制 + 退出 + 完成 + 打开键盘输入 + 显示 + 输入 + 支持 + 控制器 + 触控布局 + 音频 + 已静音 + 状态栏 + %1$s · %2$d 项 + 串流锐化 + 锐化程度 + 拉伸以适应画面 + 实时 + 本次会话正在使用 %1$d Mbps + 设置 › 串流仅对下次会话生效 + 麦克风 + 需要权限 + Steam 菜单 + 向远程电脑发送主页键 + Esc + Enter + + 控制器鼠标 + 右摇杆 · A 单击 · B 右键单击 + 手指鼠标 + 直接点击 + 触控控制器 + 检测到游戏内置触控 + 此游戏支持内置触控。如果愿意,你仍可在下方开启 OpenNOW 触控控制器。 + 此游戏支持内置触控。本次会话已启用 OpenNOW 触控控制器。 + 内置触控已启用 + 摇杆 + 固定 + 动态 + 使用手机振动替代 + 控制器鼠标模式 + 左摇杆移动 · 右摇杆滚动 · A 单击 · B 右键单击 + 鼠标模式 + 配置鼠标模拟 + 触控操作 + 控制器布局、摇杆和振动 + 报告问题 + 运行检查并发送已脱敏的诊断信息 + 拖动编辑模式 + 重置触控布局 + 将位置恢复为默认值 + 布局缩放 + 按钮大小 + 不透明度 + 边缘间距 + 底部间距 + 左侧位置 + 右侧位置 + 摇杆 + 调整触控模拟摇杆 + 动态放置 + 以拇指下方为中心开始 + 使用已保存的固定中心 + 摇杆大小 + 死区 + 动态模式会保留已保存的摇杆区域,但将拇指首次落下的位置视为中心。这样可避免未准确触及中心时突然移动。 + 状态栏 + 选择布局和显示信息 + 外观 + 位置 + 项目 + FPS + Ping + 比特率 + 电量 + 连接 + 分辨率 + 编解码器 + 服务器 + 解码 / 抖动 + 丢包 + 键盘 + %1$d/100 + %1$s + 不再显示会话报告 + 连接 + 未测量 + 延迟 + 串流速度 + 数据包丢失 + 抖动 + 帧率 + 解码 + 平均 %1$d ms + 峰值 %1$d ms + 峰值 %1$s + 稳定 + 可能影响清晰度 + 时序变化 + 平均 / 目标 FPS + 每个视频帧 + 会话控制 + 退出串流? + 确定要退出 %1$s 吗? + 当前云游戏会话将被关闭。 + 继续游戏 + 退出串流 + 错误报告 + 报告错误 + 报告串流错误 + 发送问题和已脱敏的诊断信息 + 显示说明 + 隐藏说明 + Ping %1$s + 解码 %1$s ms + 抖动 %1$s ms + 丢包 %1$s%% + 每秒 %1$d 帧 + Ping %1$d 毫秒 + 每帧解码时间 %1$s 毫秒 + 抖动 %1$s 毫秒 + 数据包丢失百分之 %1$s + 良好 + 一般 + 较差 + 关闭 + 在 %1$s 上游玩 + 清除筛选条件 + 返回顶部 + 自动 + 即将推出 + 选择启动平台 + 启动平台 + 默认 + 已选择 + 可用的启动平台 + 不再询问,并将此商店设为默认 + 使用默认商店继续:%1$s + 提示:长按“开始游戏”可稍后选择其他商店。 + 长按“开始游戏”以选择商店 + 串流 + 界面 + 质量 + 视频 + 连接 + 音频和键盘 + 指针输入 + 鼠标锁定 + 串流时将外接鼠标限制在游戏内。打开串流控制时会解除锁定。 + 控制器和触控 + 外观 + 游戏库和导航 + 状态栏 + 声音和会话 + 显示会话报告 + 每次串流结束后显示质量摘要。 + 高级工具 + 致谢 + 分辨率 + 宽高比 + 串流预设 + 推荐 + 自定义 + 低(节省流量) + + + FPS + 比特率 Mbps + 编解码器 + 颜色 + 仅限 H.264/H.265 + AV1 使用 8 位色彩。若要使用 10 位,请选择 H.265;HDR 仅限兼容的 Android TV 模式。 + OpenNOW 中的 AV1 使用 8 位色彩。已切换为 8 位并关闭 HDR。若要使用 10 位,请选择 H.265 或 H.264。 + HDR(Performance & Ultimate) + Android 手持设备不支持 HDR 串流。使用 H.265 时仍可使用 10 位 SDR。 + Android TV 上的 HDR 需要使用 60 FPS 或更低帧率的 H.265,且分辨率不高于 3840 × 2160。 + 区域 + 会话代理 + 通过此代理创建 GFN 会话并查询队列。直接请求时请保持关闭。 + 代理 URL + 复制编解码器诊断 + 已复制编解码器诊断 + 尚未运行编解码器检测。 + 启用会话代理? + GFN 会话创建、队列查询、恢复、停止和队列广告更新请求都将通过你输入的代理。 + 错误或受阻的代理可能导致启动、队列进度、活动会话恢复或会话清理失败。 + 仅使用你信任的代理。代理运营者可能看到请求时间、目标主机和敏感的会话流量元数据。 + 启用代理 + 实验性串流 + 可能导致会话启动失败。 + L4S + 当服务器和网络支持时,请求 NVIDIA 的低延迟、低丢包传输路径。如果网络变得不稳定,请保持关闭。 + Cloud G-Sync / VRR 请求 + 当你的设备、显示器、方案和 GFN 会话支持时,请求云会话使用可变刷新时序。 + 麦克风 + 将 Android 默认麦克风发送到远程游戏。你可以在串流控制中将其静音。 + 未授予麦克风权限。OpenNOW 将保持麦克风串流关闭。 + 使用系统颜色 + 强调色 + 启动页面 + 商店 + 游戏库 + 关闭更新检查 + 高级选项 + 显示实验性目录和调节选项。高级诊断标签页仍然可用。 + 富有表现力的卡片样式 + 使用更明亮的卡片表面和更圆润的边角。关闭后可使用更平坦、低调的 Material 样式。 + 目录背景 + 在手持设备屏幕的商店和游戏库后方显示背景图。 + 背景图 + 自定义图片 + 内置背景 + 彩色抽象(默认) + 原版 OpenNOW + Absolute Cinema + 选择图片 + 使用默认值 + 屏幕边缘间距 + 紧凑游戏卡片 + 显示商店标签 + 游戏卡片大小 + 隐藏串流按钮 + 屏幕键盘按钮 + 在串流状态栏中显示紧凑的键盘图标。 + 默认显示状态栏 + 统计信息浮层位置 + 隐藏服务器选择器 + 按钮音效 + 使用控制器导航和按下屏幕控件时播放短促的界面音效。 + 播放开场音乐 + 开场音乐启动状态 + 静音 + 播放 + 队列结束时播放音乐 + 音乐静音 + 拉伸串流以填满屏幕 + 智能会话计时器 + 感谢所有帮助 OpenNOW 为大家变得更好的人。 + DarkevilPT + 社区支持 + 捐赠 + 已复制捐赠链接 + OpenNOW + Pixel 蓝 + 亮粉色 + 青柠色 + 珊瑚色 + 紫罗兰色 + 原生串流器(实验性) + 拦截硬件解码器并注入厂商的低延迟属性。可能不稳定。 + 原生触控的“自动”模式会在高分辨率或高 FPS 串流中使用手柄模式,以保留所选串流模式。若要优先使用原生触控,请选择“所有游戏”。 + 最小化 + 查看 + 在电视上游玩 + 正在启动串流 + 队列位置 %1$d + 正在等待游戏设备 + 正在连接串流 + 正在恢复会话 + 正在设置游戏设备 + 正在启动会话 + 队列状态 + %1$s 已可开始游玩! + 你的 GFN 排队已结束。点按即可返回应用。 + 屏幕关闭时串流仍会继续 + 未拥有 + 未知发行商 + 恢复云会话 + 应用 %1$s + 队列 %1$d + 正在启动 + 此游戏尚无可用说明。 + 键盘布局 + 游戏语言 + 从剪贴板粘贴 + 下一步 + 重试 + 启动 + 跳过 + 刷新 + 发送 + 确定 + 撤销 + 安装 + 管理 + 允许 + 活动 + 已就绪 + 正在检查 + 最佳可用路线 + 前方 + 等待 + 会话计时器 + 清除缓存 + 重置教程 + 重置设置 + 重置并重新启动 + 切换 + 添加账号 + 退出登录 + 退出所有账号 + 选择服务提供商 + 游戏时长统计 + 云存储 + 添加存储空间 + 更改存储位置 + 当前没有活动串流 + 返回游戏库 + 结束云会话 + 第 %1$d 步,共 %2$d 步 + 按“完成” + 检测到控制器 + 因已连接实体控制器,屏幕控制器已隐藏。 + 不再显示 + 正在启动手机配对… + 与 OpenNOW 手机应用配对 + 请先在 Android 手机上安装并打开 OpenNOW。将手机和电视连接到同一 Wi‑Fi,然后用手机摄像头扫描此二维码。链接将在五分钟后失效。 + 电视配对 + 与电视配对 + 请让手机和电视连接到同一 Wi‑Fi。在此扫描电视上的二维码,或查找电视并输入其 4 位代码。 + 已连接到 %1$s。游戏现在会显示“在电视上玩”操作。 + 已连接到 %1$s + 登录电视 + 忘记电视 + 扫描二维码或查找网络中的电视 + 扫描电视二维码 + 无法打开二维码扫描器 + 查找电视 + 正在查找… + 电视代码 + 输入此电视上显示的 4 位代码。 + 配对 + 账号和服务 + 个人资料、会员、存储空间和游戏商店 + 新游戏 + 结果 + 游戏库精选横幅 + 触控手柄皮肤 + 触控手柄颜色 + 按钮字母 + 陀螺仪瞄准 + 在此网络中未找到 OpenNOW 电视。 + 打开账号资料 + 用户名 + 等级 + 电子邮件 + 账号选项 + %1$s • %2$s + 不可用 + 开发者选项 + 重置流程、查看运行环境并重建本地状态 + 用于开发与支持 + 这些操作只会重置 OpenNOW 自身的本地状态,并显示诊断导出中已有的信息。具有破坏性的操作会先询问。你可以在列表底部重新隐藏此页面。 + 流程与提示 + 目录与商店 + 串流 + 界面 + 诊断 + 破坏性 + 重置 + 清除 + 运行 + 应用 + 复制 + 重放 + 隐藏 + 重放首次启动 + 一次性重置设置向导、指南、提示、同意与浏览状态 + 设置向导会重新运行,所有一次性提示都会再次出现,分析同意会被撤回直到你再次作答,商店与库的排序也会重置。账号、收藏和串流设置不受影响。 + 重新运行设置向导 + 下次启动时显示首次启动界面 + 重新显示串流指南 + 下次开始串流时会再次出现 + 重新显示手柄提示 + 下次连接手柄时会再次出现 + 再次询问分析同意 + 在你再次作答之前保持关闭 + 重放升级迁移 + 重新应用一次性的呈现方式与电视布局默认值 + 清除游戏缓存 + 删除已缓存的商店、库和搜索结果 + 重新获取目录 + 立即从提供方重新加载商店与库 + 重置浏览状态 + 商店与库的排序和筛选恢复默认 + 忘记启动器选择 + 每款游戏已记住的商店选择 + 清除收藏 + 已保存 %1$d 项 + 所有已收藏的游戏都会被移除。此操作无法撤销。 + 清空应用架 + 已固定 %1$d 个已安装应用 + 应用实测推荐设置 + 重置触控布局 + 叠加层大小、不透明度和所有按键偏移 + 刷新服务器队列 + 重新查询 PrintedWaste 区域列表和延迟 + 重置界面 + 强调色、背景、卡片布局和动画默认值 + 复制诊断日志 + 已脱敏,与错误报告附带的文本相同 + 复制运行环境摘要 + 上方表格的文本形式 + 检查更新 + 立即执行更新检查 + 退出所有账号 + 已保存 %1$d 个 + 所有已保存的账号都会从本设备移除,OpenNOW 将返回登录界面。 + 清除应用数据并重启 + 将 OpenNOW 恢复到全新安装状态 + 账号、设置、缓存的游戏和本地文件都会被删除,OpenNOW 会像全新安装一样重启。此操作无法撤销。 + 隐藏开发者选项 + 在「关于」中连点十次版本号即可重新显示 + 构建 + 变体 + 设备 + Android + 布局配置 + 会员 + 提供方 + 串流配置 + 硬件解码器 + 商店 / 库游戏 + + 已退出 + Android TV + 掌机 + 已恢复首次启动状态 + 设置向导将在下次启动时运行 + 串流指南将再次显示 + 手柄提示将再次显示 + 将再次询问分析同意 + 升级迁移将会重放 + 已重置浏览状态 + 已忘记启动器选择 + 已清除收藏 + 已清空应用架 + 已应用实测推荐设置 + 已重置触控布局 + 已重置界面 + 已复制诊断日志 + 已复制运行环境摘要 + 已隐藏开发者选项 + 再点按 %1$d 次即可显示开发者选项 + 开发者选项现已出现在设置中 + 开发者选项已经显示 + 下一步 + 不可用 + 振动 + 可用时使用手柄振动,否则使用设备触感反馈 + 振动输出 + 某些掌机会报告内置手柄上有一个并未接线的振动马达。如果游戏内振动始终无声,请强制使用手机马达。 + 自动 + 手柄 + 手机 + 触控瞄准 + 锁定摇杆 + 锁定区域 / 触控瞄准 + 在右侧区域内任意位置拖动即可进行相对鼠标视角瞄准。 + 瞄准区域 + Discord 社区 + 在 OpenNOW 社区获取帮助并跟进错误报告。 + 社区支持与错误报告跟进 + 加入 + 已复制 Discord 邀请链接 + 排序与筛选 + 排序与筛选,%1$d 项生效 + 排序 + 热门 + 最近游玩 + 筛选 + 操作方式 + 移动端触控操作 + 云端电脑发送的像素数量。分辨率越高画面越清晰,但对解码器、GPU 和网络的要求也越高。 + 让串流画面的形状匹配屏幕。比例不匹配可能产生黑边或拉伸,但不会让解码变快。 + 「推荐」会依据本设备的屏幕、内存、处理器核心数、Android 配置和已验证的 WebRTC 硬件解码器。「自定义」则保留你手动设定的选项。 + 检测到的推荐设置:%1$s + 每秒帧数决定画面的流畅度。帧率越高,解码器处理每一帧的时间越短,在较慢的硬件上可能出现卡顿。 + 视频的最大数据速率。更高的码率可以提升细节,但前提是网络有足够且稳定的带宽;它不会提高帧率。 + H.264 兼容性最好。H.265 对带宽的利用更高效,在有已验证硬件解码器时更适合高分辨率。AV1 在此为 8 位,仅在具备兼容硬件通路的设备上使用。 + 8 位 4:2:0 最轻量、兼容性最好。10 位能改善渐变,但会提高解码器和带宽要求。Android 会自动调整不受支持的组合。 + HDR 需要兼容的 Android TV 屏幕、H.265、10 位视频以及受支持的会员。它会增加处理负担,排查延迟问题时不建议开启。 + 添加我自己的应用 + 在库中显示一个货架,可添加、启动和移除已安装的 Android 应用和游戏。 + 设为默认启动器 + 打开 Android 的启动器选择界面,让 OpenNOW 可以成为主屏幕。你可以随时在 Android 设置中改回来。 + 选择启动器 + OpenNOW 是默认启动器 + 管理 + 我自己的应用 + 添加应用 + 选择一个应用 + 正在加载已安装的应用… + 显示我的应用,已安装 %1$d 个 + 隐藏我的应用,已安装 %1$d 个 + 没有找到其他可启动的应用。 + 移除 %1$s + 这只会从你的货架上移除快捷方式,应用仍保持安装状态。 + 移除 + 精选 + 每种皮肤都是一套不同的手柄,而不只是换个颜色:按键的形状、方向键是一整个十字还是四个独立按键,以及摇杆活动的范围,都会随之变化。 + 为围绕强调色设计的皮肤重新着色。经典、描边、霜白和高对比度在设计上就是单色的。 + 当布局已成肌肉记忆后,选择「关闭」可让按键留白。 + 皮肤 + 功能键大小 + 方向键大小 + 扳机键与肩键大小 + 菜单与摇杆按下大小 + 左摇杆大小 + 右摇杆大小 + 摇杆帽大小 + 倾斜手机即可进行相对鼠标视角瞄准。 + 此设备未报告陀螺仪。 + 陀螺仪灵敏度 + 陀螺仪死区 + 陀螺仪平滑 + 反转陀螺仪水平方向 + 反转陀螺仪垂直方向 + 体感瞄准 + 竖屏手机上显示在库网格上方的轮播横幅。 + 此应用已不再安装或无法打开。 + 库排序 + 最近游玩 + 名称 A–Z + 选中描边动效 + 为选中的游戏、菜单项、服务器选项和启动器选项添加动画。想要更安静的选中样式可以关闭。 + Absolute Cinema 特效 + 在保留你所选界面颜色的同时,使用橙色与蓝色的动态聚焦光环。 + 我疯了 + 把 Absolute Cinema 放到整个界面上。悬停和聚焦的封面、描述、控件等都会获得该特效。 + 在游戏卡片上显示收藏图标 + 在手机、掌机和电视的游戏卡片上显示收藏按钮。 + 默认开启。填满屏幕而不是留下黑边,只在不匹配的轴向上拉伸画面,绝不裁切。想要精确几何比例请关闭。 + 在解码后再施加一层 GPU 滤镜。它能提升观感细节,但在较慢的设备上会增加渲染负担。 + 控制后处理锐化滤镜的强度。它不会改变源串流的分辨率。 + Absolute Cinema + Switch + 开始 + 下一步 + 返回 + 跳过 + 完成 + Android 原生 GeForce NOW + 打造你的风格 + 这里的每项选择都会立即生效。 + 强调色 + 界面动画 + 微光、聚焦辉光和轮播动效。关闭后也会遵循系统的动画设置。 + 预览 + 布局 + 每一项都会重绘预览 + 在封面下方显示游戏名称 + 关闭后,网格中只保留纯封面图。 + 方形卡片 + 把封面裁成方形,让屏幕上能放下更多游戏。 + 封面上的收藏按钮 + 无需打开游戏即可将其保存到库中。 + 圆角 + 整个应用中卡片和面板的边缘更柔和。 + Absolute Cinema + 在聚焦元素周围显示动态能量边框。通常用于手柄和电视场景。 + 反馈 + 两者都会在你下次点按时触发 + 触感反馈 + 选中项目时的短促振动,以及在设备支持时游戏内的手柄振动。 + 界面音效 + 按下按钮和浏览菜单时的提示音。不影响游戏音频。 + 背景 + 关闭 + 默认 + + 应用背景 + 壁纸 + 你的图片 + 串流画质 + 根据本设备的屏幕、芯片和解码器实测得出。 + 正在测量此设备 + 推荐 + 省流量 + 720p、30 FPS、12 Mbps + 最佳画质 + 你的套餐最高可达 %1$s、%2$d FPS + 我自己设置 + 在下方选择分辨率、帧率和码率 + %1$s 会员 + 你的套餐最高可串流 %1$s、%2$d FPS。更高的选项会标注解锁所需的等级。 + 升级 GeForce NOW 会员即可提高此上限——这并非 OpenNOW 的限制。 + 游玩时 + 选择串流的操作手感。 + 预览 + 触控鼠标 + 直接点按 + 点哪里就点击哪里 + 触控板 + 滑动移动,然后点按 + 关闭 + 使用手柄或实体鼠标 + 点按 = 移动 + 点击 + 滑动,然后点按 + 手柄 / 鼠标 + 开始游戏 + 状态栏 + 一眼查看帧率、延迟、电量和连接情况。 + 位置 + 60 FPS • 24 ms • Wi-Fi + 出问题的时候 + 卡顿、黑屏、手柄失灵。 + 内置错误报告 + 可从串流中的控制面板打开,或从会话结束后出现的报告中打开。 + 它会先把你的设置与本次会话对照,标出已知有问题的组合,通常还会给出解决办法。 + 它会发送你的描述以及脱敏后的诊断信息——设置、解码器与网络测量值、设备型号。不包含账号信息。 + 每次串流后的会话报告 + 会话结束时显示延迟、帧生成节奏和丢包情况,并可快捷进入错误报告。 + 共享匿名诊断信息 + 有助于发现崩溃和性能问题中的共性。敏感数据会被移除,也不会出售任何信息。若关闭此项,崩溃报告可能不足以支持排查。 + 完成 + 以上内容都可以在设置中修改。 + 你的选择 + 串流画质 + 触控鼠标 + 状态栏 + 开启 + 关闭 + 设置向导 + 重新运行设置向导 + 重新检视外观、串流画质、游玩操作、状态栏和错误报告 + 需要 %1$s + GeForce NOW 将 %1$s 标记为仅限 %2$s,而此账号为 %3$s。会话很可能会被拒绝,或被降级到更低的配置。 + 是否有权游玩由 GeForce NOW 决定,而非 OpenNOW,而且目录有时并不及时——所以你仍然可以试试。 + 仍要尝试 + 复制错误 + 断开连接 + 返回 + 登录 + 共享分析数据 + 共享匿名诊断信息,帮助我们发现错误、崩溃和性能问题中的共性。敏感数据会被移除,我们也不会出售你的数据。 + 如果崩溃时共享处于关闭状态,我们可能没有足够的信息来排查你的报告。此项默认关闭,可在隐私设置中更改。 + 保持关闭 + 共享诊断信息? + 正在检查最新版本… + 正在检查 Google Play… + 正在检查本次会话… + 检查项 + 会自动附上与「设置 > 高级 > 调试日志」中相同的带时间戳日志。不会添加其他文件。 + 你的数据不会被出售,仅用于排查和修复错误。 + 自动日志会在上传前移除账号名称、凭据、会话 ID 和网络地址。原始设备 ID 不会被发送。 + 会收集哪些信息? + 你输入的标题和描述会原样发送,因此请不要填写个人或敏感信息。 + PrintedWaste 和 OpenNOW 的维护者可以查看报告正文、应用版本/构建号、设备型号、Android 版本、提供方与会员类别、当前游戏、串流状态与设置、用于防滥用的匿名安装标识符,以及脱敏后的诊断日志。 + 要把此报告和随附的脱敏诊断信息发送到 PrintedWaste API 吗? + 发送错误报告? + 我同意发送此报告。 + 我了解将上传的内容,并同意将其发送至 PrintedWaste API。 + 请用英文描述此问题。会话诊断信息会一并附上。 + 发生了什么? + 你当时在做什么,出了什么问题,能否复现? + 需要使用英文 + 提交报告前,请将 OpenNOW 或设备语言设为英文。 + 无需退出游戏即可描述问题。 + 我了解 OpenNOW 已找到可能的原因。仍要发送;我可能会失去今后的报告权限。 + 匹配的建议 + 本次检查不会提示无关的解决方案。 + 来自本设备与本次会话的实时检查 + 提交报告前 + 重试版本检查 + 检查并发送 + 再发送一份 + 仍要发送 + 正在发送… + 错误报告已发送 + 采用匹配建议后仍然出现?继续提交,实测证据会自动附上。 + 问题标题 + 重新连接后串流卡住 + 在 Google Play 中更新 + 上传报告 + 上传错误报告? + 正在上传报告… + 将 OpenNOW 设为英文 + 正在检查 PrintedWaste 队列与延迟 + 简介 + 筛选 + 免费等级队列路由 + 截图 + 遥控器返回键 + 详情 + 设备、账号类型、串流配置、当前状态和临时链接已复制到剪贴板。 + 诊断信息已复制 + OpenNOW 会在上传前移除令牌、账号标识符、电子邮件地址、会话 ID 和网络地址。 + 随机生成的链接不会公开列出,但并未加密,该服务会在 24 小时内删除上传内容。 + 创建临时诊断链接? + 正在移除敏感信息并创建临时链接… + 正在准备诊断信息 + 无法生成二维码。请关闭此对话框后重试。 + 用手机扫描此二维码。脱敏后的链接将在 24 小时内过期。 + 扫描诊断链接 + 脱敏并上传 + 没有可用的浏览器 + 无法打开商店页面 + 无法开始连接商店 + 无法断开商店连接 + 访问令牌 + 无法导出日志 + 日志已导出 + 配对码 + Android 原生 GeForce NOW 客户端 + 粘贴 NVIDIA 访问令牌或令牌响应 JSON。OpenNOW 会先验证访问令牌,再保存账号。 + 使用令牌登录 + 请仅使用你本人拥有的账号凭据。 + 使用令牌免浏览器登录,或在登录前导出诊断信息。 + 登录工具 + 使用验证码登录 + 广告 + 实时排位 + 排队 + 返回 + 去反馈 + 遇到问题了吗? + 实际下发的配置 + 本次会话较短,因此评分的波动可能比平时更大。 + 会话报告 + 接下来可以做什么 + 配置为何发生变化 + 这些设置高于检测到的推荐值 + 后台活动 + 已优化(后台可能超时) + 不受限(允许后台运行) + Android 的电池优化会限制应用的后台活动,当应用最小化时可能导致连接超时或暂停 GFN 排队进度。 + 已缓存的商店、库和搜索结果将被删除。你的账号和设置不会改变。 + 清除游戏缓存? + 连接帮助 + 导出启动状态、队列状态、串流更新、恢复事件、设置、编解码器能力,以及最近脱敏的 CloudMatch JSON 响应。 + 开发者 + 此账号未启用持久存储附加服务。 + 版本说明 + 账号、设置、缓存的游戏、教程状态和本地应用文件都会被移除。OpenNOW 将像全新安装一样重启。 + 「重置教程」只是让串流指南重新出现。「重置设置」具有破坏性:它会清除本地应用数据并重启 OpenNOW。 + 重置设置和应用数据? + 选择新账号要使用的 GeForce NOW 提供方。 + 存储用量 + 游戏商店关联 + 在创建未公开列出的临时链接前,敏感信息会被移除。用手机扫描二维码即可分享。 + 上传日志并显示二维码 + 这会把配置变更摘要和可能的原因发送给 PrintedWaste 和 OpenNOW 的维护者,以便他们排查。 + 发送串流诊断信息? + 发送诊断信息 + OpenNOW 目前没有关联的本地串流。 + 串流配置已更改 + 为何会这样 + 正在发送报告和脱敏诊断信息… + 你保存的串流设置未被更改。 + 云端会话已在进行中 + 结束并重新开始 + 输入或编辑要发送到串流的文字 + 游戏缓存本来就是空的 + 已清除游戏缓存 + 正在清除应用数据并重启 OpenNOW + 已通过手机安全登录 + 已断开商店连接 + 教程将在下次串流时显示 + 拖动 + + + %1$d 台服务器 + + diff --git a/android/app/src/main/res/values-de/strings.xml b/android/app/src/main/res/values-de/strings.xml new file mode 100644 index 000000000..362b7b833 --- /dev/null +++ b/android/app/src/main/res/values-de/strings.xml @@ -0,0 +1,836 @@ + + + OpenNOW + OpenNOW wird gestartet + Mit %1$s anmelden + Auf einem anderen Gerät mit %1$s anmelden + Diesen Code zum Anmelden verwenden + %1$s + Warten auf Anmeldung + Code läuft in %1$d:%2$02d ab + Store + Suchen + Bibliothek + Einstellungen + Spiele suchen + Einstellungen durchsuchen + Allgemein + Updates, Datenschutz und App-Daten + Sprache + App-Sprache + Systemstandard + Englisch + Stream + Auflösung, FPS, Codec, HDR, Proxy + Eingabe + Mikrofon, Maus, Tastatur, Touch-Steuerung, Vibration + Oberfläche + Darstellung, Bibliothek, Statusleiste und Töne + Konto + Anmeldung, Speicher, verbundene Stores + Erweitert + Erweiterte Optionen, Experimente, Diagnose und Protokolle + Über + Version, Mitwirkende und Support + Spieltitel anzeigen + Suche löschen + Sprachsuche + %1$d Spiele + Keine Spiele geladen + Keine passenden Spiele in der Bibliothek + Lösche die Suche, um alle Spiele in deiner Bibliothek anzuzeigen. + Lösche die Filter, um alle Spiele in deiner Bibliothek anzuzeigen. + Lösche Suche oder Filter, um alle Spiele in deiner Bibliothek anzuzeigen. + Keine passenden Spiele im Store + Lösche die Suche, um weitere Spiele anzuzeigen. + Lösche die Filter, um weitere Spiele anzuzeigen. + Lösche Suche oder Filter, um weitere Spiele anzuzeigen. + Weiterspielen + Demnächst + Neue Titel bei GeForce NOW + Weiterspielen + In der Warteschlange + Favoriten + Empfehlungen + Alle anzeigen + Spielen + Fortfahren + Fortsetzen + Speichern + Gespeichert + Favorisieren + Aus Favoriten entfernen + Abbrechen + Ein + Aus + Sichtbar + Ausgeblendet + Zurück + Öffnen + Zurücksetzen + Schließen + Stream-Steuerung + Beenden + Fertig + Tastatureingabe öffnen + Anzeige + Eingabe + Support + Controller + Touch-Layout + Audio + Stumm + Statusleiste + %1$s · %2$d Elemente + Stream-Schärfung + Schärfegrad + An Bildschirm anpassen + Live + In dieser Sitzung sind %1$d Mbit/s aktiv + Einstellungen › Stream gilt erst für die nächste Sitzung + Mikrofon + Berechtigung erforderlich + Steam-Menü + Home an den gestreamten PC senden + Esc + Eingabe + + Controller-Maus + Rechter Stick · A Linksklick · B Rechtsklick + Fingermaus + Direktes Klicken + Touch-Controller + Integrierte Touch-Steuerung erkannt + Dieses Spiel unterstützt integrierte Touch-Steuerung. Du kannst unten trotzdem den Touch-Controller von OpenNOW aktivieren. + Dieses Spiel unterstützt integrierte Touch-Steuerung. Der Touch-Controller von OpenNOW ist für diese Sitzung aktiviert. + Integrierte Steuerung aktiv + Joysticks + Fest + Dynamisch + Handy-Vibration als Ersatz + Controller-Mausmodus + L-Stick bewegt · R-Stick scrollt · A klickt · B rechtsklickt + Mausmodus + Mausemulation konfigurieren + Touch-Steuerung + Controller-Layout, Joysticks und Vibration + Problem melden + Prüfungen ausführen und bereinigte Diagnosedaten senden + Verschiebemodus + Touch-Layout zurücksetzen + Positionen auf Standard zurücksetzen + Layout-Skalierung + Schaltflächengröße + Deckkraft + Randabstand + Unterer Abstand + Linke Position + Rechte Position + Joysticks + Analoge Touch-Steuerung anpassen + Dynamische Platzierung + Startet mittig unter deinem Daumen + Verwendet die gespeicherte feste Mitte + Stickgröße + Totzone + Der dynamische Modus behält den gespeicherten Stick-Bereich bei, behandelt aber die erste Berührung deines Daumens als Neutralpunkt. So werden plötzliche Bewegungen vermieden, wenn du die genaue Mitte verfehlst. + Statusleiste + Layout und Informationen auswählen + Darstellung + Position + Elemente + FPS + Ping + Bitrate + Akku + Verbindung + Auflösung + Codec + Server + Dek. / Jit. + Verlust + Tastatur + %1$d/100 + %1$s + Sitzungsberichte nicht mehr anzeigen + Verbindung + Nicht gemessen + Latenz + Stream-Geschwindigkeit + Paketverlust + Jitter + Bildrate + Dekodierung + Ø %1$d ms + Spitze %1$d ms + Spitze %1$s + Stabil + Kann die Bildschärfe beeinträchtigen + Zeitliche Schwankung + Durchschnittliche / Ziel-FPS + Pro Videobild + Sitzungssteuerung + Stream beenden? + Möchtest du %1$s wirklich beenden? + Deine aktuelle Cloud-Gaming-Sitzung wird geschlossen. + Weiterspielen + Stream beenden + Fehlerbericht + Fehler melden + Stream-Fehler melden + Problem und bereinigte Diagnosedaten senden + Beschreibung anzeigen + Beschreibung ausblenden + Ping %1$s + Dek. %1$s ms + Jit. %1$s ms + Verlust %1$s%% + %1$d Bilder pro Sekunde + Ping %1$d Millisekunden + Dekodierzeit %1$s Millisekunden pro Bild + Jitter %1$s Millisekunden + Paketverlust %1$s Prozent + gut + ausreichend + schlecht + Schließen + Auf %1$s spielen + Filter löschen + Nach oben + Automatisch + Demnächst + Launcher auswählen + Launcher + Standard + Ausgewählt + Verfügbarer Launcher + Nicht erneut fragen – diesen Store als Standard festlegen + Fortfahren mit Standard-Store: %1$s + Tipp: Halte „Spielen“ gedrückt, um später einen anderen Store auszuwählen. + „Spielen“ gedrückt halten, um einen Store auszuwählen + Stream + Oberfläche + Qualität + Video + Verbindung + Audio und Tastatur + Zeigereingabe + Maussperre + Hält eine externe Maus während des Streamings im Spiel fest. Beim Öffnen der Stream-Steuerung wird sie freigegeben. + Controller und Touch + Darstellung + Bibliothek und Navigation + Statusleiste + Töne und Sitzungen + Sitzungsbericht anzeigen + Nach jedem Stream eine Qualitätsübersicht anzeigen. + Erweiterte Werkzeuge + Danksagung + Auflösung + Seitenverhältnis + Stream-Voreinstellung + Empfohlen + Benutzerdefiniert + Niedrig (Datensparmodus) + Mittel + Hoch + FPS + Bitrate in Mbit/s + Codec + Farbe + Nur H.264/H.265 + AV1 verwendet 8-Bit-Farbe. Wähle H.265 für 10 Bit; HDR ist auf kompatible Android-TV-Modi beschränkt. + AV1 verwendet in OpenNOW 8-Bit-Farbe. Auf 8 Bit umgestellt und HDR deaktiviert. Wähle H.265 oder H.264 für 10 Bit. + HDR (Performance & Ultimate) + HDR-Streaming ist auf Android-Handhelds nicht verfügbar. 10-Bit-SDR bleibt mit H.265 verfügbar. + HDR auf Android TV erfordert H.265 mit höchstens 60 FPS und eine Auflösung bis 3840 × 2160. + Region + Sitzungs-Proxy + Leitet die Erstellung der GFN-Sitzung und Warteschlangenabfragen über diesen Proxy. Für direkte Anfragen deaktiviert lassen. + Proxy-URL + Codec-Diagnose kopieren + Codec-Diagnose kopiert + Die Codec-Prüfung wurde noch nicht ausgeführt. + Sitzungs-Proxy aktivieren? + Erstellung der GFN-Sitzung, Warteschlangenabfragen, Fortsetzen, Stoppen und Aktualisierungen von Warteschlangenwerbung werden über den eingegebenen Proxy geleitet. + Ein fehlerhafter oder blockierter Proxy kann Start, Warteschlangenfortschritt, Fortsetzen einer aktiven Sitzung oder Sitzungsbereinigung verhindern. + Verwende nur einen vertrauenswürdigen Proxy. Der Betreiber kann möglicherweise Anfragezeitpunkte, Zielhosts und vertrauliche Metadaten des Sitzungsverkehrs einsehen. + Proxy aktivieren + Experimentelles Streaming + Kann Sitzungsstarts fehlschlagen lassen. + L4S + Fordert NVIDIAs Transportpfad mit niedriger Latenz und geringem Verlust an, sofern Server und Netzwerk ihn unterstützen. Bei instabilem Netzwerk deaktiviert lassen. + Cloud-G-Sync-/VRR-Anforderung + Fordert für die Cloud-Sitzung variable Bildwiederholzeiten an, wenn Gerät, Bildschirm, Tarif und GFN-Sitzung dies unterstützen. + Mikrofon + Sendet dein Standard-Android-Mikrofon an das gestreamte Spiel. Du kannst es in der Stream-Steuerung stummschalten. + Die Mikrofonberechtigung wurde nicht erteilt. OpenNOW lässt das Mikrofon-Streaming deaktiviert. + Systemfarben verwenden + Akzentfarbe + Startseite + Store + Bibliothek + Update-Prüfung deaktivieren + Erweiterte Optionen + Zeigt experimentelle Katalog- und Optimierungsoptionen. Der Tab „Erweiterte Diagnose“ bleibt verfügbar. + Ausdrucksstarker Kartenstil + Verwendet hellere Kartenflächen und weichere Ecken. Für einen flacheren, ruhigeren Material-Stil deaktivieren. + Kataloghintergrund + Zeigt auf Handheld-Bildschirmen ein Hintergrundbild hinter Store und Bibliothek. + Hintergrundbild + Eigenes Bild + Integrierter Hintergrund + Farbenfrohes Abstrakt (Standard) + Original OpenNOW + Absolute Cinema + Bild auswählen + Standard verwenden + Bildschirmrandabstand + Kompakte Spielkarten + Store-Beschriftungen anzeigen + Größe der Spielkarten + Stream-Schaltflächen ausblenden + Bildschirmtastatur-Schaltfläche + Zeigt ein kompaktes Tastatursymbol in der Stream-Statusleiste. + Statusleiste standardmäßig anzeigen + Position der Statistik-Einblendung + Serverauswahl ausblenden + Tastentöne + Spielt einen kurzen UI-Ton bei Controller-Navigation und Betätigung der Bildschirmsteuerung. + Intromusik abspielen + Intromusik startet + Stumm + Wiedergabe + Musik abspielen, wenn die Warteschlange endet + Musik stummschalten + Stream bildschirmfüllend strecken + Intelligenter Sitzungstimer + Danke an alle, die OpenNOW für jeden besser machen. + DarkevilPT + Community-Support + Spenden + Spendenlink kopiert + OpenNOW + Pixel-Blau + Pink + Limette + Koralle + Violett + Nativer Streamer (experimentell) + Greift in den Hardwaredecoder ein, um herstellerspezifische Eigenschaften für niedrige Latenz zu setzen. Kann instabil sein. + Bei hochauflösenden Streams oder hohen FPS verwendet „Native Touch – Automatisch“ den Gamepad-Modus, damit der gewählte Stream-Modus erhalten bleibt. Wähle stattdessen „Jedes Spiel“, um Native Touch zu bevorzugen. + Minimieren + Anzeigen + Auf dem TV spielen + Stream wird gestartet + Warteschlangenposition %1$d + Warten auf einen Rechner + Stream wird verbunden + Sitzung wird fortgesetzt + Rechner wird eingerichtet + Sitzung wird gestartet + Warteschlangenstatus + %1$s ist spielbereit! + Deine GFN-Warteschlange ist abgeschlossen. Tippe, um zur App zurückzukehren. + Das Streaming läuft bei ausgeschaltetem Bildschirm weiter + Nicht im Besitz + Unbekannter Herausgeber + Cloud-Sitzung fortsetzen + App %1$s + Warteschlange %1$d + Wird gestartet + Für dieses Spiel ist noch keine Beschreibung verfügbar. + Tastaturlayout + Spielsprache + Aus Zwischenablage einfügen + Weiter + Erneut versuchen + Starten + Überspringen + Aktualisieren + Senden + OK + Rückgängig + Installieren + Verwalten + Zulassen + Aktiv + Bereit + Wird geprüft + Beste verfügbare Route + Vor dir + Warten + Sitzungstimer + Cache leeren + Tutorial zurücksetzen + Einstellungen zurücksetzen + Zurücksetzen und neu starten + Wechseln + Konto hinzufügen + Abmelden + Von allen Konten abmelden + Anbieter auswählen + Spielzeitstatistik + Cloud-Speicher + Speicher hinzufügen + Speicherort ändern + Kein aktiver Stream + Zur Bibliothek + Cloud-Sitzung beenden + Schritt %1$d von %2$d + Fertig drücken + Controller erkannt + Der Bildschirmcontroller wurde ausgeblendet, weil ein physischer Controller verbunden ist. + Nicht mehr anzeigen + Telefonkopplung wird gestartet… + Mit der OpenNOW-Telefon-App koppeln + Installiere und öffne zuerst OpenNOW auf deinem Android-Telefon. Verbinde Telefon und TV mit demselben WLAN und scanne dann diesen QR-Code mit der Telefonkamera. Der Link läuft nach fünf Minuten ab. + TV-Kopplung + Mit einem TV koppeln + Telefon und TV müssen im selben WLAN sein. Scanne hier den QR-Code des TVs oder suche den TV und gib seinen 4-stelligen Code ein. + Mit %1$s verbunden. Spiele zeigen jetzt die Aktion „Auf dem TV spielen“. + Mit %1$s verbunden + Am TV anmelden + TV vergessen + QR-Code scannen oder einen TV im Netzwerk suchen + TV-QR scannen + QR-Scanner konnte nicht geöffnet werden + TV suchen + Suche läuft… + TV-Code + Gib den 4-stelligen Code ein, der auf diesem TV angezeigt wird. + Koppeln + Konten und Dienste + Profile, Mitgliedschaft, Speicher und Spiele-Stores + Neue Spiele + Ergebnisse + Empfohlenes Bibliotheksbanner + Touch-Controller-Design + Touch-Controller-Farbe + Tastenbuchstaben + Gyroskop-Zielen + In diesem Netzwerk wurde kein OpenNOW-TV gefunden. + Kontoprofil öffnen + Benutzername + Stufe + E-Mail + Kontooptionen + %1$s • %2$s + Nicht verfügbar + Entwickleroptionen + Abläufe zurücksetzen, Laufzeit prüfen und lokalen Zustand neu aufbauen + Für Entwicklung und Support + Diese Aktionen setzen nur den lokalen Zustand von OpenNOW zurück und zeigen Informationen, die bereits im Diagnose-Export enthalten sind. Zerstörerische Aktionen fragen vorher nach. Du kannst diese Seite unten in der Liste wieder ausblenden. + Abläufe und Hinweise + Katalog und Stores + Stream + Oberfläche + Diagnose + Zerstörerisch + Zurücksetzen + Leeren + Ausführen + Anwenden + Kopieren + Wiederholen + Ausblenden + Erststart wiederholen + Einrichtung, Anleitungen, Hinweise, Einwilligung und Browsing-Zustand auf einmal + Die Einrichtung läuft erneut, alle einmaligen Hinweise erscheinen wieder, die Analyse-Einwilligung wird widerrufen, bis du sie erneut beantwortest, und die Sortierung von Store und Bibliothek wird zurückgesetzt. Konten, Favoriten und Stream-Einstellungen bleiben unberührt. + Einrichtung erneut ausführen + Zeigt die Erststart-Bildschirme beim nächsten Start + Stream-Anleitung erneut anzeigen + Erscheint beim nächsten Stream-Start wieder + Controller-Hinweis erneut anzeigen + Erscheint wieder, sobald das nächste Mal ein Controller verbunden wird + Erneut nach Analyse-Einwilligung fragen + Deaktiviert die Freigabe, bis die Frage erneut beantwortet wird + Upgrade-Migrationen wiederholen + Wendet die einmaligen Darstellungs- und TV-Layout-Standards erneut an + Spiele-Cache leeren + Verwirft zwischengespeicherte Store-, Bibliotheks- und Suchergebnisse + Katalog neu laden + Lädt Store und Bibliothek jetzt neu vom Anbieter + Browsing-Zustand zurücksetzen + Sortierung und Filter von Store und Bibliothek auf Standard + Launcher-Auswahl vergessen + Alle gespeicherten Store-Auswahlen pro Spiel + Favoriten löschen + %1$d gespeichert + Jedes favorisierte Spiel wird entfernt. Das kann nicht rückgängig gemacht werden. + App-Regal leeren + %1$d installierte Apps angeheftet + Gemessene Empfehlung anwenden + Touch-Layout zurücksetzen + Overlay-Größe, Deckkraft und jeder Tastenversatz + Server-Warteschlangen aktualisieren + Fragt die PrintedWaste-Zonenliste und Pings erneut ab + Oberfläche zurücksetzen + Akzent, Hintergrund, Kartenlayout und Animationsstandards + Diagnoseprotokoll kopieren + Bereinigt, derselbe Text, den der Fehlerbericht anhängt + Laufzeitübersicht kopieren + Die Tabelle oben, als Text + Nach Update suchen + Führt die Update-Prüfung sofort aus + Von allen Konten abmelden + %1$d gespeichert + Jedes gespeicherte Konto wird von diesem Gerät entfernt und OpenNOW kehrt zum Anmeldebildschirm zurück. + App-Daten löschen und neu starten + Setzt OpenNOW auf eine Neuinstallation zurück + Konten, Einstellungen, zwischengespeicherte Spiele und lokale Dateien werden gelöscht und OpenNOW startet wie eine Neuinstallation. Das kann nicht rückgängig gemacht werden. + Entwickleroptionen ausblenden + Tippe in „Über“ zehnmal auf die Build-Nummer, um sie zurückzuholen + Build + Variante + Gerät + Android + Layout-Profil + Mitgliedschaft + Anbieter + Stream-Profil + Hardware-Decoder + Store-/Bibliotheksspiele + Keine + Abgemeldet + Android TV + Handheld + Erststart-Zustand wiederhergestellt + Die Einrichtung läuft beim nächsten Start + Die Stream-Anleitung wird wieder angezeigt + Der Controller-Hinweis wird wieder angezeigt + Die Analyse-Einwilligung wird erneut abgefragt + Upgrade-Migrationen werden wiederholt + Browsing-Zustand zurückgesetzt + Launcher-Auswahl vergessen + Favoriten gelöscht + App-Regal geleert + Gemessene Empfehlung angewendet + Touch-Layout zurückgesetzt + Oberfläche zurückgesetzt + Diagnoseprotokoll kopiert + Laufzeitübersicht kopiert + Entwickleroptionen ausgeblendet + Noch %1$d Tippen, um Entwickleroptionen anzuzeigen + Entwickleroptionen sind jetzt in den Einstellungen + Entwickleroptionen werden bereits angezeigt + Weiter + Nicht verfügbar + Vibration + Controller-Rumble, wenn verfügbar; sonst Geräte-Haptik + Rumble-Ausgabe + Manche Handhelds melden einen Rumble-Motor am eingebauten Pad, der an nichts angeschlossen ist. Erzwinge den Telefonmotor, wenn das Rumble im Spiel stumm bleibt. + Automatisch + Controller + Telefon + Touch-Zielen + Joystick sperren + Zone sperren / Touch-Zielen + Ziehe irgendwo in der rechten Zone für relatives Mouse-Look-Zielen. + ZIELZONE + Discord-Community + Hol dir Hilfe und verfolge Fehlerberichte mit der OpenNOW-Community. + Community-Support und Nachverfolgung von Fehlerberichten + Beitreten + Discord-Einladung kopiert + Sortieren und filtern + Sortieren und filtern, %1$d aktiv + Sortieren + Beliebt + Zuletzt gespielt + Filter + Steuerung + Mobile Touch-Steuerung + Die Anzahl der Pixel, die der Cloud-PC sendet. Höhere Auflösungen wirken schärfer, brauchen aber mehr Decoder-, GPU- und Netzwerkkapazität. + Passt die Streamform an das Display an. Ein falsches Verhältnis kann schwarze Balken oder Verzerrungen erzeugen; es macht den Decoder nicht schneller. + „Empfohlen“ nutzt Display, Speicher, Prozessorkerne, Android-Profil und verifizierte WebRTC-Hardware-Decoder dieses Geräts. „Benutzerdefiniert“ behält deine manuellen Einstellungen. + Erkannte Empfehlung: %1$s + Bilder pro Sekunde steuern die Bewegungsglätte. Höhere FPS geben dem Decoder weniger Zeit pro Bild und können auf langsamer Hardware zu Rucklern führen. + Die maximale Videodatenrate. Eine höhere Bitrate kann Details verbessern, aber nur wenn die Verbindung genug stabile Kapazität hat; sie erhöht die FPS nicht. + H.264 ist am kompatibelsten. H.265 nutzt Bandbreite effizienter und ist bei hoher Auflösung vorzuziehen, wenn ein verifizierter Hardware-Decoder vorhanden ist. AV1 läuft hier mit 8 Bit und wird nur auf Geräten mit kompatiblem Hardware-Pfad genutzt. + 8 Bit 4:2:0 ist am leichtesten und kompatibelsten. 10 Bit verbessert Farbverläufe, erhöht aber die Decoder- und Bandbreitenanforderungen. Android normalisiert nicht unterstützte Kombinationen automatisch. + HDR benötigt ein kompatibles Android-TV-Display, H.265, 10-Bit-Video und eine unterstützte Mitgliedschaft. Es erhöht die Rechenlast und wird zur Lag-Fehlersuche nicht empfohlen. + Eigene Apps hinzufügen + Zeigt ein Bibliotheksregal, in dem du installierte Android-Apps und -Spiele hinzufügen, starten und entfernen kannst. + Als Standard-Launcher festlegen + Öffnet die Launcher-Auswahl von Android, damit OpenNOW zum Startbildschirm werden kann. Du kannst das in den Android-Einstellungen wieder ändern. + Launcher wählen + OpenNOW ist Standard + Verwalten + Eigene Apps + App hinzufügen + App auswählen + Installierte Apps werden geladen… + Eigene Apps anzeigen, %1$d installiert + Eigene Apps ausblenden, %1$d installiert + Keine weiteren startbaren Apps gefunden. + %1$s entfernen + Das entfernt nur die Verknüpfung aus deinem Regal. Die App bleibt installiert. + Entfernen + Empfohlen + Jedes Skin ist ein anderer Controller, nicht nur eine andere Farbe: Der Schnitt der Tasten, ob das D-Pad ein Kreuz oder vier einzelne Tasten ist, und worin sich der Stick bewegt, ändert sich jeweils mit. + Färbt die Skins um, die auf einem Akzent aufbauen. Klassisch, Umriss, Frost und Hoher Kontrast sind bewusst monochrom. + „Aus“ lässt die Tasten leer, sobald das Layout in Fleisch und Blut übergegangen ist. + Skin + Größe der Aktionstasten + D-Pad-Größe + Größe von Trigger und Schultertasten + Größe von Menü- und Stick-Klick + Größe des linken Sticks + Größe des rechten Sticks + Größe der Stick-Kappe + Neige das Telefon für relatives Mouse-Look-Zielen. + Dieses Gerät meldet kein Gyroskop. + Gyroskop-Empfindlichkeit + Gyroskop-Totzone + Gyroskop-Glättung + Gyroskop horizontal invertieren + Gyroskop vertikal invertieren + Bewegungszielen + Rotierendes Banner über dem Bibliotheksraster auf Telefonen im Hochformat. + Diese App ist nicht mehr installiert oder kann nicht geöffnet werden. + Bibliotheksreihenfolge + Zuletzt gespielt + Titel A–Z + Animierte Auswahlrahmen + Animiert ausgewählte Spiele, Menüpunkte, Serverauswahl und Launcher-Optionen. Schalte es aus für ruhigere Auswahl. + Absolute-Cinema-Effekte + Nutzt animierte orange und blaue Fokusringe und behält dabei deine gewählte Oberflächenfarbe. + Ich bin verrückt + Lass Absolute Cinema auf die gesamte Oberfläche los. Artwork, Beschreibungen, Bedienelemente und mehr bekommen den Effekt beim Überfahren und Fokussieren. + Favoritensymbol auf Spielkarten anzeigen + Zeigt eine Favoritentaste auf Spielkarten für Mobilgeräte, Handhelds und TV. + Standardmäßig an. Füllt das Display, statt schwarze Balken zu lassen, indem das Bild nur auf der abweichenden Achse gedehnt wird — nie durch Beschneiden. Schalte das aus für exakte Geometrie. + Wendet nach dem Decodieren einen zusätzlichen GPU-Filter an. Das kann die wahrgenommene Detailtiefe verbessern, erhöht aber auf langsameren Geräten die Renderlast. + Steuert die Stärke des Nachbearbeitungs-Schärfefilters. Die Auflösung des Quellstreams ändert sich dadurch nicht. + Absolute Cinema + Switch + Loslegen + Weiter + Zurück + Überspringen + Fertigstellen + Natives GeForce NOW für Android + Mach es zu deinem + Alles hier wird sofort übernommen. + Akzent + Oberflächenanimationen + Schimmer, Fokus-Leuchten und Karussellbewegung. Ausschalten berücksichtigt auch die Systemeinstellung für Animationen. + Vorschau + Layout + Jede Auswahl zeichnet die Vorschau neu + Spieltitel unter dem Artwork + „Aus“ lässt das Raster als reines Cover-Artwork. + Quadratische Karten + Beschneidet das Cover auf ein Quadrat, damit mehr Spiele auf den Bildschirm passen. + Favoritentaste auf dem Artwork + Speichert ein Spiel in deiner Bibliothek, ohne es zu öffnen. + Abgerundete Ecken + Weichere Karten- und Panelkanten in der ganzen App. + Absolute Cinema + Animierte Energierahmen um das, was gerade fokussiert ist. Normalerweise eine Controller- und TV-Darstellung. + Rückmeldung + Beide lösen beim nächsten Tippen aus + Haptik + Ein kurzes Vibrieren bei der Auswahl und Controller-Rumble im Spiel, wo das Gerät es unterstützt. + Oberflächentöne + Ein Ton bei Tastendruck und Menünavigation. Beeinflusst den Spielton nicht. + Hintergrund + Aus + Standard + Nichts + App-Hintergrund + Hintergrundbild + Dein Bild + Streamqualität + Gemessen an Display, Chipsatz und Decodern dieses Geräts. + Gerät wird vermessen + Empfohlen + Datensparmodus + 720p, 30 FPS, 12 Mbit/s + Beste Qualität + Bis zu %1$s bei %2$d FPS in deinem Tarif + Selbst einstellen + Wähle unten Auflösung, Bildrate und Bitrate + %1$s-Mitgliedschaft + Dein Tarif streamt bis zu %1$s bei %2$d FPS. Höhere Optionen sind mit der Stufe aufgeführt, die sie freischaltet. + Ein Upgrade deiner GeForce-NOW-Mitgliedschaft hebt diese Grenze an — OpenNOW begrenzt sie nicht. + Während des Spielens + Wähle, wie sich der Stream anfühlt. + Vorschau + Touch-Maus + Direkt + Tippe dorthin, wo du klicken willst + Trackpad + Wischen zum Bewegen, dann tippen + Aus + Controller oder physische Maus verwenden + Tippen = bewegen + klicken + Wischen, dann tippen + Controller / Maus + Spielen + Statuszeile + FPS, Ping, Akku und Verbindung auf einen Blick. + Position + 60 FPS • 24 ms • WLAN + Wenn etwas kaputtgeht + Ruckler, schwarze Bildschirme, tote Controller. + Der integrierte Fehlerbericht + Öffne ihn über die Steuerung im Stream oder über den Bericht nach dem Sitzungsende. + Er prüft zuerst deine Einstellungen gegen die Sitzung und markiert bekannt problematische Kombinationen, meist mit einer Lösung. + Er sendet deine Beschreibung plus bereinigte Diagnose — Einstellungen, Decoder- und Netzwerkmessungen, Gerätemodell. Keine Kontodaten. + Sitzungsbericht nach jedem Stream + Latenz, Frame-Pacing und Paketverlust am Sitzungsende, mit einer Abkürzung zum Fehlerbericht. + Anonyme Diagnose teilen + Hilft, Muster über Abstürze und Leistungsprobleme hinweg zu finden. Sensible Daten werden entfernt und nichts wird verkauft. Ist das aus, enthält ein Absturzbericht möglicherweise zu wenig für eine Untersuchung. + Fertig + Alles davon kannst du in den Einstellungen ändern. + Deine Auswahl + Streamqualität + Touch-Maus + Statuszeile + An + Aus + Einrichtung + Einrichtung erneut ausführen + Erscheinungsbild, Streamqualität, Spielsteuerung, Status und Fehlerberichte erneut durchgehen + Benötigt %1$s + GeForce NOW führt %1$s nur als %2$s, und dieses Konto nutzt %3$s. Die Sitzung wird höchstwahrscheinlich abgelehnt oder auf ein niedrigeres Profil gesenkt. + Über die Berechtigung entscheidet GeForce NOW, nicht OpenNOW, und der Katalog ist manchmal veraltet — du kannst es also trotzdem versuchen. + Trotzdem versuchen + Fehler kopieren + Trennen + Zurück + Anmelden + Analyse teilen + Teile anonyme Diagnosedaten, damit wir Muster bei Fehlern, Abstürzen und Leistungsproblemen finden. Sensible Daten werden entfernt und wir verkaufen deine Daten nicht. + Ist das Teilen bei einem Absturz aus, haben wir möglicherweise zu wenige Informationen, um deinen Bericht zu untersuchen. Es ist standardmäßig aus und kann in den Datenschutzeinstellungen geändert werden. + Aus lassen + Diagnose teilen? + Neuester Build wird geprüft… + Google Play wird geprüft… + Diese Sitzung wird geprüft… + Prüfungen + Dasselbe Protokoll mit Zeitstempel, das unter Einstellungen > Erweitert > Debug-Protokolle verfügbar ist, wird automatisch angehängt. Weitere Dateien werden nicht hinzugefügt. + Deine Daten werden nicht verkauft und nur genutzt, um Fehler zu untersuchen und zu beheben. + Das automatische Protokoll entfernt Kontonamen, Zugangsdaten, Sitzungs-IDs und Netzwerkadressen vor dem Upload. Die rohe Geräte-ID wird nicht gesendet. + Was wird erfasst? + Dein eingegebener Titel und die Beschreibung werden genau so gesendet, wie du sie geschrieben hast — nimm also keine persönlichen oder sensiblen Angaben auf. + PrintedWaste- und OpenNOW-Betreuer können den Berichtstext, App-Version/-Build, Gerätemodell, Android-Version, Anbieter- und Mitgliedschaftskategorie, aktuelles Spiel, Streamstatus/-einstellungen, eine pseudonyme Installationskennung zur Missbrauchsprävention und ein bereinigtes Diagnoseprotokoll einsehen. + Diesen Bericht und die angehängte bereinigte Diagnose an die PrintedWaste-API senden? + Fehlerbericht senden? + Ich willige ein, diesen Bericht zu senden. + Ich verstehe, was hochgeladen wird, und willige ein, es an die PrintedWaste-API zu senden. + Beschreibe den Fehler auf Englisch. Sitzungsdiagnosen werden angehängt. + Was ist passiert? + Was hast du gemacht, was ging schief, und kannst du es reproduzieren? + Englisch erforderlich + Stelle entweder OpenNOW oder die Gerätesprache auf Englisch, bevor du berichtest. + Beschreibe das Problem, ohne dein Spiel zu verlassen. + Ich verstehe, dass OpenNOW eine wahrscheinliche Ursache gefunden hat. Trotzdem senden; ich verliere möglicherweise künftigen Zugang zum Berichten. + PASSENDE VORSCHLÄGE + Für diese Prüfung werden keine unpassenden Lösungen vorgeschlagen. + Live-Prüfungen von diesem Gerät und dieser Sitzung + Bevor du berichtest + Versionsprüfung wiederholen + Prüfen & senden + Weiteren senden + Trotzdem senden + Wird gesendet… + Fehlerbericht gesendet + Tritt es nach einem passenden Vorschlag weiter auf? Fahre fort, und die gemessenen Belege werden automatisch angehängt. + Titel des Problems + Stream eingefroren nach erneutem Verbinden + In Google Play aktualisieren + Bericht hochladen + Fehlerbericht hochladen? + Bericht wird hochgeladen… + Englisch für OpenNOW verwenden + PrintedWaste-Warteschlangen und Latenz werden geprüft + Beschreibung + Filter + Warteschlangen-Routing für die kostenlose Stufe + Screenshots + Zurück-Taste der Fernbedienung + Details + Gerät, Kontotyp, Streamprofil, aktueller Status und die temporäre Paste-URL wurden in die Zwischenablage kopiert. + Diagnose kopiert + OpenNOW entfernt Tokens, Kontokennungen, E-Mail-Adressen, Sitzungs-IDs und Netzwerkadressen vor dem Hochladen. + Der zufällig erzeugte Link ist nicht gelistet, aber nicht verschlüsselt, und der Paste-Dienst löscht Uploads innerhalb von 24 Stunden. + Temporäre Diagnose-Paste erstellen? + Sensible Werte werden entfernt und eine temporäre Paste wird erstellt… + Diagnose wird vorbereitet + Der QR-Code konnte nicht erstellt werden. Schließe diesen Dialog und versuche es erneut. + Scanne diesen QR-Code mit deinem Telefon. Die bereinigte Paste läuft innerhalb von 24 Stunden ab. + Diagnose-Link scannen + Bereinigen und hochladen + Kein Browser verfügbar + Store-Seite konnte nicht geöffnet werden + Store-Verbindung konnte nicht gestartet werden + Store konnte nicht getrennt werden + Zugriffstoken + Protokolle konnten nicht exportiert werden + Protokolle exportiert + KOPPLUNGSCODE + Nativer GeForce-NOW-Client für Android + Füge ein NVIDIA-Zugriffstoken oder eine Token-Antwort im JSON-Format ein. OpenNOW prüft das Zugriffstoken, bevor das Konto gespeichert wird. + Mit Token anmelden + Verwende nur Zugangsdaten für ein Konto, das dir gehört. + Melde dich mit einem Token ohne Browser an oder exportiere die Diagnose vor der Anmeldung. + Anmeldewerkzeuge + Code-Anmeldung verwenden + Werbung + Live-Position + Warteschlange + ZURÜCK + Melde ihn + Einen Fehler erlebt? + Geliefertes Profil + Das war eine kurze Sitzung, daher kann der Wert stärker schwanken als sonst. + Sitzungsbericht + Was du als Nächstes tun kannst + Warum sich das Profil geändert hat + Diese Einstellungen liegen über der erkannten Empfehlung + Hintergrundaktivität + Optimiert (kann im Hintergrund abbrechen) + Unbegrenzt (im Hintergrund erlaubt) + Die Android-Akkuoptimierung schränkt die Hintergrundaktivität der App ein, was zu Verbindungsabbrüchen führen oder den Fortschritt in der GFN-Warteschlange anhalten kann, wenn die App minimiert ist. + Zwischengespeicherte Store-, Bibliotheks- und Suchergebnisse werden entfernt. Dein Konto und deine Einstellungen bleiben unverändert. + Spiele-Cache leeren? + Verbindungshilfe + Exportiert Startzustand, Warteschlangenzustand, Stream-Updates, Wiederherstellungsereignisse, Einstellungen, Codec-Fähigkeiten und aktuelle bereinigte CloudMatch-JSON-Antworten. + Entwickler + Für dieses Konto ist kein Add-on für dauerhaften Speicher aktiv. + Versionshinweise + Konten, Einstellungen, zwischengespeicherte Spiele, Tutorial-Status und lokale App-Dateien werden entfernt. OpenNOW startet wie eine Neuinstallation. + „Tutorial zurücksetzen“ lässt nur die Stream-Anleitung wieder erscheinen. „Einstellungen zurücksetzen“ ist zerstörerisch: Es löscht lokale App-Daten und startet OpenNOW neu. + Einstellungen und App-Daten zurücksetzen? + Wähle den GeForce-NOW-Anbieter für das neue Konto. + Speichernutzung + Store-Verbindungen + Sensible Werte werden entfernt, bevor eine nicht gelistete, temporäre Paste erstellt wird. Scanne den QR-Code mit deinem Telefon, um sie zu teilen. + Protokolle hochladen und QR anzeigen + Das sendet die Zusammenfassung der Profiländerung und die wahrscheinliche Ursache an PrintedWaste- und OpenNOW-Betreuer, damit sie es untersuchen können. + Stream-Diagnose senden? + Diagnose senden + OpenNOW hat derzeit keinen lokalen Stream angebunden. + Streamprofil geändert + Warum es passiert ist + Bericht und bereinigte Diagnose werden gesendet… + Deine gespeicherten Stream-Einstellungen wurden nicht geändert. + Cloud-Sitzung bereits aktiv + Beenden und neu starten + Streamtext eingeben oder bearbeiten + Der Spiele-Cache war bereits leer + Spiele-Cache geleert + App-Daten werden gelöscht und OpenNOW neu gestartet + Sicher vom Telefon angemeldet + Store getrennt + Das Tutorial wird beim nächsten Stream angezeigt + Ziehen + + + %1$d Server + %1$d Server + + diff --git a/android/app/src/main/res/values-es/strings.xml b/android/app/src/main/res/values-es/strings.xml new file mode 100644 index 000000000..c3ddc1dd5 --- /dev/null +++ b/android/app/src/main/res/values-es/strings.xml @@ -0,0 +1,837 @@ + + + OpenNOW + Iniciando OpenNOW + Iniciar sesión con %1$s + Iniciar sesión en otro dispositivo con %1$s + Usa este código para iniciar sesión + %1$s + Esperando el inicio de sesión + El código caduca en %1$d:%2$02d + Tienda + Buscar + Biblioteca + Ajustes + Buscar juegos + Buscar en los ajustes + General + Actualizaciones, privacidad y datos de la aplicación + Idioma + Idioma de la aplicación + Predeterminado del sistema + Inglés + Transmisión + Resolución, FPS, códec, HDR y proxy + Entrada + Micrófono, ratón, teclado, controles táctiles y vibración + Interfaz + Apariencia, biblioteca, barra de estado y sonidos + Cuenta + Inicio de sesión, almacenamiento y tiendas conectadas + Avanzado + Opciones avanzadas, experimentos, diagnósticos y registros + Acerca de + Versión, créditos y asistencia + Mostrar títulos de los juegos + Borrar búsqueda + Búsqueda por voz + %1$d juegos + No hay juegos cargados + No hay coincidencias en la biblioteca + Borra la búsqueda para mostrar todos los juegos de tu biblioteca. + Borra los filtros para mostrar todos los juegos de tu biblioteca. + Borra la búsqueda o los filtros para mostrar todos los juegos de tu biblioteca. + No hay coincidencias en la tienda + Borra la búsqueda para mostrar más juegos. + Borra los filtros para mostrar más juegos. + Borra la búsqueda o los filtros para mostrar más juegos. + Volver al juego + Próximamente + Novedades de GeForce NOW + Seguir jugando + En cola + Favoritos + Recomendaciones + Ver todo + Jugar + Continuar + Reanudar + Guardar + Guardado + Añadir a favoritos + Quitar de favoritos + Cancelar + Activado + Desactivado + Visible + Oculto + Atrás + Abrir + Restablecer + Cerrar + Controles de transmisión + Salir + Listo + Abrir envío de teclado + Pantalla + Entrada + Asistencia + Mando + Diseño táctil + Audio + Silenciado + Barra de estado + %1$s · %2$d elementos + Nitidez de la transmisión + Nivel de nitidez + Estirar para ajustar + En directo + %1$d Mbps activos en esta sesión + Ajustes › Transmisión solo se aplica a la próxima sesión + Micrófono + Se necesita permiso + Menú de Steam + Enviar Inicio al PC remoto + Esc + Intro + + Ratón con mando + Stick derecho · A clic · B clic derecho + Ratón con el dedo + Clic directo + Mando táctil + Se detectaron controles táctiles integrados + Este juego admite controles táctiles integrados. Si lo prefieres, puedes activar abajo el mando táctil de OpenNOW. + Este juego admite controles táctiles integrados. El mando táctil de OpenNOW está activado para esta sesión. + Control integrado activo + Joysticks + Fijo + Dinámico + Vibración del teléfono como alternativa + Modo ratón con mando + Stick izq. mueve · Stick der. desplaza · A hace clic · B hace clic derecho + Modo ratón + Configurar la emulación del ratón + Controles táctiles + Diseño del mando, joysticks y vibración + Informar de un problema + Ejecutar comprobaciones y enviar diagnósticos depurados + Modo de edición por arrastre + Restablecer diseño táctil + Restablecer las posiciones predeterminadas + Escala del diseño + Tamaño de los botones + Opacidad + Margen del borde + Margen inferior + Posición izquierda + Posición derecha + Joysticks + Ajusta los controles analógicos táctiles + Colocación dinámica + Empieza centrado bajo el pulgar + Usa el centro fijo guardado + Tamaño del joystick + Zona muerta + El modo dinámico conserva el área guardada del joystick, pero considera neutral el primer punto donde apoyas el pulgar. Así evita movimientos repentinos si no tocas el centro exacto. + Barra de estado + Elige su diseño e información + Apariencia + Posición + Elementos + FPS + Ping + Tasa de bits + Batería + Conexión + Resolución + Códec + Servidor + Dec. / Jit. + Pérdida + Teclado + %1$d/100 + %1$s + No volver a mostrar informes de sesión + Conexión + Sin medir + Latencia + Velocidad de transmisión + Pérdida de paquetes + Variación + Frecuencia de fotogramas + Decodificación + %1$d ms de media + Pico de %1$d ms + Pico de %1$s + Estable + Puede afectar a la nitidez + Variación temporal + FPS medios / objetivo + Por fotograma de vídeo + Control de sesión + ¿Salir de la transmisión? + ¿Seguro que quieres salir de %1$s? + Se cerrará tu sesión actual de juego en la nube. + Seguir jugando + Salir de la transmisión + Informe de errores + Informar de un error + Informar de un error de transmisión + Enviar un problema y diagnósticos depurados + Mostrar descripción + Ocultar descripción + Ping %1$s + Dec. %1$s ms + Var. %1$s ms + Pérdida %1$s%% + %1$d fotogramas por segundo + Ping de %1$d milisegundos + Tiempo de decodificación: %1$s milisegundos por fotograma + Variación de %1$s milisegundos + Pérdida de paquetes del %1$s por ciento + buena + aceptable + mala + Descartar + Jugar en %1$s + Borrar filtros + Volver arriba + Automático + Próximamente + Elegir iniciador + Iniciadores + Predeterminado + Seleccionado + Iniciador disponible + No volver a preguntar: usar esta tienda como predeterminada + Continuando con la tienda predeterminada: %1$s + Consejo: mantén pulsado Jugar para elegir otra tienda más adelante. + Mantén pulsado Jugar para elegir una tienda + Transmisión + Interfaz + Calidad + Vídeo + Conexión + Audio y teclado + Entrada de puntero + Bloqueo del ratón + Mantiene un ratón externo capturado dentro del juego durante la transmisión. Al abrir los controles de transmisión se libera. + Mando y control táctil + Apariencia + Biblioteca y navegación + Barra de estado + Sonidos y sesiones + Mostrar informe de sesión + Muestra un resumen de calidad después de cada transmisión. + Herramientas avanzadas + Agradecimientos + Resolución + Relación de aspecto + Preajuste de transmisión + Recomendado + Personalizado + Bajo (ahorro de datos) + Medio + Alto + FPS + Tasa de bits en Mbps + Códec + Color + Solo H.264/H.265 + AV1 usa color de 8 bits. Elige H.265 para 10 bits; HDR está limitado a modos compatibles de Android TV. + AV1 usa color de 8 bits en OpenNOW. Se cambió a 8 bits y se desactivó HDR. Elige H.265 o H.264 para usar 10 bits. + HDR (Performance & Ultimate) + La transmisión HDR no está disponible en dispositivos Android portátiles. SDR de 10 bits sigue disponible con H.265. + HDR en Android TV requiere H.265 a 60 FPS o menos y una resolución máxima de 3840 × 2160. + Región + Proxy de sesión + Enruta la creación de sesiones GFN y las consultas de cola a través de este proxy. Déjalo desactivado para solicitudes directas. + URL del proxy + Copiar diagnóstico del códec + Diagnóstico del códec copiado + La prueba del códec aún no se ha ejecutado. + ¿Activar el proxy de sesión? + La creación de sesiones GFN, consultas de cola, reanudación, detención y actualizaciones de anuncios de cola se enrutarán a través del proxy introducido. + Un proxy defectuoso o bloqueado puede impedir el inicio, el progreso de la cola, la reanudación de una sesión activa o su limpieza. + Usa solo un proxy de confianza. Su operador podría observar los horarios de las solicitudes, los hosts de destino y metadatos confidenciales del tráfico de sesión. + Activar proxy + Transmisión experimental + Puede provocar fallos al iniciar sesiones. + L4S + Solicita la ruta de transporte de NVIDIA de baja latencia y pocas pérdidas cuando el servidor y la red la admiten. Déjalo desactivado si la red se vuelve inestable. + Solicitud de Cloud G-Sync / VRR + Solicita que la sesión en la nube use una frecuencia de actualización variable si el dispositivo, la pantalla, el plan y la sesión de GFN lo admiten. + Micrófono + Envía el micrófono predeterminado de Android al juego remoto. Puedes silenciarlo desde los controles de transmisión. + No se concedió el permiso del micrófono. OpenNOW mantendrá desactivada la transmisión del micrófono. + Usar colores del sistema + Acento + Página de inicio + Tienda + Biblioteca + Desactivar búsqueda de actualizaciones + Opciones avanzadas + Muestra opciones experimentales del catálogo y de ajuste. La pestaña de diagnóstico avanzado permanece disponible. + Estilo de tarjetas expresivo + Usa superficies de tarjeta más brillantes y esquinas más suaves. Desactívalo para un estilo Material más plano y discreto. + Fondo del catálogo + Muestra una imagen de fondo detrás de Tienda y Biblioteca en pantallas portátiles. + Imagen de fondo + Imagen personalizada + Fondo integrado + Abstracto colorido (predeterminado) + OpenNOW original + Cine absoluto + Elegir imagen + Usar valor predeterminado + Margen del borde de pantalla + Tarjetas de juego compactas + Mostrar etiquetas de tienda + Tamaño de las tarjetas de juego + Ocultar botones de transmisión + Botón del teclado en pantalla + Muestra un icono de teclado compacto en la barra de estado de la transmisión. + Mostrar barra de estado de forma predeterminada + Posición de las estadísticas superpuestas + Ocultar selector de servidor + Sonidos al pulsar botones + Reproduce un tono breve al navegar con el mando y pulsar controles en pantalla. + Reproducir música de introducción + La música de introducción comienza + Silenciada + Reproduciéndose + Reproducir música al terminar la cola + Silenciar música + Estirar la transmisión para rellenar + Temporizador de sesión inteligente + Gracias a quienes ayudan a mejorar OpenNOW para todos. + DarkevilPT + Apoyo a la comunidad + Donar + Enlace de donación copiado + OpenNOW + Azul Pixel + Rosa intenso + Lima + Coral + Violeta + Transmisor nativo (experimental) + Intercepta el decodificador de hardware para inyectar propiedades de baja latencia del fabricante. Puede ser inestable. + El modo Automático del control táctil nativo usa el modo mando en transmisiones de alta resolución o FPS elevados para conservar el modo seleccionado. Elige Todos los juegos para dar prioridad al control táctil nativo. + Minimizar + Ver + Jugar en el televisor + Iniciando transmisión + Posición en la cola: %1$d + Esperando un equipo + Conectando la transmisión + Reanudando la sesión + Preparando el equipo + Iniciando la sesión + Estado de la cola + ¡%1$s ya está listo para jugar! + Tu cola de GFN ha terminado. Toca para volver a la aplicación. + La transmisión continúa mientras la pantalla está apagada + No adquirido + Editor desconocido + Reanudar sesión en la nube + Aplicación %1$s + Cola %1$d + Iniciando + Todavía no hay una descripción disponible para este juego. + Distribución del teclado + Idioma del juego + Pegar desde el portapapeles + Siguiente + Reintentar + Iniciar + Omitir + Actualizar + Enviar + Aceptar + Deshacer + Instalar + Gestionar + Permitir + Activo + Listo + Comprobando + Mejor ruta disponible + Por delante + Espera + Temporizador de sesión + Borrar caché + Restablecer tutorial + Restablecer ajustes + Restablecer y reiniciar + Cambiar + Añadir cuenta + Cerrar sesión + Cerrar sesión en todas las cuentas + Elegir proveedor + Estadísticas de tiempo de juego + Almacenamiento en la nube + Añadir almacenamiento + Cambiar ubicación de almacenamiento + No hay ninguna transmisión activa + Volver a la biblioteca + Finalizar sesión en la nube + Paso %1$d de %2$d + Pulsa Hecho + Mando detectado + El mando en pantalla se ha ocultado porque hay un mando físico conectado. + No volver a mostrar + Iniciando vinculación con el teléfono… + Vincular con la aplicación OpenNOW del teléfono + Primero instala y abre OpenNOW en tu teléfono Android. Conecta el teléfono y el televisor a la misma red Wi‑Fi y escanea este código QR con la cámara del teléfono. El enlace caduca en cinco minutos. + Vinculación con TV + Vincular con un televisor + Mantén el teléfono y el televisor en la misma red Wi‑Fi. Escanea aquí el QR del televisor o búscalo e introduce su código de 4 dígitos. + Conectado a %1$s. Los juegos ahora muestran la acción Jugar en TV. + Conectado a %1$s + Iniciar sesión en TV + Olvidar TV + Escanea un QR o busca un televisor en tu red + Escanear QR del TV + No se pudo abrir el escáner QR + Buscar TV + Buscando… + Código del TV + Introduce el código de 4 dígitos que aparece en este televisor. + Vincular + Cuentas y servicios + Perfiles, suscripción, almacenamiento y tiendas de juegos + Juegos nuevos + Resultados + Banner destacado de la biblioteca + Diseño del mando táctil + Color del mando táctil + Letras de los botones + Apuntado con giroscopio + No se encontró ningún televisor OpenNOW en esta red. + Abrir perfil de la cuenta + Nombre de usuario + Nivel + Correo electrónico + Opciones de la cuenta + %1$s • %2$s + No disponible + Opciones de desarrollador + Reinicia flujos, inspecciona el entorno y reconstruye el estado local + Para desarrollo y soporte + Estas acciones solo restablecen el estado local de OpenNOW y muestran información que ya está en la exportación de diagnóstico. Las destructivas piden confirmación. Puedes volver a ocultar esta página desde el final de la lista. + Flujos y avisos + Catálogo y tiendas + Transmisión + Interfaz + Diagnóstico + Destructivo + Restablecer + Borrar + Ejecutar + Aplicar + Copiar + Repetir + Ocultar + Repetir el primer inicio + Configuración, guías, avisos, consentimiento y estado de navegación a la vez + La configuración se ejecutará de nuevo, todos los avisos únicos vuelven a aparecer, el consentimiento de análisis se retira hasta que lo respondas otra vez y el orden de Tienda y Biblioteca se restablece. Las cuentas, los favoritos y los ajustes de transmisión no se tocan. + Volver a ejecutar la configuración + Muestra las pantallas de primer inicio en el próximo arranque + Volver a mostrar la guía de transmisión + Reaparece la próxima vez que empiece una transmisión + Volver a mostrar el aviso del mando + Reaparece la próxima vez que se conecte un mando + Volver a pedir consentimiento de análisis + Queda desactivado hasta que se responda la pregunta de nuevo + Repetir las migraciones de actualización + Vuelve a aplicar los valores únicos de presentación y diseño de TV + Borrar la caché de juegos + Descarta los resultados en caché de Tienda, Biblioteca y búsqueda + Recargar el catálogo + Vuelve a cargar Tienda y Biblioteca desde el proveedor ahora + Restablecer el estado de navegación + Orden y filtros de Tienda y Biblioteca a sus valores predeterminados + Olvidar las elecciones de lanzador + Todas las selecciones de tienda recordadas por juego + Borrar favoritos + %1$d guardados + Se eliminarán todos los juegos marcados como favoritos. Esto no se puede deshacer. + Vaciar la estantería de apps + %1$d apps instaladas ancladas + Aplicar la recomendación medida + Restablecer el diseño táctil + Tamaño de la superposición, opacidad y todos los desplazamientos de botones + Actualizar las colas de servidores + Vuelve a consultar la lista de zonas de PrintedWaste y los pings + Restablecer la interfaz + Acento, fondo, diseño de tarjetas y valores de animación + Copiar el registro de diagnóstico + Depurado, el mismo texto que adjunta el informe de errores + Copiar el resumen del entorno + La tabla de arriba, como texto + Buscar actualizaciones + Ejecuta la comprobación de actualizaciones de inmediato + Cerrar sesión en todas las cuentas + %1$d guardadas + Todas las cuentas guardadas se eliminarán de este dispositivo y OpenNOW volverá a la pantalla de inicio de sesión. + Borrar datos y reiniciar la app + Devuelve OpenNOW a una instalación nueva + Se eliminarán las cuentas, los ajustes, los juegos en caché y los archivos locales, y OpenNOW se reiniciará como una instalación nueva. Esto no se puede deshacer. + Ocultar opciones de desarrollador + Toca diez veces el número de compilación en Acerca de para recuperarlas + Compilación + Variante + Dispositivo + Android + Perfil de diseño + Suscripción + Proveedor + Perfil de transmisión + Decodificadores por hardware + Juegos de Tienda / Biblioteca + Ninguno + Sesión cerrada + Android TV + Portátil + Estado de primer inicio restaurado + La configuración se ejecutará en el próximo inicio + La guía de transmisión volverá a mostrarse + El aviso del mando volverá a mostrarse + Se volverá a pedir el consentimiento de análisis + Las migraciones de actualización se repetirán + Estado de navegación restablecido + Elecciones de lanzador olvidadas + Favoritos borrados + Estantería de apps vaciada + Recomendación medida aplicada + Diseño táctil restablecido + Interfaz restablecida + Registro de diagnóstico copiado + Resumen del entorno copiado + Opciones de desarrollador ocultas + %1$d toques más para mostrar las opciones de desarrollador + Las opciones de desarrollador ya están en Ajustes + Las opciones de desarrollador ya se muestran + Siguiente + No disponible + Vibración + Vibración del mando cuando esté disponible; si no, la del dispositivo + Salida de vibración + Algunas consolas portátiles informan de un motor de vibración en su mando integrado que no está conectado a nada. Fuerza el motor del teléfono si la vibración en el juego sigue en silencio. + Automática + Mando + Teléfono + Puntería táctil + Bloquear joystick + Bloquear zona / puntería táctil + Arrastra en cualquier punto de la zona derecha para apuntar con vista relativa de ratón. + ZONA DE PUNTERÍA + Comunidad de Discord + Consigue ayuda y haz seguimiento de los informes de errores con la comunidad de OpenNOW. + Soporte de la comunidad y seguimiento de informes de errores + Unirse + Invitación de Discord copiada + Ordenar y filtrar + Ordenar y filtrar, %1$d activos + Ordenar + Populares + Jugados recientemente + Filtros + Controles + Controles táctiles móviles + El número de píxeles que envía el PC en la nube. Las resoluciones más altas se ven más nítidas, pero requieren más capacidad de decodificación, GPU y red. + Ajusta la forma de la transmisión a la pantalla. Una proporción incorrecta puede añadir barras negras o estiramiento; no acelera el decodificador. + «Recomendado» usa la pantalla, la memoria, el número de procesadores, el perfil de Android y los decodificadores WebRTC por hardware verificados de este dispositivo. «Personalizado» mantiene tus elecciones manuales. + Recomendación detectada: %1$s + Los fotogramas por segundo controlan la fluidez del movimiento. Más FPS dan al decodificador menos tiempo por fotograma y pueden causar tirones en hardware más lento. + La tasa máxima de datos de vídeo. Una tasa de bits mayor puede mejorar el detalle, pero solo cuando la conexión tiene suficiente capacidad estable; no aumenta los FPS. + H.264 es el más compatible. H.265 aprovecha mejor el ancho de banda y es preferible en resoluciones altas cuando existe un decodificador por hardware verificado. AV1 es de 8 bits aquí y solo se usa en dispositivos con una ruta de hardware compatible. + 8 bits 4:2:0 es el más ligero y compatible. 10 bits mejora los degradados, pero aumenta los requisitos de decodificación y ancho de banda. Android normaliza automáticamente las combinaciones no admitidas. + HDR necesita una pantalla de Android TV compatible, H.265, vídeo de 10 bits y una suscripción admitida. Añade carga de procesamiento y no se recomienda para diagnosticar retardo. + Añadir mis propias apps + Muestra una estantería en la Biblioteca donde puedes añadir, abrir y quitar apps y juegos de Android instalados. + Convertir en tu lanzador predeterminado + Abre el selector de lanzador de Android para que OpenNOW pueda ser la pantalla de inicio. Puedes cambiarlo de nuevo en los ajustes de Android. + Elegir lanzador + OpenNOW es el predeterminado + Gestionar + Mis propias apps + Añadir app + Elegir una app + Cargando apps instaladas… + Mostrar mis apps, %1$d instaladas + Ocultar mis apps, %1$d instaladas + No se encontraron otras apps que se puedan abrir. + Quitar %1$s + Esto solo quita el acceso directo de tu estantería. La app sigue instalada. + Quitar + Destacado + Cada aspecto es un mando distinto, no solo un color distinto: el corte de los botones, si la cruceta es una sola cruz o cuatro teclas separadas, y el espacio por el que se mueve el stick cambian con él. + Recolorea los aspectos construidos en torno a un acento. Clásico, Contorno, Escarcha y Alto contraste son monocromos por diseño. + «Desactivado» deja los botones sin letras cuando el diseño ya es memoria muscular. + Aspecto + Tamaño de los botones frontales + Tamaño de la cruceta + Tamaño de gatillos y botones superiores + Tamaño de menú y clic de stick + Tamaño del stick izquierdo + Tamaño del stick derecho + Tamaño de la cabeza del stick + Inclina el teléfono para apuntar con vista relativa de ratón. + Este dispositivo no informa de un giroscopio. + Sensibilidad del giroscopio + Zona muerta del giroscopio + Suavizado del giroscopio + Invertir giroscopio en horizontal + Invertir giroscopio en vertical + Puntería por movimiento + Banner rotatorio sobre la cuadrícula de la Biblioteca en teléfonos en vertical. + Esta app ya no está instalada o no se puede abrir. + Orden de la biblioteca + Jugados recientemente + Título A–Z + Contornos de selección animados + Anima los juegos seleccionados, las opciones de menú, las elecciones de servidor y las opciones de lanzador. Desactívalo para una selección más sobria. + Efectos Absolute Cinema + Usa anillos de enfoque animados en naranja y azul manteniendo el color de interfaz que has elegido. + Estoy loco + Suelta Absolute Cinema por toda la interfaz. Las ilustraciones, descripciones, controles y más reciben el efecto al pasar por encima y al enfocarlos. + Mostrar el icono de favorito en las tarjetas + Muestra un botón de favorito en las tarjetas de juego de móvil, portátil y TV. + Activado por defecto. Llena la pantalla en lugar de dejar barras negras, estirando la imagen solo en el eje que no coincide, nunca recortándola. Desactívalo para una geometría exacta. + Aplica un filtro de GPU adicional tras la decodificación. Puede mejorar el detalle percibido, pero añade carga de renderizado en dispositivos más lentos. + Controla la intensidad del filtro de nitidez de posprocesado. No cambia la resolución de la transmisión de origen. + Absolute Cinema + Switch + Empezar + Siguiente + Atrás + Omitir + Finalizar + GeForce NOW nativo para Android + Hazlo tuyo + Todo lo de aquí se aplica según lo eliges. + Acento + Animaciones de la interfaz + Brillos, destellos de enfoque y movimiento del carrusel. Desactivarlo también respeta el ajuste de animaciones del sistema. + Vista previa + Diseño + Cada uno vuelve a dibujar la vista previa + Títulos de juego bajo la ilustración + «Desactivado» deja la cuadrícula como pura ilustración de portada. + Tarjetas cuadradas + Recorta la portada en cuadrado para que quepan más juegos en pantalla. + Botón de favorito sobre la ilustración + Guarda un juego en tu Biblioteca sin abrirlo. + Esquinas redondeadas + Bordes más suaves en tarjetas y paneles de toda la app. + Absolute Cinema + Marcos de energía animados alrededor de lo que esté enfocado. Normalmente es un tratamiento de mando y TV. + Respuesta + Ambas se activan en tu próximo toque + Vibración + Un breve zumbido al seleccionar algo y vibración del mando en el juego donde el dispositivo lo permita. + Sonidos de la interfaz + Un tono al pulsar botones y navegar por los menús. No afecta al audio del juego. + Fondo + Desactivado + Predeterminado + Ninguno + Fondo de la app + Fondo de pantalla + Tu imagen + Calidad de transmisión + Medida a partir de la pantalla, el chipset y los decodificadores de este dispositivo. + Midiendo este dispositivo + Recomendada + Ahorro de datos + 720p, 30 FPS, 12 Mbps + Máxima calidad + Hasta %1$s a %2$d FPS con tu plan + Configurarlo yo + Elige abajo la resolución, la tasa de fotogramas y la tasa de bits + Suscripción %1$s + Tu plan transmite hasta %1$s a %2$d FPS. Las opciones superiores aparecen con el nivel que las desbloquea. + Mejorar tu suscripción a GeForce NOW eleva este límite; OpenNOW no lo restringe. + Durante la partida + Elige cómo se siente la transmisión. + Vista previa + Ratón táctil + Directo + Toca donde quieras hacer clic + Panel táctil + Desliza para mover y luego toca + Desactivado + Usa un mando o un ratón físico + Tocar = mover + clic + Desliza y luego toca + Mando / ratón + Jugar + Línea de estado + FPS, ping, batería y conexión de un vistazo. + Posición + 60 FPS • 24 ms • Wi-Fi + Cuando algo falla + Tirones, pantallas en negro, mandos que no responden. + El informe de errores integrado + Ábrelo desde los controles en transmisión o desde el informe que aparece al terminar una sesión. + Primero contrasta tus ajustes con la sesión y señala combinaciones problemáticas conocidas, normalmente con una solución. + Envía tu descripción más un diagnóstico depurado: ajustes, mediciones de decodificador y red, modelo del dispositivo. Ningún dato de la cuenta. + Informe de sesión tras cada transmisión + Latencia, ritmo de fotogramas y pérdida de paquetes al terminar una sesión, con un acceso directo al informe de errores. + Compartir diagnóstico anónimo + Ayuda a encontrar patrones en fallos y problemas de rendimiento. Los datos sensibles se eliminan y nada se vende. Con esto desactivado, un informe de fallo puede no aportar lo suficiente para investigarlo. + Listo + Puedes cambiar cualquier cosa en Ajustes. + Tus elecciones + Calidad de transmisión + Ratón táctil + Línea de estado + Activado + Desactivado + Configuración + Volver a ejecutar la configuración + Revisa la apariencia, la calidad de transmisión, los controles de juego, el estado y los informes de errores + Requiere %1$s + GeForce NOW indica %1$s solo como %2$s, y esta cuenta usa %3$s. Lo más probable es que la sesión se rechace o baje a un perfil inferior. + El derecho de acceso lo decide GeForce NOW, no OpenNOW, y el catálogo a veces está desactualizado, así que aún puedes intentarlo. + Intentarlo igualmente + Copiar error + Desconectar + Volver + Iniciar sesión + Compartir análisis + Comparte diagnósticos anónimos para ayudarnos a encontrar patrones en errores, fallos y problemas de rendimiento. Los datos sensibles se eliminan y no vendemos tus datos. + Si el uso compartido está desactivado durante un fallo, puede que no tengamos suficiente información para investigar tu informe. Está desactivado por defecto y se puede cambiar en los ajustes de privacidad. + Dejar desactivado + ¿Compartir diagnóstico? + Comprobando la última compilación… + Comprobando Google Play… + Comprobando esta sesión… + Comprobaciones + Se adjunta automáticamente el mismo registro con marca de tiempo disponible en Ajustes > Avanzado > Registros de depuración. No se añaden otros archivos. + Tus datos no se venden y solo se usan para investigar y corregir errores. + El registro automático elimina nombres de cuenta, credenciales, ID de sesión y direcciones de red antes de subirlo. El ID de dispositivo sin procesar no se envía. + ¿Qué se recopila? + El título y la descripción que escribas se envían exactamente como los redactes, así que no incluyas información personal ni sensible. + Los responsables de PrintedWaste y OpenNOW pueden ver el texto del informe, la versión/compilación de la app, el modelo del dispositivo, la versión de Android, el proveedor y la categoría de suscripción, el juego actual, el estado y los ajustes de transmisión, un identificador de instalación seudónimo para prevenir abusos y un registro de diagnóstico depurado. + ¿Enviar este informe y el diagnóstico depurado adjunto a la API de PrintedWaste? + ¿Enviar informe de error? + Doy mi consentimiento para enviar este informe. + Entiendo lo que se subirá y doy mi consentimiento para enviarlo a la API de PrintedWaste. + Describe el error en inglés. Se adjuntan los diagnósticos de la sesión. + ¿Qué ha pasado? + ¿Qué estabas haciendo, qué salió mal y puedes reproducirlo? + Se requiere inglés + Configura OpenNOW o el idioma del dispositivo en inglés antes de informar. + Describe el problema sin salir de tu juego. + Entiendo que OpenNOW ha encontrado una causa probable. Enviar igualmente; puedo perder el acceso a informar en el futuro. + SUGERENCIAS COINCIDENTES + No se sugieren soluciones irrelevantes para esta comprobación. + Comprobaciones en vivo de este dispositivo y esta sesión + Antes de informar + Reintentar la comprobación de versión + Revisar y enviar + Enviar otro + Enviar igualmente + Enviando… + Informe de error enviado + ¿Sigue ocurriendo tras aplicar alguna sugerencia? Continúa y las pruebas medidas se adjuntarán automáticamente. + Título del problema + La transmisión se congeló tras reconectar + Actualizar en Google Play + Subir informe + ¿Subir informe de error? + Subiendo informe… + Usar inglés en OpenNOW + Comprobando las colas y la latencia de PrintedWaste + Descripción + Filtros + Enrutamiento de cola del nivel gratuito + Capturas de pantalla + Botón Atrás del mando a distancia + Detalles + El dispositivo, el tipo de cuenta, el perfil de transmisión, el estado actual y la URL temporal se han copiado al portapapeles. + Diagnóstico copiado + OpenNOW eliminará los tokens, los identificadores de cuenta, las direcciones de correo, los ID de sesión y las direcciones de red antes de subirlo. + El enlace aleatorio no está listado pero no está cifrado, y el servicio elimina las subidas en 24 horas. + ¿Crear un enlace temporal de diagnóstico? + Eliminando valores sensibles y creando un enlace temporal… + Preparando el diagnóstico + No se pudo crear el código QR. Cierra este diálogo e inténtalo de nuevo. + Escanea este código QR con tu teléfono. El enlace depurado caduca en 24 horas. + Escanear el enlace de diagnóstico + Depurar y subir + No hay ningún navegador disponible + No se pudo abrir la página de la tienda + No se pudo iniciar la conexión con la tienda + No se pudo desconectar la tienda + Token de acceso + No se pudieron exportar los registros + Registros exportados + CÓDIGO DE VINCULACIÓN + Cliente nativo de GeForce NOW para Android + Pega un token de acceso de NVIDIA o el JSON de respuesta del token. OpenNOW verifica el token de acceso antes de guardar la cuenta. + Iniciar sesión con token + Usa solo credenciales de una cuenta que controles. + Usa un token para iniciar sesión sin el navegador, o exporta el diagnóstico antes de iniciar sesión. + Herramientas de inicio de sesión + Usar inicio de sesión con código + Publicidad + Posición en directo + Cola + ATRÁS + Infórmalo + ¿Has encontrado un error? + Perfil entregado + Ha sido una sesión corta, así que la puntuación puede variar más de lo habitual. + Informe de sesión + Qué hacer a continuación + Por qué cambió el perfil + Estos ajustes superan la recomendación detectada + Actividad en segundo plano + Optimizada (puede agotar el tiempo en segundo plano) + Sin límite (permitida en segundo plano) + La optimización de batería de Android restringe la actividad en segundo plano de la app, lo que puede provocar tiempos de espera de conexión o pausar el avance en la cola de GFN cuando la app está minimizada. + Se eliminarán los resultados en caché de tienda, biblioteca y búsqueda. Tu cuenta y tus ajustes no cambian. + ¿Borrar la caché de juegos? + Ayuda con la conexión + Exporta el estado de inicio, el estado de la cola, las actualizaciones de transmisión, los eventos de recuperación, los ajustes, las capacidades de códec y las respuestas JSON de CloudMatch depuradas recientes. + Desarrollador + Esta cuenta no tiene activo ningún complemento de almacenamiento persistente. + Notas de la versión + Se eliminarán las cuentas, los ajustes, los juegos en caché, el estado del tutorial y los archivos locales de la app. OpenNOW se reiniciará como una instalación nueva. + «Restablecer tutorial» solo hace que la guía de transmisión vuelva a aparecer. «Restablecer ajustes» es destructivo: borra los datos locales de la app y reinicia OpenNOW. + ¿Restablecer ajustes y datos de la app? + Selecciona el proveedor de GeForce NOW que usará la nueva cuenta. + Uso de almacenamiento + Conexiones con tiendas + Los valores sensibles se eliminan antes de crear un enlace temporal no listado. Escanea el código QR con tu teléfono para compartirlo. + Subir registros y mostrar QR + Esto envía el resumen del cambio de perfil y la causa probable a los responsables de PrintedWaste y OpenNOW para que puedan investigarlo. + ¿Enviar diagnóstico de transmisión? + Enviar diagnóstico + OpenNOW no tiene ninguna transmisión local asociada en este momento. + El perfil de transmisión ha cambiado + Por qué ha ocurrido + Enviando el informe y el diagnóstico depurado… + Tus ajustes de transmisión guardados no se han modificado. + Ya hay una sesión en la nube activa + Terminar e iniciar una nueva + Escribe o edita el texto de la transmisión + La caché de juegos ya estaba vacía + Caché de juegos borrada + Borrando datos de la app y reiniciando OpenNOW + Sesión iniciada de forma segura desde el teléfono + Tienda desconectada + El tutorial se mostrará en la próxima transmisión + Arrastrar + + + %1$d servidor + %1$d de servidores + %1$d servidores + + diff --git a/android/app/src/main/res/values-fr/strings.xml b/android/app/src/main/res/values-fr/strings.xml new file mode 100644 index 000000000..fc6840134 --- /dev/null +++ b/android/app/src/main/res/values-fr/strings.xml @@ -0,0 +1,837 @@ + + + OpenNOW + Démarrage d’OpenNOW + Se connecter avec %1$s + Se connecter sur un autre appareil avec %1$s + Utilisez ce code pour vous connecter + %1$s + En attente de connexion + Le code expire dans %1$d:%2$02d + Boutique + Rechercher + Bibliothèque + Paramètres + Rechercher des jeux + Rechercher dans les paramètres + Général + Mises à jour, confidentialité et données de l’application + Langue + Langue de l’application + Langue du système + Anglais + Streaming + Résolution, FPS, codec, HDR, proxy + Entrée + Microphone, souris, clavier, commandes tactiles, vibrations + Interface + Apparence, bibliothèque, barre d’état et sons + Compte + Connexion, stockage, boutiques associées + Avancé + Options avancées, expériences, diagnostics et journaux + À propos + Version, crédits et assistance + Afficher les titres des jeux + Effacer la recherche + Recherche vocale + %1$d jeux + Aucun jeu chargé + Aucun jeu correspondant dans la bibliothèque + Effacez la recherche pour afficher tous les jeux de votre bibliothèque. + Effacez les filtres pour afficher tous les jeux de votre bibliothèque. + Effacez la recherche ou les filtres pour afficher tous les jeux de votre bibliothèque. + Aucun jeu correspondant dans la boutique + Effacez la recherche pour afficher plus de jeux. + Effacez les filtres pour afficher plus de jeux. + Effacez la recherche ou les filtres pour afficher plus de jeux. + Reprendre + Prochainement + Nouveautés de GeForce NOW + Continuer à jouer + Dans la file d’attente + Favoris + Recommandations + Tout afficher + Jouer + Continuer + Reprendre + Enregistrer + Enregistré + Ajouter aux favoris + Retirer des favoris + Annuler + Activé + Désactivé + Visible + Masqué + Retour + Ouvrir + Réinitialiser + Fermer + Commandes du streaming + Quitter + Terminé + Ouvrir la saisie clavier + Affichage + Entrée + Assistance + Manette + Disposition tactile + Audio + Muet + Barre d’état + %1$s · %2$d éléments + Netteté du streaming + Niveau de netteté + Étirer pour ajuster + En direct + %1$d Mbit/s actifs dans cette session + Paramètres › Streaming s’applique uniquement à la prochaine session + Microphone + Autorisation requise + Menu Steam + Envoyer Accueil au PC distant + Échap. + Entrée + + Souris à la manette + Stick droit · A clic · B clic droit + Souris au doigt + Clic direct + Manette tactile + Commandes tactiles intégrées détectées + Ce jeu prend en charge les commandes tactiles intégrées. Vous pouvez tout de même activer ci-dessous la manette tactile d’OpenNOW. + Ce jeu prend en charge les commandes tactiles intégrées. La manette tactile d’OpenNOW est activée pour cette session. + Commandes intégrées actives + Joysticks + Fixe + Dynamique + Vibration du téléphone en remplacement + Mode souris à la manette + Stick G déplace · Stick D fait défiler · A clique · B fait un clic droit + Mode souris + Configurer l’émulation de la souris + Commandes tactiles + Disposition de la manette, joysticks et vibrations + Signaler un problème + Exécuter des vérifications et envoyer des diagnostics expurgés + Mode de déplacement + Réinitialiser la disposition tactile + Rétablir les positions par défaut + Échelle de la disposition + Taille des boutons + Opacité + Marge des bords + Marge inférieure + Position gauche + Position droite + Joysticks + Régler les commandes analogiques tactiles + Placement dynamique + Démarre centré sous votre pouce + Utilise le centre fixe enregistré + Taille du joystick + Zone morte + Le mode dynamique conserve la zone enregistrée du joystick, mais considère le premier point où votre pouce se pose comme neutre. Cela évite un mouvement brusque si vous manquez le centre exact. + Barre d’état + Choisir sa disposition et ses informations + Apparence + Position + Éléments + FPS + Ping + Débit + Batterie + Connexion + Résolution + Codec + Serveur + Déc. / Var. + Perte + Clavier + %1$d/100 + %1$s + Ne plus afficher les rapports de session + Connexion + Non mesuré + Latence + Vitesse du streaming + Perte de paquets + Variation + Fréquence d’images + Décodage + %1$d ms en moyenne + Pic de %1$d ms + Pic de %1$s + Stable + Peut affecter la netteté + Variation de synchronisation + FPS moyens / cibles + Par image vidéo + Contrôle de la session + Quitter le streaming ? + Voulez-vous vraiment quitter %1$s ? + Votre session de jeu dans le cloud actuelle sera fermée. + Continuer à jouer + Quitter le streaming + Signalement de bugs + Signaler un bug + Signaler un bug de streaming + Envoyer un problème et des diagnostics expurgés + Afficher la description + Masquer la description + Ping %1$s + Déc. %1$s ms + Var. %1$s ms + Perte %1$s%% + %1$d images par seconde + Ping de %1$d millisecondes + Temps de décodage : %1$s millisecondes par image + Variation de %1$s millisecondes + Perte de paquets de %1$s pour cent + bonne + correcte + mauvaise + Fermer + Jouer sur %1$s + Effacer les filtres + Retour en haut + Automatique + Prochainement + Choisir le lanceur + Lanceurs + Par défaut + Sélectionné + Lanceur disponible + Ne plus demander — définir cette boutique par défaut + Poursuite avec la boutique par défaut : %1$s + Astuce : appuyez longuement sur Jouer pour choisir une autre boutique plus tard. + Appuyer longuement sur Jouer pour choisir une boutique + Streaming + Interface + Qualité + Vidéo + Connexion + Audio et clavier + Entrée du pointeur + Verrouillage de la souris + Maintient une souris externe capturée dans le jeu pendant le streaming. L’ouverture des commandes du streaming la libère. + Manette et tactile + Apparence + Bibliothèque et navigation + Barre d’état + Sons et sessions + Afficher le rapport de session + Afficher un résumé de la qualité après chaque streaming. + Outils avancés + Remerciements + Résolution + Format d’image + Préréglage du streaming + Recommandé + Personnalisé + Faible (économie de données) + Moyen + Élevé + FPS + Débit en Mbit/s + Codec + Couleur + H.264/H.265 uniquement + AV1 utilise des couleurs 8 bits. Choisissez H.265 pour 10 bits ; le HDR est limité aux modes Android TV compatibles. + AV1 utilise des couleurs 8 bits dans OpenNOW. Passage à 8 bits et HDR désactivé. Choisissez H.265 ou H.264 pour utiliser 10 bits. + HDR (Performance & Ultimate) + Le streaming HDR n’est pas disponible sur les appareils Android portables. Le SDR 10 bits reste disponible avec H.265. + Le HDR sur Android TV nécessite H.265 à 60 FPS ou moins et une résolution maximale de 3840 × 2160. + Région + Proxy de session + Achemine la création de session GFN et l’interrogation de la file d’attente via ce proxy. Laissez désactivé pour des requêtes directes. + URL du proxy + Copier le diagnostic du codec + Diagnostic du codec copié + Le test du codec n’a pas encore été exécuté. + Activer le proxy de session ? + La création de session GFN, l’interrogation de la file, la reprise, l’arrêt et les mises à jour publicitaires de la file seront acheminés via le proxy saisi. + Un proxy incorrect ou bloqué peut empêcher le lancement, la progression de la file, la reprise d’une session active ou son nettoyage. + Utilisez uniquement un proxy fiable. Son opérateur pourrait observer l’heure des requêtes, les hôtes de destination et des métadonnées sensibles du trafic de session. + Activer le proxy + Streaming expérimental + Peut provoquer des échecs de lancement de session. + L4S + Demande le chemin de transport NVIDIA à faible latence et faibles pertes lorsque le serveur et le réseau le permettent. Laissez désactivé si votre réseau devient instable. + Demande Cloud G-Sync / VRR + Demande à la session cloud d’utiliser une fréquence de rafraîchissement variable si votre appareil, écran, abonnement et session GFN le permettent. + Microphone + Envoie le microphone Android par défaut au jeu distant. Vous pouvez le couper depuis les commandes du streaming. + L’autorisation du microphone n’a pas été accordée. OpenNOW gardera le streaming du microphone désactivé. + Utiliser les couleurs du système + Couleur d’accentuation + Page de démarrage + Boutique + Bibliothèque + Désactiver la recherche de mises à jour + Options avancées + Affiche les options expérimentales du catalogue et de réglage. L’onglet Diagnostics avancés reste disponible. + Style de cartes expressif + Utilise des surfaces de carte plus lumineuses et des angles plus doux. Désactivez-le pour un style Material plus plat et discret. + Arrière-plan du catalogue + Affiche une image derrière la Boutique et la Bibliothèque sur les écrans portables. + Image d’arrière-plan + Image personnalisée + Arrière-plan intégré + Abstrait coloré (par défaut) + OpenNOW original + Cinéma absolu + Choisir une image + Utiliser la valeur par défaut + Marge du bord de l’écran + Cartes de jeu compactes + Afficher les étiquettes de boutique + Taille des cartes de jeu + Masquer les boutons du streaming + Bouton du clavier à l’écran + Affiche une icône de clavier compacte dans la barre d’état du streaming. + Afficher la barre d’état par défaut + Position des statistiques superposées + Masquer le sélecteur de serveur + Sons de pression des boutons + Joue un bref son d’interface lors de la navigation à la manette et de l’utilisation des commandes à l’écran. + Jouer la musique d’introduction + La musique d’introduction démarre + En sourdine + En lecture + Jouer de la musique à la fin de la file + Couper la musique + Étirer le streaming pour remplir l’écran + Minuteur de session intelligent + Merci à toutes les personnes qui contribuent à améliorer OpenNOW pour tous. + DarkevilPT + Soutien de la communauté + Faire un don + Lien de don copié + OpenNOW + Bleu Pixel + Rose vif + Citron vert + Corail + Violet + Streamer natif (expérimental) + Intercepte le décodeur matériel pour injecter des propriétés de faible latence propres au fabricant. Peut être instable. + Le mode Automatique du tactile natif utilise le mode manette pour les streamings haute résolution ou à FPS élevés afin de préserver le mode choisi. Choisissez Tous les jeux pour privilégier le tactile natif. + Réduire + Afficher + Jouer sur le téléviseur + Démarrage du streaming + Position dans la file : %1$d + En attente d’une machine + Connexion au streaming + Reprise de la session + Préparation de la machine + Démarrage de la session + État de la file d’attente + %1$s est prêt à jouer ! + Votre attente GFN est terminée. Appuyez pour revenir à l’application. + Le streaming continue lorsque l’écran est éteint + Non possédé + Éditeur inconnu + Reprendre la session cloud + Application %1$s + File %1$d + Démarrage + Aucune description n’est encore disponible pour ce jeu. + Disposition du clavier + Langue du jeu + Coller depuis le presse-papiers + Suivant + Réessayer + Lancer + Passer + Actualiser + Envoyer + OK + Annuler + Installer + Gérer + Autoriser + Actif + Prêt + Vérification + Meilleur itinéraire disponible + Devant + Attente + Minuteur de session + Vider le cache + Réinitialiser le tutoriel + Réinitialiser les paramètres + Réinitialiser et relancer + Changer + Ajouter un compte + Se déconnecter + Déconnecter tous les comptes + Choisir le fournisseur + Statistiques du temps de jeu + Stockage cloud + Ajouter du stockage + Modifier l’emplacement du stockage + Aucun stream actif + Retour à la bibliothèque + Terminer la session cloud + Étape %1$d sur %2$d + Appuyez sur Terminé + Manette détectée + La manette à l’écran a été masquée car une manette physique est connectée. + Ne plus afficher + Démarrage de l’association au téléphone… + Associer à l’application OpenNOW du téléphone + Installez et ouvrez d’abord OpenNOW sur votre téléphone Android. Connectez le téléphone et le téléviseur au même Wi‑Fi, puis scannez ce code QR avec l’appareil photo du téléphone. Le lien expire après cinq minutes. + Association au téléviseur + Associer à un téléviseur + Gardez le téléphone et le téléviseur sur le même Wi‑Fi. Scannez ici le code QR du téléviseur, ou recherchez-le et saisissez son code à 4 chiffres. + Connecté à %1$s. Les jeux affichent maintenant l’action Jouer sur le téléviseur. + Connecté à %1$s + Connecter le téléviseur + Oublier le téléviseur + Scanner un code QR ou trouver un téléviseur sur le réseau + Scanner le QR du téléviseur + Impossible d’ouvrir le scanner QR + Trouver un téléviseur + Recherche… + Code du téléviseur + Saisissez le code à 4 chiffres affiché sur ce téléviseur. + Associer + Comptes et services + Profils, abonnement, stockage et boutiques de jeux + Nouveaux jeux + Résultats + Bannière en vedette de la bibliothèque + Apparence de la manette tactile + Couleur de la manette tactile + Lettres des boutons + Visée au gyroscope + Aucun téléviseur OpenNOW n’a été trouvé sur ce réseau. + Ouvrir le profil du compte + Nom d’utilisateur + Niveau + E-mail + Options du compte + %1$s • %2$s + Non disponible + Options pour les développeurs + Réinitialiser des parcours, inspecter l’exécution et reconstruire l’état local + Pour le développement et l’assistance + Ces actions réinitialisent uniquement l’état local d’OpenNOW et affichent des informations déjà présentes dans l’export de diagnostic. Les actions destructives demandent confirmation. Vous pouvez masquer cette page à nouveau depuis le bas de la liste. + Parcours et invites + Catalogue et boutiques + Flux + Interface + Diagnostics + Destructif + Réinitialiser + Effacer + Exécuter + Appliquer + Copier + Rejouer + Masquer + Rejouer le premier lancement + Configuration, guides, invites, consentement et état de navigation d’un coup + La configuration sera relancée, toutes les invites uniques réapparaissent, le consentement analytique est retiré jusqu’à ce que vous répondiez à nouveau, et le tri de la Boutique et de la Bibliothèque est réinitialisé. Les comptes, les favoris et les réglages de flux ne sont pas touchés. + Relancer la configuration + Affiche les écrans de premier lancement au prochain démarrage + Réafficher le guide de flux + Réapparaît au prochain démarrage d’un flux + Réafficher l’invite de manette + Réapparaît à la prochaine connexion d’une manette + Redemander le consentement analytique + Désactive le partage jusqu’à ce que la question soit à nouveau posée + Rejouer les migrations de mise à niveau + Réapplique les valeurs uniques de présentation et de mise en page TV + Vider le cache des jeux + Supprime les résultats en cache de la Boutique, de la Bibliothèque et des recherches + Recharger le catalogue + Recharge la Boutique et la Bibliothèque depuis le fournisseur maintenant + Réinitialiser l’état de navigation + Tri et filtres de la Boutique et de la Bibliothèque par défaut + Oublier les choix de lanceur + Toutes les sélections de boutique mémorisées par jeu + Effacer les favoris + %1$d enregistrés + Tous les jeux mis en favori seront supprimés. Cette action est irréversible. + Vider l’étagère d’applis + %1$d applis installées épinglées + Appliquer la recommandation mesurée + Réinitialiser la disposition tactile + Taille de la surcouche, opacité et tous les décalages de boutons + Actualiser les files de serveurs + Réinterroge la liste des zones PrintedWaste et les pings + Réinitialiser l’interface + Accent, arrière-plan, disposition des cartes et valeurs d’animation + Copier le journal de diagnostic + Expurgé, le même texte que joint le rapport de bug + Copier le résumé d’exécution + Le tableau ci-dessus, sous forme de texte + Rechercher une mise à jour + Lance immédiatement la vérification de mise à jour + Se déconnecter de tous les comptes + %1$d enregistrés + Tous les comptes enregistrés seront supprimés de cet appareil et OpenNOW reviendra à l’écran de connexion. + Effacer les données et relancer + Ramène OpenNOW à une installation neuve + Les comptes, réglages, jeux en cache et fichiers locaux seront supprimés, et OpenNOW redémarrera comme une installation neuve. Cette action est irréversible. + Masquer les options pour développeurs + Appuyez dix fois sur le numéro de build dans À propos pour les retrouver + Build + Variante + Appareil + Android + Profil de mise en page + Abonnement + Fournisseur + Profil de flux + Décodeurs matériels + Jeux Boutique / Bibliothèque + Aucun + Déconnecté + Android TV + Console portable + État de premier lancement restauré + La configuration s’exécutera au prochain démarrage + Le guide de flux sera réaffiché + L’invite de manette sera réaffichée + Le consentement analytique sera redemandé + Les migrations de mise à niveau seront rejouées + État de navigation réinitialisé + Choix de lanceur oubliés + Favoris effacés + Étagère d’applis vidée + Recommandation mesurée appliquée + Disposition tactile réinitialisée + Interface réinitialisée + Journal de diagnostic copié + Résumé d’exécution copié + Options pour développeurs masquées + Encore %1$d appuis pour afficher les options pour développeurs + Les options pour développeurs sont maintenant dans les Paramètres + Les options pour développeurs sont déjà affichées + Suivant + Indisponible + Vibration + Vibration de la manette si disponible ; sinon, celle de l’appareil + Sortie de vibration + Certaines consoles portables signalent un moteur de vibration sur leur manette intégrée qui n’est relié à rien. Forcez le moteur du téléphone si la vibration en jeu reste silencieuse. + Automatique + Manette + Téléphone + Visée tactile + Verrouiller le joystick + Verrouiller la zone / visée tactile + Faites glisser n’importe où dans la zone de droite pour une visée à la souris relative. + ZONE DE VISÉE + Communauté Discord + Obtenez de l’aide et suivez vos rapports de bug avec la communauté OpenNOW. + Assistance de la communauté et suivi des rapports de bug + Rejoindre + Invitation Discord copiée + Trier et filtrer + Trier et filtrer, %1$d actifs + Trier + Populaires + Joués récemment + Filtres + Commandes + Commandes tactiles mobiles + Le nombre de pixels envoyés par le PC dans le cloud. Les résolutions élevées paraissent plus nettes mais demandent plus de capacité de décodage, de GPU et de réseau. + Adapte la forme du flux à l’écran. Un rapport incorrect peut ajouter des bandes noires ou étirer l’image ; il n’accélère pas le décodeur. + « Recommandé » utilise l’écran, la mémoire, le nombre de cœurs, le profil Android et les décodeurs matériels WebRTC vérifiés de cet appareil. « Personnalisé » conserve vos choix manuels. + Recommandation détectée : %1$s + Les images par seconde déterminent la fluidité du mouvement. Un débit d’images élevé laisse moins de temps au décodeur par image et peut provoquer des saccades sur du matériel lent. + Le débit vidéo maximal. Un débit plus élevé peut améliorer le détail, mais seulement si la connexion dispose d’une capacité stable suffisante ; il n’augmente pas les FPS. + H.264 est le plus compatible. H.265 utilise la bande passante plus efficacement et est préférable en haute résolution lorsqu’un décodeur matériel vérifié existe. AV1 est en 8 bits ici et n’est utilisé que sur les appareils disposant d’une chaîne matérielle compatible. + Le 8 bits 4:2:0 est le plus léger et le plus compatible. Le 10 bits améliore les dégradés mais augmente les exigences de décodage et de bande passante. Android normalise automatiquement les combinaisons non prises en charge. + Le HDR nécessite un écran Android TV compatible, H.265, une vidéo 10 bits et un abonnement pris en charge. Il ajoute de la charge de traitement et n’est pas recommandé pour diagnostiquer la latence. + Ajouter mes propres applis + Affiche une étagère dans la Bibliothèque où vous pouvez ajouter, lancer et retirer des applis et jeux Android installés. + En faire votre lanceur par défaut + Ouvre le sélecteur de lanceur d’Android pour qu’OpenNOW puisse devenir l’écran d’accueil. Vous pouvez le modifier à nouveau dans les paramètres Android. + Choisir un lanceur + OpenNOW est le lanceur par défaut + Gérer + Mes propres applis + Ajouter une appli + Choisir une appli + Chargement des applis installées… + Afficher mes applis, %1$d installées + Masquer mes applis, %1$d installées + Aucune autre appli lançable n’a été trouvée. + Retirer %1$s + Cela retire uniquement le raccourci de votre étagère. L’appli reste installée. + Retirer + À la une + Chaque habillage est une manette différente, pas seulement une couleur différente : la découpe des boutons, le fait que la croix directionnelle soit d’un seul bloc ou en quatre touches séparées, et l’espace dans lequel le stick se déplace changent avec lui. + Recolorise les habillages construits autour d’un accent. Classique, Contour, Givre et Contraste élevé sont monochromes par conception. + « Désactivé » laisse les boutons vierges une fois la disposition acquise par automatisme. + Habillage + Taille des boutons d’action + Taille de la croix directionnelle + Taille des gâchettes et des tranches + Taille des boutons menu et clic de stick + Taille du stick gauche + Taille du stick droit + Taille de la tête de stick + Inclinez le téléphone pour une visée à la souris relative. + Cet appareil ne signale pas de gyroscope. + Sensibilité du gyroscope + Zone morte du gyroscope + Lissage du gyroscope + Inverser le gyroscope à l’horizontale + Inverser le gyroscope à la verticale + Visée par mouvement + Bannière rotative au-dessus de la grille de la Bibliothèque sur les téléphones en mode portrait. + Cette appli n’est plus installée ou ne peut pas être ouverte. + Ordre de la bibliothèque + Joués récemment + Titre A–Z + Contours de sélection animés + Anime les jeux sélectionnés, les éléments de menu, les choix de serveur et les options de lanceur. Désactivez-le pour une sélection plus sobre. + Effets Absolute Cinema + Utilise des anneaux de focus animés orange et bleus tout en conservant la couleur d’interface que vous avez choisie. + Je suis fou + Lâchez Absolute Cinema sur toute l’interface. Les visuels survolés et ciblés, les descriptions, les commandes et bien plus reçoivent l’effet. + Afficher l’icône de favori sur les cartes + Affiche un bouton favori sur les cartes de jeu mobile, console portable et TV. + Activé par défaut. Remplit l’écran au lieu de laisser des bandes noires, en étirant l’image uniquement sur l’axe non concordant — jamais en la rognant. Désactivez-le pour une géométrie exacte. + Applique un filtre GPU supplémentaire après le décodage. Cela peut améliorer le détail perçu mais ajoute de la charge de rendu sur les appareils lents. + Contrôle l’intensité du filtre de netteté de post-traitement. Cela ne modifie pas la résolution du flux source. + Absolute Cinema + Switch + Commencer + Suivant + Retour + Ignorer + Terminer + GeForce NOW natif pour Android + Personnalisez + Tout ici s’applique au fur et à mesure de vos choix. + Accent + Animations de l’interface + Miroitement, halos de focus et mouvement du carrousel. Désactiver cette option respecte aussi le réglage d’animation du système. + Aperçu + Disposition + Chacune redessine l’aperçu + Titres des jeux sous les visuels + « Désactivé » laisse la grille en jaquettes pures. + Cartes carrées + Rogne la jaquette en carré pour afficher plus de jeux à l’écran. + Bouton favori sur le visuel + Enregistre un jeu dans votre Bibliothèque sans l’ouvrir. + Coins arrondis + Bords de cartes et de panneaux plus doux dans toute l’appli. + Absolute Cinema + Cadres d’énergie animés autour de l’élément ciblé. Habituellement un traitement manette et TV. + Retour + Les deux se déclenchent à votre prochain appui + Retour haptique + Une brève vibration lors d’une sélection, et la vibration de la manette en jeu là où l’appareil le permet. + Sons de l’interface + Une tonalité lors des appuis sur les boutons et de la navigation dans les menus. N’affecte pas le son du jeu. + Arrière-plan + Désactivé + Par défaut + Aucun + Arrière-plan de l’appli + Fond d’écran + Votre image + Qualité du flux + Mesurée à partir de l’écran, du chipset et des décodeurs de cet appareil. + Mesure de cet appareil + Recommandée + Économiseur de données + 720p, 30 FPS, 12 Mb/s + Qualité maximale + Jusqu’à %1$s à %2$d FPS avec votre formule + Je règle moi-même + Choisissez ci-dessous la résolution, la fréquence d’images et le débit + Abonnement %1$s + Votre formule diffuse jusqu’à %1$s à %2$d FPS. Les options supérieures sont indiquées avec le niveau qui les débloque. + Améliorer votre abonnement GeForce NOW relève ce plafond — OpenNOW ne le limite pas. + Pendant le jeu + Choisissez le ressenti du flux. + Aperçu + Souris tactile + Direct + Touchez là où vous voulez cliquer + Pavé tactile + Glissez pour déplacer, puis touchez + Désactivé + Utilisez une manette ou une souris physique + Toucher = déplacer + cliquer + Glissez, puis touchez + Manette / souris + Jouer + Ligne d’état + FPS, ping, batterie et connexion en un coup d’œil. + Position + 60 FPS • 24 ms • Wi-Fi + Quand quelque chose casse + Saccades, écrans noirs, manettes qui ne répondent plus. + Le rapport de bug intégré + Ouvrez-le depuis les commandes en cours de flux, ou depuis le rapport proposé à la fin d’une session. + Il confronte d’abord vos réglages à la session et signale les combinaisons problématiques connues, généralement avec un correctif. + Il envoie votre description ainsi qu’un diagnostic expurgé — réglages, mesures de décodage et de réseau, modèle d’appareil. Aucune donnée de compte. + Rapport de session après chaque flux + Latence, régularité des images et perte de paquets à la fin d’une session, avec un raccourci vers le rapport de bug. + Partager des diagnostics anonymes + Aide à repérer des tendances parmi les plantages et les problèmes de performance. Les données sensibles sont supprimées et rien n’est vendu. Sans cela, un rapport de plantage peut ne pas contenir assez d’éléments pour enquêter. + Terminé + Vous pouvez tout modifier dans les Paramètres. + Vos choix + Qualité du flux + Souris tactile + Ligne d’état + Activé + Désactivé + Configuration + Relancer la configuration + Revoir l’apparence, la qualité du flux, les commandes de jeu, l’état et les rapports de bug + Nécessite %1$s + GeForce NOW indique %1$s uniquement en %2$s, et ce compte est en %3$s. La session sera très probablement refusée ou rétrogradée vers un profil inférieur. + L’éligibilité est décidée par GeForce NOW, pas par OpenNOW, et le catalogue est parfois obsolète — vous pouvez donc quand même essayer. + Essayer quand même + Copier l’erreur + Déconnecter + Retour + Se connecter + Partager les analyses + Partagez des diagnostics anonymes pour nous aider à repérer des tendances parmi les bugs, les plantages et les problèmes de performance. Les données sensibles sont supprimées et nous ne vendons pas vos données. + Si le partage est désactivé lors d’un plantage, nous n’aurons peut-être pas assez d’informations pour enquêter sur votre rapport. Il est désactivé par défaut et modifiable dans les paramètres de confidentialité. + Laisser désactivé + Partager les diagnostics ? + Vérification du dernier build… + Vérification de Google Play… + Vérification de cette session… + Vérifications + Le même journal horodaté disponible dans Paramètres > Avancé > Journaux de débogage est joint automatiquement. Aucun autre fichier n’est ajouté. + Vos données ne sont pas vendues et servent uniquement à enquêter sur les bugs et à les corriger. + Le journal automatique supprime les noms de compte, les identifiants, les ID de session et les adresses réseau avant l’envoi. L’identifiant brut de l’appareil n’est pas envoyé. + Que collecte-t-on ? + Le titre et la description que vous saisissez sont envoyés tels quels : n’y incluez donc aucune information personnelle ou sensible. + Les mainteneurs de PrintedWaste et d’OpenNOW peuvent consulter le texte du rapport, la version/le build de l’appli, le modèle d’appareil, la version d’Android, le fournisseur et la catégorie d’abonnement, le jeu en cours, l’état et les réglages du flux, un identifiant d’installation pseudonyme pour prévenir les abus, et un journal de diagnostic expurgé. + Envoyer ce rapport et le diagnostic expurgé joint à l’API PrintedWaste ? + Envoyer le rapport de bug ? + Je consens à envoyer ce rapport. + Je comprends ce qui sera envoyé et je consens à le transmettre à l’API PrintedWaste. + Décrivez le bug en anglais. Les diagnostics de session sont joints. + Que s’est-il passé ? + Que faisiez-vous, qu’est-ce qui a mal tourné, et pouvez-vous le reproduire ? + Anglais requis + Réglez OpenNOW ou la langue de l’appareil sur l’anglais avant de signaler. + Décrivez le problème sans quitter votre jeu. + Je comprends qu’OpenNOW a trouvé une cause probable. Envoyer quand même ; je risque de perdre l’accès aux signalements. + SUGGESTIONS CORRESPONDANTES + Aucun correctif hors sujet n’est proposé pour cette vérification. + Vérifications en direct depuis cet appareil et cette session + Avant de signaler + Réessayer la vérification de version + Vérifier et envoyer + En envoyer un autre + Envoyer quand même + Envoi… + Rapport de bug envoyé + Le problème persiste après une suggestion correspondante ? Continuez et les preuves mesurées seront jointes automatiquement. + Titre du problème + Le flux s’est figé après une reconnexion + Mettre à jour dans Google Play + Envoyer le rapport + Envoyer le rapport de bug ? + Envoi du rapport… + Utiliser l’anglais pour OpenNOW + Vérification des files et de la latence PrintedWaste + Description + Filtres + Routage de file du niveau gratuit + Captures d’écran + Bouton Retour de la télécommande + Détails + L’appareil, le type de compte, le profil de flux, l’état actuel et l’URL temporaire ont été copiés dans le presse-papiers. + Diagnostics copiés + OpenNOW supprimera les jetons, les identifiants de compte, les adresses e-mail, les ID de session et les adresses réseau avant l’envoi. + Le lien aléatoire n’est pas répertorié mais n’est pas chiffré, et le service supprime les envois sous 24 heures. + Créer un lien de diagnostic temporaire ? + Suppression des valeurs sensibles et création d’un lien temporaire… + Préparation des diagnostics + Impossible de créer le code QR. Fermez cette fenêtre et réessayez. + Scannez ce code QR avec votre téléphone. Le lien expurgé expire sous 24 heures. + Scanner le lien de diagnostic + Expurger et envoyer + Aucun navigateur disponible + Impossible d’ouvrir la page de la boutique + Impossible de démarrer la connexion à la boutique + Impossible de déconnecter la boutique + Jeton d’accès + Impossible d’exporter les journaux + Journaux exportés + CODE D’APPAIRAGE + Client GeForce NOW natif pour Android + Collez un jeton d’accès NVIDIA ou le JSON de réponse du jeton. OpenNOW vérifie le jeton d’accès avant d’enregistrer le compte. + Se connecter avec un jeton + N’utilisez que les identifiants d’un compte qui vous appartient. + Utilisez un jeton pour vous connecter sans navigateur, ou exportez les diagnostics avant de vous connecter. + Outils de connexion + Utiliser la connexion par code + Publicité + Position en direct + File d’attente + RETOUR + Signalez-le + Vous avez rencontré un bug ? + Profil délivré + La session a été courte, le score peut donc varier plus que d’habitude. + Rapport de session + Que faire ensuite + Pourquoi le profil a changé + Ces réglages dépassent la recommandation détectée + Activité en arrière-plan + Optimisée (peut expirer en arrière-plan) + Illimitée (autorisée en arrière-plan) + L’optimisation de la batterie d’Android restreint l’activité de l’appli en arrière-plan, ce qui peut provoquer des délais de connexion dépassés ou suspendre la progression dans la file GFN lorsque l’appli est réduite. + Les résultats en cache de la boutique, de la bibliothèque et des recherches seront supprimés. Votre compte et vos réglages restent inchangés. + Vider le cache des jeux ? + Aide à la connexion + Exporte l’état de lancement, l’état de la file, les mises à jour du flux, les événements de récupération, les réglages, les capacités de codec et les réponses JSON CloudMatch expurgées récentes. + Développeur + Aucune option de stockage persistant n’est active pour ce compte. + Notes de version + Les comptes, réglages, jeux en cache, l’état du tutoriel et les fichiers locaux de l’appli seront supprimés. OpenNOW redémarrera comme une installation neuve. + « Réinitialiser le tutoriel » fait seulement réapparaître le guide de flux. « Réinitialiser les réglages » est destructif : cela efface les données locales et relance OpenNOW. + Réinitialiser les réglages et les données ? + Sélectionnez le fournisseur GeForce NOW à utiliser pour le nouveau compte. + Utilisation du stockage + Connexions aux boutiques + Les valeurs sensibles sont supprimées avant la création d’un lien temporaire non répertorié. Scannez le code QR avec votre téléphone pour le partager. + Envoyer les journaux et afficher le QR + Cela envoie le résumé du changement de profil et la cause probable aux mainteneurs de PrintedWaste et d’OpenNOW afin qu’ils puissent enquêter. + Envoyer les diagnostics du flux ? + Envoyer les diagnostics + OpenNOW n’a aucun flux local rattaché pour le moment. + Profil de flux modifié + Pourquoi cela s’est produit + Envoi du rapport et des diagnostics expurgés… + Vos réglages de flux enregistrés n’ont pas été modifiés. + Session cloud déjà active + Terminer et en démarrer une nouvelle + Saisir ou modifier le texte du flux + Le cache des jeux était déjà vide + Cache des jeux vidé + Effacement des données et redémarrage d’OpenNOW + Connexion sécurisée depuis le téléphone + Boutique déconnectée + Le tutoriel s’affichera au prochain flux + Glisser + + + %1$d serveur + %1$d de serveurs + %1$d serveurs + + diff --git a/android/app/src/main/res/values-ja/strings.xml b/android/app/src/main/res/values-ja/strings.xml new file mode 100644 index 000000000..df69c8907 --- /dev/null +++ b/android/app/src/main/res/values-ja/strings.xml @@ -0,0 +1,835 @@ + + + OpenNOW + OpenNOW を起動しています + %1$s でログイン + 別のデバイスで %1$s を使ってログイン + このコードを使ってログイン + %1$s + ログインを待機しています + コードの有効期限: %1$d:%2$02d + ストア + 検索 + ライブラリ + 設定 + ゲームを検索 + 設定を検索 + 一般 + アップデート、プライバシー、アプリデータ + 言語 + アプリの言語 + システムのデフォルト + 英語 + ストリーム + 解像度、FPS、コーデック、HDR、プロキシ + 入力 + マイク、マウス、キーボード、タッチ操作、振動 + インターフェース + 外観、ライブラリ、ステータスバー、サウンド + アカウント + ログイン、ストレージ、接続済みストア + 詳細設定 + 詳細オプション、試験機能、診断、ログ + アプリについて + バージョン、クレジット、サポート + ゲームタイトルを表示 + 検索をクリア + 音声検索 + %1$d 本のゲーム + ゲームが読み込まれていません + ライブラリに一致するゲームがありません + 検索をクリアすると、ライブラリ内のすべてのゲームが表示されます。 + フィルターをクリアすると、ライブラリ内のすべてのゲームが表示されます。 + 検索またはフィルターをクリアすると、ライブラリ内のすべてのゲームが表示されます。 + ストアに一致するゲームがありません + 検索をクリアすると、さらにゲームが表示されます。 + フィルターをクリアすると、さらにゲームが表示されます。 + 検索またはフィルターをクリアすると、さらにゲームが表示されます。 + もう一度プレイ + 近日登場 + GeForce NOW の新着タイトル + プレイを続ける + 待機中 + お気に入り + おすすめ + すべて表示 + プレイ + 続行 + 再開 + 保存 + 保存済み + お気に入りに追加 + お気に入りから削除 + キャンセル + オン + オフ + 表示 + 非表示 + 戻る + 開く + リセット + 閉じる + ストリーム操作 + 終了 + 完了 + キーボード送信を開く + 表示 + 入力 + サポート + コントローラー + タッチレイアウト + オーディオ + ミュート中 + ステータスバー + %1$s · %2$d 項目 + ストリームの鮮明化 + 鮮明度 + 画面に合わせて引き伸ばす + ライブ + このセッションでは %1$d Mbps が有効です + 設定 › ストリームは次のセッションから適用されます + マイク + 権限が必要です + Steam メニュー + ストリーミング先 PC にホームキーを送信 + Esc + Enter + + コントローラーマウス + 右スティック · A クリック · B 右クリック + フィンガーマウス + 直接クリック + タッチコントローラー + ゲーム内蔵のタッチ操作を検出しました + このゲームは内蔵タッチ操作に対応しています。必要であれば、下で OpenNOW のタッチコントローラーを有効にできます。 + このゲームは内蔵タッチ操作に対応しています。このセッションでは OpenNOW のタッチコントローラーが有効です。 + 内蔵操作が有効 + ジョイスティック + 固定 + 動的 + スマートフォンの振動を代用 + コントローラーマウスモード + L スティックで移動 · R スティックでスクロール · A でクリック · B で右クリック + マウスモード + マウスエミュレーションを設定 + タッチ操作 + コントローラー配置、ジョイスティック、振動 + 問題を報告 + チェックを実行して編集済みの診断情報を送信 + ドラッグ編集モード + タッチレイアウトをリセット + 位置をデフォルトに戻す + レイアウトの倍率 + ボタンサイズ + 不透明度 + 端の余白 + 下部の余白 + 左の位置 + 右の位置 + ジョイスティック + タッチ式アナログ操作を調整 + 動的配置 + 親指の下を中心にして開始します + 保存済みの固定中心を使用します + スティックサイズ + デッドゾーン + 動的モードでは保存済みのスティック範囲を維持しつつ、親指が最初に触れた位置をニュートラルとして扱います。正確な中心から外れたときの急な動きを防ぎます。 + ステータスバー + レイアウトと表示情報を選択 + 外観 + 位置 + 項目 + FPS + Ping + ビットレート + バッテリー + 接続 + 解像度 + コーデック + サーバー + デコード / ジッター + 損失 + キーボード + %1$d/100 + %1$s + セッションレポートを今後表示しない + 接続 + 未測定 + 遅延 + ストリーム速度 + パケット損失 + ジッター + フレームレート + デコード + 平均 %1$d ms + ピーク %1$d ms + ピーク %1$s + 安定 + 鮮明さに影響する可能性があります + タイミングの変動 + 平均 / 目標 FPS + 映像フレームあたり + セッション操作 + ストリームを終了しますか? + 本当に %1$s を終了しますか? + 現在のクラウドゲーミングセッションは終了します。 + プレイを続ける + ストリームを終了 + 不具合報告 + 不具合を報告 + ストリームの不具合を報告 + 問題と編集済みの診断情報を送信 + 説明を表示 + 説明を非表示 + Ping %1$s + デコード %1$s ms + ジッター %1$s ms + 損失 %1$s%% + 1 秒あたり %1$d フレーム + Ping %1$d ミリ秒 + フレームあたりのデコード時間 %1$s ミリ秒 + ジッター %1$s ミリ秒 + パケット損失 %1$s パーセント + 良好 + 普通 + 低品質 + 閉じる + %1$s でプレイ + フィルターをクリア + 先頭に戻る + 自動 + 近日公開 + ランチャーを選択 + ランチャー + デフォルト + 選択済み + 利用可能なランチャー + 今後確認せず、このストアをデフォルトにする + デフォルトのストアで続行: %1$s + ヒント: 「プレイ」を長押しすると、後で別のストアを選べます。 + 「プレイ」を長押ししてストアを選択 + ストリーム + インターフェース + 品質 + 映像 + 接続 + オーディオとキーボード + ポインター入力 + マウスロック + ストリーミング中、外付けマウスをゲーム内に固定します。ストリーム操作を開くと解除されます。 + コントローラーとタッチ + 外観 + ライブラリとナビゲーション + ステータスバー + サウンドとセッション + セッションレポートを表示 + 各ストリームの後に品質の概要を表示します。 + 詳細ツール + 謝辞 + 解像度 + アスペクト比 + ストリームプリセット + おすすめ + カスタム + 低(データ節約) + + + FPS + ビットレート Mbps + コーデック + カラー + H.264/H.265 のみ + AV1 は 8 ビットカラーを使用します。10 ビットには H.265 を選択してください。HDR は互換性のある Android TV モードに限られます。 + OpenNOW の AV1 は 8 ビットカラーを使用します。8 ビットに切り替えて HDR を無効にしました。10 ビットを使うには H.265 または H.264 を選択してください。 + HDR(Performance & Ultimate) + Android 携帯端末では HDR ストリーミングを利用できません。H.265 では 10 ビット SDR を引き続き利用できます。 + Android TV の HDR には、60 FPS 以下の H.265 と最大 3840 × 2160 の解像度が必要です。 + リージョン + セッションプロキシ + GFN セッションの作成とキューの確認をこのプロキシ経由にします。直接リクエストする場合はオフのままにしてください。 + プロキシ URL + コーデック診断をコピー + コーデック診断をコピーしました + コーデックの検査はまだ実行されていません。 + セッションプロキシを有効にしますか? + GFN セッションの作成、キューの確認、再開、停止、キュー広告の更新リクエストは、入力したプロキシ経由になります。 + 不正またはブロックされたプロキシは、起動、キューの進行、アクティブなセッションの再開、セッションの終了処理を妨げる可能性があります。 + 信頼できるプロキシのみを使用してください。運営者は、リクエストの時刻、接続先ホスト、機密性の高いセッショントラフィックのメタデータを確認できる可能性があります。 + プロキシを有効化 + 試験的ストリーミング + セッションの起動に失敗する場合があります。 + L4S + サーバーとネットワークが対応している場合、NVIDIA の低遅延・低損失トランスポートを要求します。ネットワークが不安定になる場合はオフにしてください。 + Cloud G-Sync / VRR を要求 + デバイス、ディスプレイ、プラン、GFN セッションが対応している場合、クラウドセッションに可変リフレッシュタイミングを要求します。 + マイク + Android のデフォルトマイクをストリーミング先のゲームに送ります。ストリーム操作からミュートできます。 + マイクの権限が許可されませんでした。OpenNOW はマイクのストリーミングをオフのままにします。 + システムカラーを使用 + アクセント + 起動ページ + ストア + ライブラリ + アップデート確認を無効化 + 詳細オプション + 試験的なカタログおよび調整オプションを表示します。詳細診断タブは引き続き利用できます。 + 表現豊かなカードスタイル + より明るいカード面と柔らかな角を使用します。平坦で落ち着いた Material スタイルにするにはオフにしてください。 + カタログの背景 + 携帯端末の画面で、ストアとライブラリの背後に背景画像を表示します。 + 背景画像 + カスタム画像 + 内蔵背景 + カラフルな抽象画(デフォルト) + オリジナル OpenNOW + Absolute Cinema + 画像を選択 + デフォルトを使用 + 画面端の余白 + コンパクトなゲームカード + ストア名を表示 + ゲームカードのサイズ + ストリームボタンを非表示 + 画面キーボードボタン + ストリームのステータスバーに小さなキーボードアイコンを表示します。 + ステータスバーをデフォルトで表示 + 統計オーバーレイの位置 + サーバー選択を非表示 + ボタン操作音 + コントローラーのナビゲーションや画面上の操作を押したときに短い操作音を再生します。 + イントロ音楽を再生 + イントロ音楽の開始状態 + ミュート + 再生 + キュー終了時に音楽を再生 + 音楽をミュート + 画面いっぱいにストリームを引き伸ばす + スマートセッションタイマー + OpenNOW をより良くするために協力してくださる皆さんに感謝します。 + DarkevilPT + コミュニティサポート + 寄付 + 寄付リンクをコピーしました + OpenNOW + Pixel ブルー + ホットピンク + ライム + コーラル + バイオレット + ネイティブストリーマー(試験的) + ハードウェアデコーダーに介入し、ベンダー固有の低遅延プロパティを適用します。不安定になる可能性があります。 + ネイティブタッチの「自動」は、高解像度または高 FPS のストリームでゲームパッドモードを使い、選択したストリームモードを維持します。ネイティブタッチを優先するには「すべてのゲーム」を選択してください。 + 最小化 + 表示 + テレビでプレイ + ストリームを開始しています + キューの位置 %1$d + ゲーム用マシンを待っています + ストリームに接続しています + セッションを再開しています + ゲーム用マシンを準備しています + セッションを開始しています + キューの状態 + %1$s をプレイする準備ができました! + GFN の待機が終了しました。タップしてアプリに戻ります。 + 画面がオフでもストリーミングを続けます + 所有していません + 不明なパブリッシャー + クラウドセッションを再開 + アプリ %1$s + キュー %1$d + 開始中 + このゲームの説明はまだありません。 + キーボード配列 + ゲームの言語 + クリップボードから貼り付け + 次へ + 再試行 + 起動 + スキップ + 更新 + 送信 + OK + 元に戻す + インストール + 管理 + 許可 + アクティブ + 準備完了 + 確認中 + 利用可能な最適ルート + 待ち人数 + 待ち時間 + セッションタイマー + キャッシュを消去 + チュートリアルをリセット + 設定をリセット + リセットして再起動 + 切り替え + アカウントを追加 + サインアウト + すべてのアカウントからサインアウト + プロバイダーを選択 + プレイ時間の統計 + クラウドストレージ + ストレージを追加 + ストレージの場所を変更 + アクティブなストリームはありません + ライブラリに戻る + クラウドセッションを終了 + ステップ %1$d / %2$d + 「完了」を押してください + コントローラーを検出 + 物理コントローラーが接続されているため、画面上のコントローラーを非表示にしました。 + 今後表示しない + スマートフォンとのペアリングを開始しています… + OpenNOW スマートフォンアプリとペアリング + まず Android スマートフォンに OpenNOW をインストールして開いてください。スマートフォンとテレビを同じ Wi‑Fi に接続し、スマートフォンのカメラでこの QR コードを読み取ります。リンクは 5 分後に期限切れになります。 + テレビのペアリング + テレビとペアリング + スマートフォンとテレビを同じ Wi‑Fi に接続してください。ここでテレビの QR コードを読み取るか、テレビを検索して 4 桁のコードを入力します。 + %1$s に接続しました。ゲームに「テレビでプレイ」操作が表示されます。 + %1$s に接続済み + テレビにサインイン + テレビを削除 + QR コードを読み取るか、ネットワーク上のテレビを検索 + テレビの QR をスキャン + QR スキャナーを開けませんでした + テレビを検索 + 検索中… + テレビのコード + このテレビに表示されている 4 桁のコードを入力してください。 + ペアリング + アカウントとサービス + プロフィール、メンバーシップ、ストレージ、ゲームストア + 新着ゲーム + 結果 + ライブラリの注目バナー + タッチコントローラーのスキン + タッチコントローラーの色 + ボタンの文字 + ジャイロ照準 + このネットワーク上に OpenNOW テレビが見つかりませんでした。 + アカウントプロフィールを開く + ユーザー名 + ティア + メールアドレス + アカウントオプション + %1$s • %2$s + 利用できません + 開発者向けオプション + フローのリセット、ランタイムの確認、ローカル状態の再構築 + 開発とサポート向け + これらの操作は OpenNOW 自身のローカル状態のみをリセットし、診断エクスポートにすでに含まれている情報を表示します。破壊的な操作は事前に確認します。このページはリストの最下部から再び非表示にできます。 + フローとプロンプト + カタログとストア + ストリーム + インターフェース + 診断 + 破壊的 + リセット + 消去 + 実行 + 適用 + コピー + 再実行 + 非表示 + 初回起動を再現 + セットアップ、ガイド、プロンプト、同意、閲覧状態をまとめて + セットアップが再実行され、一度きりのプロンプトがすべて戻り、分析への同意は再度回答するまで取り消され、ストアとライブラリの並び順がリセットされます。アカウント、お気に入り、ストリーム設定はそのままです。 + セットアップを再実行 + 次回の起動時に初回起動画面を表示します + ストリームガイドを再表示 + 次にストリームを開始したときに再び表示されます + コントローラーのプロンプトを再表示 + 次にコントローラーを接続したときに再び表示されます + 分析への同意を再度確認 + 再び回答するまでオフのままになります + アップグレード移行を再実行 + 一度きりの表示設定と TV レイアウトの既定値を再適用します + ゲームキャッシュを消去 + キャッシュされたストア、ライブラリ、検索結果を削除します + カタログを再取得 + ストアとライブラリをプロバイダーから今すぐ再読み込みします + 閲覧状態をリセット + ストアとライブラリの並び順とフィルターを既定値に戻します + ランチャーの選択を破棄 + ゲームごとに記憶されたストア選択のすべて + お気に入りを消去 + %1$d 件保存済み + お気に入りに登録したゲームがすべて削除されます。元に戻せません。 + アプリ棚を空にする + インストール済みアプリ %1$d 件を固定中 + 計測した推奨設定を適用 + タッチレイアウトをリセット + オーバーレイのサイズ、不透明度、すべてのボタン位置 + サーバーの待ち行列を更新 + PrintedWaste のゾーン一覧と ping を再取得します + インターフェースをリセット + アクセント、背景、カードレイアウト、アニメーションの既定値 + 診断ログをコピー + 秘匿処理済み。バグ報告が添付するものと同じテキストです + ランタイム概要をコピー + 上の表をテキストとして + 更新を確認 + 更新チェックをすぐに実行します + すべてのアカウントからログアウト + %1$d 件保存済み + 保存済みのアカウントがすべてこの端末から削除され、OpenNOW はログイン画面に戻ります。 + アプリデータを消去して再起動 + OpenNOW を新規インストール状態に戻します + アカウント、設定、キャッシュされたゲーム、ローカルファイルが削除され、OpenNOW は新規インストールのように再起動します。元に戻せません。 + 開発者向けオプションを非表示 + 「情報」でビルド番号を 10 回タップすると戻ります + ビルド + バリアント + 端末 + Android + レイアウトプロファイル + メンバーシップ + プロバイダー + ストリームプロファイル + ハードウェアデコーダー + ストア / ライブラリのゲーム + なし + ログアウト済み + Android TV + 携帯機 + 初回起動の状態を復元しました + 次回の起動時にセットアップが実行されます + ストリームガイドが再び表示されます + コントローラーのプロンプトが再び表示されます + 分析への同意を再度確認します + アップグレード移行が再実行されます + 閲覧状態をリセットしました + ランチャーの選択を破棄しました + お気に入りを消去しました + アプリ棚を空にしました + 計測した推奨設定を適用しました + タッチレイアウトをリセットしました + インターフェースをリセットしました + 診断ログをコピーしました + ランタイム概要をコピーしました + 開発者向けオプションを非表示にしました + あと %1$d 回タップすると開発者向けオプションが表示されます + 開発者向けオプションが設定に表示されました + 開発者向けオプションはすでに表示されています + 次へ + 利用できません + 振動 + 利用できる場合はコントローラーの振動、そうでなければ端末の触覚フィードバック + 振動の出力先 + 一部の携帯機は、内蔵パッドに何にもつながっていない振動モーターがあると報告します。ゲーム内の振動が無反応なら端末のモーターを強制してください。 + 自動 + コントローラー + スマートフォン + タッチエイム + スティックを固定 + ゾーン固定 / タッチエイム + 右側のゾーン内のどこでもドラッグすると、相対マウスルックでエイムできます。 + エイムゾーン + Discord コミュニティ + OpenNOW コミュニティでサポートを受け、バグ報告の経過を追えます。 + コミュニティによるサポートとバグ報告の追跡 + 参加 + Discord の招待リンクをコピーしました + 並べ替えと絞り込み + 並べ替えと絞り込み、%1$d 件有効 + 並べ替え + 人気 + 最終プレイ + フィルター + 操作方法 + モバイルのタッチ操作 + クラウド PC が送るピクセル数です。解像度が高いほど鮮明に見えますが、デコーダー、GPU、ネットワークの能力をより多く必要とします。 + ストリームの形状をディスプレイに合わせます。比率が合わないと黒帯や引き伸ばしが生じることがありますが、デコードが速くなるわけではありません。 + 「推奨」はこの端末のディスプレイ、メモリ、プロセッサ数、Android プロファイル、検証済みの WebRTC ハードウェアデコーダーを使います。「カスタム」は手動の設定を保持します。 + 検出された推奨設定: %1$s + フレームレートは動きの滑らかさを左右します。FPS が高いほどデコーダーの 1 フレームあたりの時間は短くなり、性能の低い端末ではカクつくことがあります。 + 映像の最大データレートです。ビットレートを上げるとディテールが向上することがありますが、回線に十分で安定した帯域がある場合に限られます。FPS は上がりません。 + H.264 が最も互換性に優れます。H.265 は帯域をより効率的に使い、検証済みのハードウェアデコーダーがあれば高解像度で有利です。AV1 はここでは 8 ビットで、互換性のあるハードウェア経路を持つ端末でのみ使われます。 + 8 ビット 4:2:0 が最も軽く互換性に優れます。10 ビットはグラデーションが向上しますが、デコーダーと帯域の要件が増えます。Android は非対応の組み合わせを自動的に調整します。 + HDR には対応した Android TV のディスプレイ、H.265、10 ビット映像、対応するメンバーシップが必要です。処理負荷が増えるため、遅延の切り分けにはおすすめしません。 + 自分のアプリを追加 + インストール済みの Android アプリやゲームを追加、起動、削除できる棚をライブラリに表示します。 + 既定のランチャーにする + OpenNOW をホーム画面にできるよう、Android のランチャー選択画面を開きます。Android の設定でいつでも変更できます。 + ランチャーを選択 + OpenNOW が既定です + 管理 + 自分のアプリ + アプリを追加 + アプリを選択 + インストール済みアプリを読み込み中… + 自分のアプリを表示、%1$d 件インストール済み + 自分のアプリを隠す、%1$d 件インストール済み + 起動できる他のアプリは見つかりませんでした。 + %1$s を削除 + 棚からショートカットを外すだけです。アプリはインストールされたままです。 + 削除 + 注目 + スキンは色違いではなく別のコントローラーです。ボタンの形、方向キーが一体の十字か 4 つの独立キーか、スティックが動く範囲まで、まとめて変わります。 + アクセント色を軸に作られたスキンの色を変えます。クラシック、アウトライン、フロスト、ハイコントラストは設計上モノクロです。 + レイアウトが体に馴染んだら、「オフ」でボタンの刻印を消せます。 + スキン + フェイスボタンのサイズ + 方向キーのサイズ + トリガーとショルダーのサイズ + メニューとスティック押し込みのサイズ + 左スティックのサイズ + 右スティックのサイズ + スティックヘッドのサイズ + 端末を傾けて相対マウスルックでエイムします。 + この端末はジャイロスコープを報告していません。 + ジャイロの感度 + ジャイロのデッドゾーン + ジャイロのスムージング + ジャイロの左右を反転 + ジャイロの上下を反転 + モーションエイム + 縦向きのスマートフォンで、ライブラリのグリッド上に表示される回転バナーです。 + このアプリはインストールされていないか、開けません。 + ライブラリの並び + 最近プレイした順 + タイトル昇順 + 選択枠のアニメーション + 選択中のゲーム、メニュー項目、サーバー、ランチャーの選択肢をアニメーション表示します。落ち着いた表示にするにはオフにしてください。 + Absolute Cinema エフェクト + 選んだインターフェース色を保ったまま、オレンジと青のアニメーションするフォーカスリングを使います。 + とことんやる + Absolute Cinema をインターフェース全体に広げます。カーソルを合わせたアートワークやフォーカス中のアートワーク、説明文、操作要素などにも効果がかかります。 + ゲームカードにお気に入りアイコンを表示 + モバイル、携帯機、TV のゲームカードにお気に入りボタンを表示します。 + 既定でオンです。黒帯を残す代わりに画面を埋めます。合わない軸方向にのみ映像を引き伸ばし、切り取ることはありません。正確な比率にするにはオフにしてください。 + デコード後に追加の GPU フィルターをかけます。体感的なディテールは上がりますが、性能の低い端末では描画負荷が増えます。 + 後処理シャープネスフィルターの強さを調整します。元のストリームの解像度は変わりません。 + Absolute Cinema + Switch + はじめる + 次へ + 戻る + スキップ + 完了 + Android ネイティブの GeForce NOW + 自分好みに + ここでの選択はその場で反映されます。 + アクセント + インターフェースのアニメーション + きらめき、フォーカスの発光、カルーセルの動き。オフにするとシステムのアニメーション設定にも従います。 + プレビュー + レイアウト + 選ぶたびにプレビューが描き直されます + アートワークの下にゲーム名 + オフにするとグリッドはボックスアートだけになります。 + 正方形のカード + ボックスアートを正方形に切り取り、画面により多くのゲームを収めます。 + アートワーク上のお気に入りボタン + ゲームを開かずにライブラリへ保存します。 + 角を丸くする + アプリ全体でカードとパネルの角が柔らかくなります。 + Absolute Cinema + フォーカス中の要素をアニメーションするエネルギーフレームで囲みます。通常はコントローラーと TV 向けの演出です。 + フィードバック + どちらも次のタップで反応します + 触覚フィードバック + 項目を選んだときの短い振動と、端末が対応していればゲーム中のコントローラー振動。 + インターフェース音 + ボタン操作やメニュー移動のときに鳴る音です。ゲーム音声には影響しません。 + 背景 + オフ + 既定 + なし + アプリの背景 + 壁紙 + 自分の画像 + ストリーム品質 + この端末のディスプレイ、チップセット、デコーダーから計測しました。 + この端末を計測中 + 推奨 + データセーバー + 720p、30 FPS、12 Mbps + 最高品質 + ご利用のプランで最大 %1$s/%2$d FPS + 自分で設定する + 下で解像度、フレームレート、ビットレートを選びます + %1$s メンバーシップ + ご利用のプランは最大 %1$s/%2$d FPS で配信されます。それ以上の選択肢には、解放に必要なティアが併記されています。 + GeForce NOW のメンバーシップを上げるとこの上限も上がります。OpenNOW 側で制限しているわけではありません。 + プレイ中 + ストリームの操作感を選びます。 + プレビュー + タッチマウス + ダイレクト + クリックしたい場所をタップ + トラックパッド + スワイプで移動し、タップ + オフ + コントローラーか物理マウスを使う + タップ=移動+クリック + スワイプしてからタップ + コントローラー / マウス + プレイ + ステータス表示 + FPS、ping、バッテリー、接続をひと目で確認できます。 + 位置 + 60 FPS • 24 ms • Wi-Fi + うまく動かないとき + カクつき、ブラックスクリーン、反応しないコントローラー。 + 内蔵のバグ報告 + ストリーム中の操作パネル、またはセッション終了後に表示されるレポートから開けます。 + まず設定をセッションと照らし合わせ、既知の相性問題を通常は対処法つきで指摘します。 + 入力した説明に加えて、秘匿処理済みの診断情報(設定、デコーダーとネットワークの計測値、端末モデル)を送ります。アカウント情報は含みません。 + ストリームごとのセッションレポート + セッション終了時の遅延、フレームの安定度、パケットロスを表示し、バグ報告への近道も用意します。 + 匿名の診断情報を共有 + クラッシュや性能問題に共通する傾向を見つけるのに役立ちます。機微な情報は取り除かれ、販売もしません。オフの場合、クラッシュ報告だけでは調査に足りないことがあります。 + 完了 + どれも設定からいつでも変更できます。 + あなたの選択 + ストリーム品質 + タッチマウス + ステータス表示 + オン + オフ + セットアップ + セットアップを再実行 + 外観、ストリーム品質、プレイ操作、ステータス表示、バグ報告を見直します + %1$s が必要です + GeForce NOW では %1$s は %2$s のみとされており、このアカウントは %3$s です。セッションは拒否されるか、下位のプロファイルに落とされる可能性が高いです。 + 利用可否を決めるのは OpenNOW ではなく GeForce NOW で、カタログが古い場合もあります。試してみることは可能です。 + それでも試す + エラーをコピー + 接続を解除 + 戻る + ログイン + 分析を共有 + バグ、クラッシュ、性能問題の傾向を見つけられるよう、匿名の診断情報を共有してください。機微な情報は取り除かれ、データを販売することはありません。 + クラッシュ時に共有がオフだと、報告を調査するのに十分な情報が得られないことがあります。既定ではオフで、プライバシー設定から変更できます。 + オフのままにする + 診断情報を共有しますか? + 最新ビルドを確認中… + Google Play を確認中… + このセッションを確認中… + チェック + 設定 > 詳細設定 > デバッグログ から取得できるものと同じタイムスタンプ付きログが自動で添付されます。他のファイルは追加されません。 + データが販売されることはなく、バグの調査と修正にのみ使われます。 + 自動ログはアップロード前にアカウント名、認証情報、セッション ID、ネットワークアドレスを取り除きます。生の端末 ID は送信されません。 + 収集される情報 + 入力したタイトルと説明は書いたとおりに送信されるため、個人情報や機微な情報は含めないでください。 + PrintedWaste と OpenNOW のメンテナーは、報告本文、アプリのバージョン/ビルド、端末モデル、Android バージョン、プロバイダーとメンバーシップ区分、プレイ中のゲーム、ストリームの状態と設定、不正利用防止のための仮名のインストール識別子、秘匿処理済みの診断ログを閲覧できます。 + この報告と添付の秘匿処理済み診断情報を PrintedWaste API に送信しますか? + バグ報告を送信しますか? + この報告の送信に同意します。 + アップロードされる内容を理解し、PrintedWaste API への送信に同意します。 + バグは英語で説明してください。セッションの診断情報が添付されます。 + 何が起きましたか? + 何をしていて、何がうまくいかず、再現できますか? + 英語が必要です + 報告の前に、OpenNOW か端末の言語を英語に設定してください。 + ゲームを離れずに問題を説明できます。 + OpenNOW が原因の候補を見つけたことを理解しています。それでも送信します。今後の報告機能が使えなくなる可能性があります。 + 該当する提案 + このチェックに無関係な対処法は提案されていません。 + この端末とこのセッションからのリアルタイムチェック + 報告の前に + バージョン確認をやり直す + 確認して送信 + 別の報告を送る + それでも送信 + 送信中… + バグ報告を送信しました + 該当する提案を試しても続きますか? 続行すると、計測した証拠が自動で添付されます。 + 問題のタイトル + 再接続後にストリームが固まった + Google Play で更新 + 報告をアップロード + バグ報告をアップロードしますか? + 報告をアップロード中… + OpenNOW を英語にする + PrintedWaste の待ち行列と遅延を確認中 + 説明 + フィルター + 無料ティアの待ち行列ルーティング + スクリーンショット + リモコンの戻るボタン + 詳細 + 端末、アカウント種別、ストリームプロファイル、現在の状態、一時 URL をクリップボードにコピーしました。 + 診断情報をコピーしました + OpenNOW はアップロード前に、トークン、アカウント識別子、メールアドレス、セッション ID、ネットワークアドレスを取り除きます。 + ランダムなリンクは一覧には載りませんが暗号化はされておらず、サービス側で 24 時間以内に削除されます。 + 一時的な診断リンクを作成しますか? + 機微な値を取り除き、一時的なリンクを作成しています… + 診断情報を準備中 + QR コードを作成できませんでした。このダイアログを閉じてやり直してください。 + この QR コードをスマートフォンで読み取ってください。秘匿処理済みのリンクは 24 時間以内に期限切れになります。 + 診断リンクを読み取る + 秘匿処理してアップロード + 利用できるブラウザがありません + ストアページを開けませんでした + ストアへの接続を開始できませんでした + ストアの接続を解除できませんでした + アクセストークン + ログをエクスポートできませんでした + ログをエクスポートしました + ペアリングコード + Android ネイティブの GeForce NOW クライアント + NVIDIA のアクセストークン、またはトークン応答の JSON を貼り付けてください。OpenNOW はアカウントを保存する前にアクセストークンを検証します。 + トークンでログイン + 自分が管理するアカウントの認証情報のみを使用してください。 + ブラウザを使わずトークンでログインするか、ログイン前に診断情報をエクスポートします。 + ログインツール + コードでログインする + 広告 + 現在の順位 + 待ち行列 + 戻る + 報告する + 不具合がありましたか? + 実際のプロファイル + 短いセッションだったため、スコアは普段よりばらつくことがあります。 + セッションレポート + 次にできること + プロファイルが変わった理由 + これらの設定は検出された推奨値を超えています + バックグラウンド動作 + 最適化あり(バックグラウンドで切断される可能性) + 制限なし(バックグラウンドで許可) + Android のバッテリー最適化はアプリのバックグラウンド動作を制限するため、アプリを最小化したときに接続がタイムアウトしたり、GFN の待ち行列の進行が止まったりすることがあります。 + キャッシュされたストア、ライブラリ、検索結果が削除されます。アカウントと設定はそのままです。 + ゲームキャッシュを消去しますか? + 接続のヘルプ + 起動状態、待ち行列の状態、ストリームの更新、復旧イベント、設定、コーデック対応状況、最近の秘匿処理済み CloudMatch JSON 応答をエクスポートします。 + 開発者 + このアカウントでは永続ストレージのアドオンが有効になっていません。 + リリースノート + アカウント、設定、キャッシュされたゲーム、チュートリアルの状態、ローカルのアプリファイルが削除されます。OpenNOW は新規インストールのように再起動します。 + 「チュートリアルをリセット」はストリームガイドを再表示するだけです。「設定をリセット」は破壊的で、ローカルのアプリデータを消去して OpenNOW を再起動します。 + 設定とアプリデータをリセットしますか? + 新しいアカウントで使う GeForce NOW のプロバイダーを選択してください。 + ストレージ使用量 + ゲームストア連携 + 一覧に載らない一時的なリンクを作る前に、機微な値は取り除かれます。共有するには QR コードをスマートフォンで読み取ってください。 + ログをアップロードして QR を表示 + プロファイル変更の概要と推定原因を、調査できるよう PrintedWaste と OpenNOW のメンテナーに送信します。 + ストリームの診断情報を送信しますか? + 診断情報を送信 + 現在、OpenNOW にローカルストリームは接続されていません。 + ストリームプロファイルが変更されました + 変更された理由 + 報告と秘匿処理済みの診断情報を送信中… + 保存済みのストリーム設定は変更されていません。 + クラウドセッションはすでに実行中です + 終了して新しく開始 + ストリームに送るテキストを入力・編集 + ゲームキャッシュはすでに空でした + ゲームキャッシュを消去しました + アプリデータを消去して OpenNOW を再起動しています + スマートフォンから安全にログインしました + ストアの接続を解除しました + 次回のストリームでチュートリアルが表示されます + ドラッグ + + + %1$d 台のサーバー + + diff --git a/android/app/src/main/res/values-ko/strings.xml b/android/app/src/main/res/values-ko/strings.xml new file mode 100644 index 000000000..9651bf698 --- /dev/null +++ b/android/app/src/main/res/values-ko/strings.xml @@ -0,0 +1,835 @@ + + + OpenNOW + OpenNOW 시작 중 + %1$s 계정으로 로그인 + 다른 기기에서 %1$s 계정으로 로그인 + 이 코드로 로그인하세요 + %1$s + 로그인 대기 중 + 코드 만료까지 %1$d:%2$02d + 스토어 + 검색 + 라이브러리 + 설정 + 게임 검색 + 설정 검색 + 일반 + 업데이트, 개인정보 보호 및 앱 데이터 + 언어 + 앱 언어 + 시스템 기본값 + 영어 + 스트림 + 해상도, FPS, 코덱, HDR, 프록시 + 입력 + 마이크, 마우스, 키보드, 터치 조작, 진동 + 인터페이스 + 모양, 라이브러리, 상태 표시줄 및 소리 + 계정 + 로그인, 저장공간, 연결된 스토어 + 고급 + 고급 옵션, 실험 기능, 진단 및 로그 + 정보 + 버전, 제작진 및 지원 + 게임 제목 표시 + 검색 지우기 + 음성 검색 + 게임 %1$d개 + 불러온 게임 없음 + 라이브러리에 일치하는 게임이 없습니다 + 검색을 지우면 라이브러리의 모든 게임이 표시됩니다. + 필터를 지우면 라이브러리의 모든 게임이 표시됩니다. + 검색 또는 필터를 지우면 라이브러리의 모든 게임이 표시됩니다. + 스토어에 일치하는 게임이 없습니다 + 검색을 지우면 더 많은 게임이 표시됩니다. + 필터를 지우면 더 많은 게임이 표시됩니다. + 검색 또는 필터를 지우면 더 많은 게임이 표시됩니다. + 다시 시작 + 출시 예정 + GeForce NOW 신규 게임 + 계속 플레이 + 대기열에 있음 + 즐겨찾기 + 추천 + 모두 보기 + 플레이 + 계속 + 재개 + 저장 + 저장됨 + 즐겨찾기에 추가 + 즐겨찾기에서 삭제 + 취소 + 켜짐 + 꺼짐 + 표시 + 숨김 + 뒤로 + 열기 + 재설정 + 닫기 + 스트림 제어 + 나가기 + 완료 + 키보드 입력 열기 + 화면 + 입력 + 지원 + 컨트롤러 + 터치 레이아웃 + 오디오 + 음소거됨 + 상태 표시줄 + %1$s · 항목 %2$d개 + 스트림 선명도 + 선명도 수준 + 화면에 맞게 늘리기 + 실시간 + 이 세션에서 %1$d Mbps 사용 중 + 설정 › 스트림은 다음 세션부터 적용됩니다 + 마이크 + 권한 필요 + Steam 메뉴 + 스트리밍 PC로 홈 키 보내기 + Esc + Enter + + 컨트롤러 마우스 + 오른쪽 스틱 · A 클릭 · B 오른쪽 클릭 + 손가락 마우스 + 직접 클릭 + 터치 컨트롤러 + 게임 내장 터치 조작 감지됨 + 이 게임은 내장 터치 조작을 지원합니다. 원한다면 아래에서 OpenNOW 터치 컨트롤러를 켤 수도 있습니다. + 이 게임은 내장 터치 조작을 지원합니다. 이 세션에서는 OpenNOW 터치 컨트롤러가 활성화되어 있습니다. + 내장 조작 활성화 + 조이스틱 + 고정 + 동적 + 휴대전화 진동 대체 + 컨트롤러 마우스 모드 + L 스틱 이동 · R 스틱 스크롤 · A 클릭 · B 오른쪽 클릭 + 마우스 모드 + 마우스 에뮬레이션 설정 + 터치 조작 + 컨트롤러 레이아웃, 조이스틱 및 진동 + 문제 신고 + 검사를 실행하고 민감 정보를 제거한 진단 자료 보내기 + 드래그 편집 모드 + 터치 레이아웃 재설정 + 위치를 기본값으로 재설정 + 레이아웃 배율 + 버튼 크기 + 불투명도 + 가장자리 여백 + 아래쪽 여백 + 왼쪽 위치 + 오른쪽 위치 + 조이스틱 + 터치 아날로그 조작 조정 + 동적 배치 + 엄지손가락 아래를 중심으로 시작 + 저장된 고정 중심 사용 + 스틱 크기 + 데드존 + 동적 모드는 저장된 스틱 영역을 유지하면서 엄지손가락이 처음 닿은 위치를 중립으로 처리합니다. 정확한 중심을 놓쳤을 때 갑자기 움직이는 것을 방지합니다. + 상태 표시줄 + 레이아웃과 정보 선택 + 모양 + 위치 + 항목 + FPS + + 비트레이트 + 배터리 + 연결 + 해상도 + 코덱 + 서버 + 디코딩 / 지터 + 손실 + 키보드 + %1$d/100 + %1$s + 세션 보고서를 다시 표시하지 않음 + 연결 + 측정되지 않음 + 지연 시간 + 스트림 속도 + 패킷 손실 + 지터 + 프레임 속도 + 디코딩 + 평균 %1$d ms + 최대 %1$d ms + 최대 %1$s + 안정적 + 선명도에 영향을 줄 수 있음 + 타이밍 변동 + 평균 / 목표 FPS + 비디오 프레임당 + 세션 제어 + 스트림을 종료할까요? + 정말 %1$s을(를) 종료하시겠습니까? + 현재 클라우드 게임 세션이 종료됩니다. + 계속 플레이 + 스트림 종료 + 버그 신고 + 버그 신고 + 스트림 버그 신고 + 문제와 민감 정보를 제거한 진단 자료 보내기 + 설명 표시 + 설명 숨기기 + 핑 %1$s + 디코딩 %1$s ms + 지터 %1$s ms + 손실 %1$s%% + 초당 %1$d 프레임 + 핑 %1$d밀리초 + 프레임당 디코딩 시간 %1$s밀리초 + 지터 %1$s밀리초 + 패킷 손실 %1$s퍼센트 + 좋음 + 보통 + 나쁨 + 닫기 + %1$s에서 플레이 + 필터 지우기 + 맨 위로 + 자동 + 출시 예정 + 런처 선택 + 런처 + 기본값 + 선택됨 + 사용 가능한 런처 + 다시 묻지 않고 이 스토어를 기본값으로 설정 + 기본 스토어로 계속 진행: %1$s + 팁: 나중에 다른 스토어를 선택하려면 플레이를 길게 누르세요. + 플레이를 길게 눌러 스토어 선택 + 스트림 + 인터페이스 + 품질 + 비디오 + 연결 + 오디오 및 키보드 + 포인터 입력 + 마우스 잠금 + 스트리밍 중 외부 마우스를 게임 안에 고정합니다. 스트림 제어를 열면 해제됩니다. + 컨트롤러 및 터치 + 모양 + 라이브러리 및 탐색 + 상태 표시줄 + 소리 및 세션 + 세션 보고서 표시 + 각 스트림 후에 품질 요약을 표시합니다. + 고급 도구 + 감사의 말 + 해상도 + 화면 비율 + 스트림 사전 설정 + 권장 + 사용자 지정 + 낮음(데이터 절약) + 중간 + 높음 + FPS + 비트레이트 Mbps + 코덱 + 색상 + H.264/H.265 전용 + AV1은 8비트 색상을 사용합니다. 10비트는 H.265를 선택하세요. HDR은 호환되는 Android TV 모드에서만 사용할 수 있습니다. + OpenNOW의 AV1은 8비트 색상을 사용합니다. 8비트로 전환하고 HDR을 비활성화했습니다. 10비트를 사용하려면 H.265 또는 H.264를 선택하세요. + HDR(Performance & Ultimate) + Android 휴대용 기기에서는 HDR 스트리밍을 사용할 수 없습니다. H.265에서는 10비트 SDR을 계속 사용할 수 있습니다. + Android TV의 HDR에는 60 FPS 이하의 H.265와 최대 3840 × 2160 해상도가 필요합니다. + 지역 + 세션 프록시 + GFN 세션 생성과 대기열 확인을 이 프록시를 통해 처리합니다. 직접 요청하려면 꺼 두세요. + 프록시 URL + 코덱 진단 복사 + 코덱 진단을 복사했습니다 + 코덱 검사가 아직 실행되지 않았습니다. + 세션 프록시를 활성화할까요? + GFN 세션 생성, 대기열 확인, 재개, 중지 및 대기열 광고 업데이트 요청이 입력한 프록시를 통해 처리됩니다. + 잘못되거나 차단된 프록시는 실행, 대기열 진행, 활성 세션 재개 또는 세션 정리를 방해할 수 있습니다. + 신뢰하는 프록시만 사용하세요. 프록시 운영자가 요청 시각, 대상 호스트 및 민감한 세션 트래픽 메타데이터를 볼 수 있습니다. + 프록시 활성화 + 실험적 스트리밍 + 세션 실행에 실패할 수 있습니다. + L4S + 서버와 네트워크가 지원할 때 NVIDIA의 저지연·저손실 전송 경로를 요청합니다. 네트워크가 불안정해지면 꺼 두세요. + Cloud G-Sync / VRR 요청 + 기기, 디스플레이, 요금제 및 GFN 세션이 지원할 때 클라우드 세션에서 가변 화면 재생 빈도를 사용하도록 요청합니다. + 마이크 + 기본 Android 마이크를 스트리밍 게임으로 보냅니다. 스트림 제어에서 음소거할 수 있습니다. + 마이크 권한이 허용되지 않았습니다. OpenNOW는 마이크 스트리밍을 꺼 둡니다. + 시스템 색상 사용 + 강조 색상 + 시작 페이지 + 스토어 + 라이브러리 + 업데이트 확인 비활성화 + 고급 옵션 + 실험적 카탈로그 및 조정 옵션을 표시합니다. 고급 진단 탭은 계속 사용할 수 있습니다. + 표현형 카드 스타일 + 더 밝은 카드 표면과 부드러운 모서리를 사용합니다. 더 평평하고 차분한 Material 스타일을 원하면 끄세요. + 카탈로그 배경 + 휴대용 화면에서 스토어와 라이브러리 뒤에 배경 이미지를 표시합니다. + 배경 이미지 + 사용자 지정 이미지 + 내장 배경 + 다채로운 추상화(기본값) + 원본 OpenNOW + Absolute Cinema + 이미지 선택 + 기본값 사용 + 화면 가장자리 여백 + 간결한 게임 카드 + 스토어 라벨 표시 + 게임 카드 크기 + 스트림 버튼 숨기기 + 화상 키보드 버튼 + 스트림 상태 표시줄에 작은 키보드 아이콘을 표시합니다. + 기본적으로 상태 표시줄 표시 + 통계 오버레이 위치 + 서버 선택기 숨기기 + 버튼 누름 소리 + 컨트롤러 탐색 및 화면 조작 버튼을 누를 때 짧은 UI 소리를 재생합니다. + 인트로 음악 재생 + 인트로 음악 시작 상태 + 음소거 + 재생 + 대기열 종료 시 음악 재생 + 음악 음소거 + 스트림을 화면에 맞게 늘리기 + 스마트 세션 타이머 + 모두를 위해 OpenNOW를 개선하는 데 도움을 주신 분들께 감사드립니다. + DarkevilPT + 커뮤니티 지원 + 후원 + 후원 링크를 복사했습니다 + OpenNOW + Pixel 블루 + 핫 핑크 + 라임 + 코랄 + 바이올렛 + 네이티브 스트리머(실험적) + 하드웨어 디코더에 개입하여 제조업체의 저지연 속성을 적용합니다. 불안정할 수 있습니다. + 네이티브 터치 자동은 고해상도 또는 고 FPS 스트림에서 게임패드 모드를 사용하여 선택한 스트림 모드를 유지합니다. 네이티브 터치를 우선하려면 모든 게임을 선택하세요. + 최소화 + 보기 + TV에서 플레이 + 스트림 시작 중 + 대기열 위치 %1$d + 게임 장비 대기 중 + 스트림 연결 중 + 세션 재개 중 + 게임 장비 설정 중 + 세션 시작 중 + 대기열 상태 + %1$s을(를) 플레이할 준비가 되었습니다! + GFN 대기열이 끝났습니다. 탭하여 앱으로 돌아가세요. + 화면이 꺼져 있어도 스트리밍이 계속됩니다 + 소유하지 않음 + 알 수 없는 배급사 + 클라우드 세션 재개 + 앱 %1$s + 대기열 %1$d + 시작 중 + 이 게임에 대한 설명이 아직 없습니다. + 키보드 배열 + 게임 언어 + 클립보드 붙여넣기 + 다음 + 다시 시도 + 실행 + 건너뛰기 + 새로 고침 + 보내기 + 확인 + 실행 취소 + 설치 + 관리 + 허용 + 활성 + 준비됨 + 확인 중 + 사용 가능한 최적 경로 + 앞선 대기자 + 대기 + 세션 타이머 + 캐시 지우기 + 튜토리얼 재설정 + 설정 재설정 + 재설정 후 다시 실행 + 전환 + 계정 추가 + 로그아웃 + 모든 계정에서 로그아웃 + 제공업체 선택 + 플레이 시간 통계 + 클라우드 저장소 + 저장소 추가 + 저장소 위치 변경 + 활성 스트림 없음 + 라이브러리로 돌아가기 + 클라우드 세션 종료 + %2$d단계 중 %1$d단계 + 완료를 누르세요 + 컨트롤러 감지됨 + 물리 컨트롤러가 연결되어 화면 컨트롤러를 숨겼습니다. + 다시 표시 안 함 + 휴대전화 페어링을 시작하는 중… + OpenNOW 휴대전화 앱과 페어링 + 먼저 Android 휴대전화에 OpenNOW를 설치하고 여세요. 휴대전화와 TV를 같은 Wi‑Fi에 연결한 다음 휴대전화 카메라로 이 QR 코드를 스캔하세요. 링크는 5분 후 만료됩니다. + TV 페어링 + TV와 페어링 + 휴대전화와 TV를 같은 Wi‑Fi에 연결하세요. 여기서 TV의 QR 코드를 스캔하거나 TV를 찾은 뒤 4자리 코드를 입력하세요. + %1$s에 연결되었습니다. 이제 게임에 TV에서 플레이 작업이 표시됩니다. + %1$s에 연결됨 + TV에 로그인 + TV 삭제 + QR 코드를 스캔하거나 네트워크에서 TV 찾기 + TV QR 스캔 + QR 스캐너를 열 수 없습니다 + TV 찾기 + 찾는 중… + TV 코드 + 이 TV에 표시된 4자리 코드를 입력하세요. + 페어링 + 계정 및 서비스 + 프로필, 멤버십, 저장 공간 및 게임 스토어 + 새 게임 + 결과 + 라이브러리 추천 배너 + 터치 컨트롤러 스킨 + 터치 컨트롤러 색상 + 버튼 문자 + 자이로 조준 + 이 네트워크에서 OpenNOW TV를 찾지 못했습니다. + 계정 프로필 열기 + 사용자 이름 + 등급 + 이메일 + 계정 옵션 + %1$s • %2$s + 사용할 수 없음 + 개발자 옵션 + 흐름 초기화, 런타임 확인, 로컬 상태 재구성 + 개발 및 지원용 + 이 작업들은 OpenNOW 자체의 로컬 상태만 초기화하며, 진단 내보내기에 이미 포함된 정보를 표시합니다. 파괴적인 작업은 먼저 확인을 요청합니다. 목록 맨 아래에서 이 페이지를 다시 숨길 수 있습니다. + 흐름 및 안내 + 카탈로그 및 스토어 + 스트림 + 인터페이스 + 진단 + 파괴적 + 초기화 + 지우기 + 실행 + 적용 + 복사 + 다시 실행 + 숨기기 + 첫 실행 다시 재현 + 설정, 가이드, 안내, 동의, 탐색 상태를 한 번에 + 설정이 다시 실행되고, 일회성 안내가 모두 되돌아오며, 분석 동의는 다시 답할 때까지 철회되고, 스토어와 라이브러리 정렬이 초기화됩니다. 계정, 즐겨찾기, 스트림 설정은 그대로 유지됩니다. + 설정 다시 실행 + 다음 실행 시 첫 실행 화면을 표시합니다 + 스트림 가이드 다시 표시 + 다음에 스트림을 시작할 때 다시 나타납니다 + 컨트롤러 안내 다시 표시 + 다음에 컨트롤러를 연결할 때 다시 나타납니다 + 분석 동의 다시 요청 + 질문에 다시 답할 때까지 꺼진 상태로 유지됩니다 + 업그레이드 마이그레이션 다시 실행 + 일회성 표시 및 TV 레이아웃 기본값을 다시 적용합니다 + 게임 캐시 지우기 + 캐시된 스토어, 라이브러리, 검색 결과를 삭제합니다 + 카탈로그 다시 가져오기 + 스토어와 라이브러리를 지금 공급자로부터 다시 불러옵니다 + 탐색 상태 초기화 + 스토어와 라이브러리의 정렬 및 필터를 기본값으로 되돌립니다 + 실행기 선택 지우기 + 게임별로 기억된 모든 스토어 선택 + 즐겨찾기 지우기 + %1$d개 저장됨 + 즐겨찾기에 추가한 모든 게임이 제거됩니다. 되돌릴 수 없습니다. + 앱 선반 비우기 + 설치된 앱 %1$d개 고정됨 + 측정된 권장 설정 적용 + 터치 레이아웃 초기화 + 오버레이 크기, 불투명도, 모든 버튼 위치 + 서버 대기열 새로 고침 + PrintedWaste 지역 목록과 핑을 다시 조회합니다 + 인터페이스 초기화 + 강조색, 배경, 카드 레이아웃, 애니메이션 기본값 + 진단 로그 복사 + 민감 정보가 제거된, 버그 신고가 첨부하는 것과 같은 텍스트 + 런타임 요약 복사 + 위 표를 텍스트로 + 업데이트 확인 + 업데이트 확인을 즉시 실행합니다 + 모든 계정에서 로그아웃 + %1$d개 저장됨 + 저장된 모든 계정이 이 기기에서 제거되고 OpenNOW가 로그인 화면으로 돌아갑니다. + 앱 데이터 삭제 후 다시 시작 + OpenNOW를 새로 설치한 상태로 되돌립니다 + 계정, 설정, 캐시된 게임, 로컬 파일이 삭제되고 OpenNOW가 새로 설치한 것처럼 다시 시작됩니다. 되돌릴 수 없습니다. + 개발자 옵션 숨기기 + 정보 화면에서 빌드 번호를 열 번 눌러 다시 표시할 수 있습니다 + 빌드 + 변형 + 기기 + Android + 레이아웃 프로필 + 멤버십 + 공급자 + 스트림 프로필 + 하드웨어 디코더 + 스토어 / 라이브러리 게임 + 없음 + 로그아웃됨 + Android TV + 휴대용 기기 + 첫 실행 상태를 복원했습니다 + 다음 실행 시 설정이 진행됩니다 + 스트림 가이드가 다시 표시됩니다 + 컨트롤러 안내가 다시 표시됩니다 + 분석 동의를 다시 요청합니다 + 업그레이드 마이그레이션을 다시 실행합니다 + 탐색 상태를 초기화했습니다 + 실행기 선택을 지웠습니다 + 즐겨찾기를 지웠습니다 + 앱 선반을 비웠습니다 + 측정된 권장 설정을 적용했습니다 + 터치 레이아웃을 초기화했습니다 + 인터페이스를 초기화했습니다 + 진단 로그를 복사했습니다 + 런타임 요약을 복사했습니다 + 개발자 옵션을 숨겼습니다 + %1$d번 더 누르면 개발자 옵션이 표시됩니다 + 개발자 옵션이 설정에 표시되었습니다 + 개발자 옵션이 이미 표시되어 있습니다 + 다음 + 사용할 수 없음 + 진동 + 가능하면 컨트롤러 진동, 그렇지 않으면 기기 햅틱 + 진동 출력 + 일부 휴대용 기기는 내장 패드에 아무것도 연결되지 않은 진동 모터가 있다고 보고합니다. 게임 내 진동이 계속 없으면 휴대폰 모터를 강제로 사용하세요. + 자동 + 컨트롤러 + 휴대폰 + 터치 조준 + 조이스틱 고정 + 영역 고정 / 터치 조준 + 오른쪽 영역 안에서 어디든 드래그하면 상대 마우스룩으로 조준합니다. + 조준 영역 + Discord 커뮤니티 + OpenNOW 커뮤니티에서 도움을 받고 버그 신고를 추적하세요. + 커뮤니티 지원 및 버그 신고 추적 + 참여 + Discord 초대 링크를 복사했습니다 + 정렬 및 필터 + 정렬 및 필터, %1$d개 적용 중 + 정렬 + 인기 + 최근 플레이 + 필터 + 조작 + 모바일 터치 조작 + 클라우드 PC가 보내는 픽셀 수입니다. 해상도가 높을수록 선명하지만 디코더, GPU, 네트워크 성능이 더 필요합니다. + 스트림 형태를 화면에 맞춥니다. 비율이 맞지 않으면 검은 여백이나 늘어짐이 생길 수 있으며, 디코더가 빨라지지는 않습니다. + ‘권장’은 이 기기의 디스플레이, 메모리, 프로세서 수, Android 프로필, 검증된 WebRTC 하드웨어 디코더를 사용합니다. ‘사용자 지정’은 직접 고른 설정을 유지합니다. + 감지된 권장 설정: %1$s + 초당 프레임 수는 움직임의 부드러움을 좌우합니다. FPS가 높을수록 디코더가 프레임당 쓸 시간이 줄어 느린 기기에서는 끊길 수 있습니다. + 최대 영상 데이터 전송률입니다. 비트레이트가 높으면 디테일이 좋아질 수 있지만 회선에 충분하고 안정적인 여유가 있을 때만 해당하며, FPS를 올리지는 않습니다. + H.264가 호환성이 가장 좋습니다. H.265는 대역폭을 더 효율적으로 쓰며 검증된 하드웨어 디코더가 있을 때 고해상도에 유리합니다. AV1은 여기서 8비트이며 호환 하드웨어 경로가 있는 기기에서만 사용됩니다. + 8비트 4:2:0이 가장 가볍고 호환성이 좋습니다. 10비트는 그라데이션이 좋아지지만 디코더와 대역폭 요구가 늘어납니다. Android는 지원되지 않는 조합을 자동으로 조정합니다. + HDR에는 호환되는 Android TV 디스플레이, H.265, 10비트 영상, 지원되는 멤버십이 필요합니다. 처리 부하가 늘어나므로 지연 문제를 진단할 때는 권장하지 않습니다. + 내 앱 추가 + 설치된 Android 앱과 게임을 추가, 실행, 제거할 수 있는 선반을 라이브러리에 표시합니다. + 기본 실행기로 설정 + OpenNOW가 홈 화면이 될 수 있도록 Android의 실행기 선택 화면을 엽니다. Android 설정에서 다시 변경할 수 있습니다. + 실행기 선택 + OpenNOW가 기본입니다 + 관리 + 내 앱 + 앱 추가 + 앱 선택 + 설치된 앱을 불러오는 중… + 내 앱 표시, %1$d개 설치됨 + 내 앱 숨기기, %1$d개 설치됨 + 실행할 수 있는 다른 앱을 찾지 못했습니다. + %1$s 제거 + 선반에서 바로가기만 제거합니다. 앱은 설치된 상태로 남습니다. + 제거 + 추천 + 각 스킨은 색만 다른 게 아니라 다른 컨트롤러입니다. 버튼 모양, 방향키가 하나의 십자인지 네 개의 개별 키인지, 스틱이 움직이는 범위까지 함께 바뀝니다. + 강조색을 중심으로 만들어진 스킨의 색을 바꿉니다. 클래식, 아웃라인, 프로스트, 고대비는 설계상 단색입니다. + 레이아웃이 손에 익으면 ‘끄기’로 버튼 글자를 비울 수 있습니다. + 스킨 + 페이스 버튼 크기 + 방향키 크기 + 트리거 및 숄더 버튼 크기 + 메뉴 및 스틱 클릭 크기 + 왼쪽 스틱 크기 + 오른쪽 스틱 크기 + 스틱 헤드 크기 + 휴대폰을 기울여 상대 마우스룩으로 조준합니다. + 이 기기는 자이로스코프를 보고하지 않습니다. + 자이로 감도 + 자이로 데드존 + 자이로 부드럽게 + 자이로 좌우 반전 + 자이로 상하 반전 + 모션 조준 + 세로 모드 휴대폰에서 라이브러리 격자 위에 표시되는 회전 배너입니다. + 이 앱은 더 이상 설치되어 있지 않거나 열 수 없습니다. + 라이브러리 순서 + 최근 플레이순 + 제목순 + 선택 테두리 애니메이션 + 선택된 게임, 메뉴 항목, 서버 선택, 실행기 옵션에 애니메이션을 적용합니다. 차분한 선택 표시를 원하면 끄세요. + Absolute Cinema 효과 + 선택한 인터페이스 색을 유지하면서 주황색과 파란색 포커스 링 애니메이션을 사용합니다. + 끝까지 간다 + Absolute Cinema를 인터페이스 전체에 적용합니다. 마우스를 올리거나 포커스된 아트워크, 설명, 조작 요소 등에도 효과가 적용됩니다. + 게임 카드에 즐겨찾기 아이콘 표시 + 모바일, 휴대용 기기, TV 게임 카드에 즐겨찾기 버튼을 표시합니다. + 기본적으로 켜져 있습니다. 검은 여백을 남기는 대신 화면을 채우며, 맞지 않는 축 방향으로만 영상을 늘리고 잘라내지는 않습니다. 정확한 비율을 원하면 끄세요. + 디코딩 후 추가 GPU 필터를 적용합니다. 체감 디테일이 좋아질 수 있지만 느린 기기에서는 렌더링 부하가 늘어납니다. + 후처리 선명도 필터의 강도를 조절합니다. 원본 스트림 해상도는 바뀌지 않습니다. + Absolute Cinema + Switch + 시작하기 + 다음 + 뒤로 + 건너뛰기 + 완료 + Android 네이티브 GeForce NOW + 내 취향대로 + 여기서 고르는 대로 바로 적용됩니다. + 강조색 + 인터페이스 애니메이션 + 반짝임, 포커스 발광, 캐러셀 움직임. 끄면 시스템 애니메이션 설정도 함께 따릅니다. + 미리보기 + 레이아웃 + 고를 때마다 미리보기가 다시 그려집니다 + 아트워크 아래에 게임 제목 + ‘끄기’로 두면 격자가 박스 아트만으로 채워집니다. + 정사각형 카드 + 박스 아트를 정사각형으로 잘라 화면에 더 많은 게임이 들어갑니다. + 아트워크 위 즐겨찾기 버튼 + 게임을 열지 않고 라이브러리에 저장합니다. + 둥근 모서리 + 앱 전반에서 카드와 패널의 모서리가 부드러워집니다. + Absolute Cinema + 포커스된 요소 주위에 움직이는 에너지 테두리를 그립니다. 보통 컨트롤러와 TV용 연출입니다. + 피드백 + 둘 다 다음 탭에서 작동합니다 + 햅틱 + 항목을 선택할 때의 짧은 진동과, 기기가 지원하는 경우 게임 중 컨트롤러 진동. + 인터페이스 소리 + 버튼을 누르거나 메뉴를 이동할 때 나는 소리입니다. 게임 오디오에는 영향을 주지 않습니다. + 배경 + 끄기 + 기본 + 없음 + 앱 배경 + 배경화면 + 내 이미지 + 스트림 품질 + 이 기기의 디스플레이, 칩셋, 디코더를 측정해 정했습니다. + 이 기기를 측정하는 중 + 권장 + 데이터 절약 + 720p, 30 FPS, 12 Mbps + 최고 품질 + 현재 요금제에서 최대 %1$s, %2$d FPS + 직접 설정 + 아래에서 해상도, 프레임 속도, 비트레이트를 선택하세요 + %1$s 멤버십 + 현재 요금제는 최대 %1$s, %2$d FPS로 스트리밍합니다. 더 높은 옵션은 잠금을 해제하는 등급과 함께 표시됩니다. + GeForce NOW 멤버십을 상향하면 이 한도가 올라갑니다. OpenNOW가 제한하는 것이 아닙니다. + 플레이 중 + 스트림의 조작감을 선택하세요. + 미리보기 + 터치 마우스 + 직접 + 클릭하려는 곳을 탭하세요 + 트랙패드 + 밀어서 움직인 다음 탭하세요 + 끄기 + 컨트롤러나 실제 마우스를 사용하세요 + 탭 = 이동 + 클릭 + 밀고 나서 탭 + 컨트롤러 / 마우스 + 플레이 + 상태 표시줄 + FPS, 핑, 배터리, 연결 상태를 한눈에 봅니다. + 위치 + 60 FPS • 24 ms • Wi-Fi + 문제가 생겼을 때 + 끊김, 검은 화면, 반응 없는 컨트롤러. + 내장 버그 신고 + 스트림 중 조작 패널이나 세션 종료 후 표시되는 리포트에서 열 수 있습니다. + 먼저 설정을 세션과 대조해 알려진 문제 조합을 표시하며, 보통 해결 방법도 함께 제시합니다. + 입력한 설명과 함께 민감 정보가 제거된 진단 정보(설정, 디코더 및 네트워크 측정값, 기기 모델)를 보냅니다. 계정 정보는 포함되지 않습니다. + 스트림마다 세션 리포트 + 세션이 끝나면 지연, 프레임 페이싱, 패킷 손실을 보여주고 버그 신고로 바로 갈 수 있습니다. + 익명 진단 정보 공유 + 충돌과 성능 문제 전반의 패턴을 찾는 데 도움이 됩니다. 민감한 데이터는 제거되며 어떤 것도 판매하지 않습니다. 꺼져 있으면 충돌 보고만으로는 조사에 충분하지 않을 수 있습니다. + 완료 + 모두 설정에서 바꿀 수 있습니다. + 선택한 항목 + 스트림 품질 + 터치 마우스 + 상태 표시줄 + 켜짐 + 꺼짐 + 설정 + 설정 다시 실행 + 외관, 스트림 품질, 플레이 조작, 상태 표시줄, 버그 신고를 다시 살펴봅니다 + %1$s 필요 + GeForce NOW는 %1$s을(를) %2$s 전용으로 표시하며, 이 계정은 %3$s입니다. 세션이 거부되거나 더 낮은 프로필로 낮아질 가능성이 큽니다. + 이용 자격은 OpenNOW가 아니라 GeForce NOW가 결정하며 카탈로그가 오래된 경우도 있어, 그래도 시도해 볼 수 있습니다. + 그래도 시도 + 오류 복사 + 연결 해제 + 돌아가기 + 로그인 + 분석 공유 + 버그, 충돌, 성능 문제의 패턴을 찾을 수 있도록 익명 진단 정보를 공유해 주세요. 민감한 데이터는 제거되며 데이터를 판매하지 않습니다. + 충돌 시 공유가 꺼져 있으면 신고 내용을 조사할 정보가 부족할 수 있습니다. 기본값은 꺼짐이며 개인정보 설정에서 변경할 수 있습니다. + 계속 끄기 + 진단 정보를 공유할까요? + 최신 빌드를 확인하는 중… + Google Play를 확인하는 중… + 이 세션을 확인하는 중… + 점검 + 설정 > 고급 > 디버그 로그에서 볼 수 있는 것과 같은 타임스탬프 로그가 자동으로 첨부됩니다. 다른 파일은 추가되지 않습니다. + 데이터는 판매되지 않으며 버그를 조사하고 고치는 데에만 사용됩니다. + 자동 로그는 업로드 전에 계정 이름, 자격 증명, 세션 ID, 네트워크 주소를 제거합니다. 원본 기기 ID는 전송되지 않습니다. + 수집되는 정보 + 입력한 제목과 설명은 작성한 그대로 전송되므로 개인 정보나 민감한 정보를 포함하지 마세요. + PrintedWaste와 OpenNOW 관리자는 신고 본문, 앱 버전/빌드, 기기 모델, Android 버전, 공급자 및 멤버십 구분, 현재 게임, 스트림 상태/설정, 악용 방지를 위한 가명 설치 식별자, 민감 정보가 제거된 진단 로그를 볼 수 있습니다. + 이 신고와 첨부된 진단 정보를 PrintedWaste API로 보낼까요? + 버그 신고를 보낼까요? + 이 신고를 보내는 데 동의합니다. + 업로드되는 내용을 이해하며 PrintedWaste API로 전송하는 데 동의합니다. + 버그는 영어로 설명해 주세요. 세션 진단 정보가 첨부됩니다. + 무슨 일이 있었나요? + 무엇을 하고 있었고, 무엇이 잘못되었으며, 다시 재현할 수 있나요? + 영어가 필요합니다 + 신고하기 전에 OpenNOW 또는 기기 언어를 영어로 설정하세요. + 게임을 떠나지 않고 문제를 설명하세요. + OpenNOW가 유력한 원인을 찾았다는 점을 이해합니다. 그래도 보냅니다. 앞으로 신고 권한을 잃을 수 있습니다. + 관련 제안 + 이 점검과 무관한 해결책은 제안하지 않습니다. + 이 기기와 이 세션의 실시간 점검 + 신고하기 전에 + 버전 확인 다시 시도 + 검토 후 보내기 + 하나 더 보내기 + 그래도 보내기 + 보내는 중… + 버그 신고를 보냈습니다 + 관련 제안을 적용한 뒤에도 계속되나요? 계속 진행하면 측정된 근거가 자동으로 첨부됩니다. + 문제 제목 + 재연결 후 스트림이 멈춤 + Google Play에서 업데이트 + 신고 업로드 + 버그 신고를 업로드할까요? + 신고를 업로드하는 중… + OpenNOW를 영어로 사용 + PrintedWaste 대기열과 지연을 확인하는 중 + 설명 + 필터 + 무료 등급 대기열 라우팅 + 스크린샷 + 리모컨 뒤로 버튼 + 세부 정보 + 기기, 계정 유형, 스트림 프로필, 현재 상태, 임시 URL을 클립보드에 복사했습니다. + 진단 정보를 복사했습니다 + OpenNOW는 업로드 전에 토큰, 계정 식별자, 이메일 주소, 세션 ID, 네트워크 주소를 제거합니다. + 무작위 링크는 목록에 공개되지 않지만 암호화되지는 않으며, 서비스가 24시간 이내에 업로드를 삭제합니다. + 임시 진단 링크를 만들까요? + 민감한 값을 제거하고 임시 링크를 만드는 중… + 진단 정보를 준비하는 중 + QR 코드를 만들지 못했습니다. 이 창을 닫고 다시 시도하세요. + 휴대폰으로 이 QR 코드를 스캔하세요. 정리된 링크는 24시간 이내에 만료됩니다. + 진단 링크 스캔 + 정리 후 업로드 + 사용할 수 있는 브라우저가 없습니다 + 스토어 페이지를 열지 못했습니다 + 스토어 연결을 시작하지 못했습니다 + 스토어 연결을 해제하지 못했습니다 + 액세스 토큰 + 로그를 내보내지 못했습니다 + 로그를 내보냈습니다 + 페어링 코드 + Android 네이티브 GeForce NOW 클라이언트 + NVIDIA 액세스 토큰 또는 토큰 응답 JSON을 붙여넣으세요. OpenNOW는 계정을 저장하기 전에 액세스 토큰을 확인합니다. + 토큰으로 로그인 + 본인이 관리하는 계정의 자격 증명만 사용하세요. + 브라우저 없이 토큰으로 로그인하거나, 로그인 전에 진단 정보를 내보냅니다. + 로그인 도구 + 코드 로그인 사용 + 광고 + 실시간 순번 + 대기열 + 뒤로 + 신고하기 + 버그를 겪으셨나요? + 실제 적용된 프로필 + 짧은 세션이라 점수가 평소보다 더 크게 달라질 수 있습니다. + 세션 리포트 + 다음에 할 일 + 프로필이 바뀐 이유 + 이 설정은 감지된 권장값을 넘어섭니다 + 백그라운드 활동 + 최적화됨 (백그라운드에서 끊길 수 있음) + 제한 없음 (백그라운드 허용) + Android의 배터리 최적화는 앱의 백그라운드 활동을 제한하므로, 앱을 최소화했을 때 연결이 끊기거나 GFN 대기열 진행이 멈출 수 있습니다. + 캐시된 스토어, 라이브러리, 검색 결과가 삭제됩니다. 계정과 설정은 그대로 유지됩니다. + 게임 캐시를 지울까요? + 연결 도움말 + 실행 상태, 대기열 상태, 스트림 업데이트, 복구 이벤트, 설정, 코덱 지원 정보, 최근 정리된 CloudMatch JSON 응답을 내보냅니다. + 개발자 + 이 계정에는 영구 저장소 부가 기능이 활성화되어 있지 않습니다. + 릴리스 노트 + 계정, 설정, 캐시된 게임, 튜토리얼 상태, 로컬 앱 파일이 삭제됩니다. OpenNOW는 새로 설치한 것처럼 다시 시작됩니다. + ‘튜토리얼 초기화’는 스트림 가이드를 다시 보이게 할 뿐입니다. ‘설정 초기화’는 파괴적이며, 로컬 앱 데이터를 지우고 OpenNOW를 다시 시작합니다. + 설정과 앱 데이터를 초기화할까요? + 새 계정에 사용할 GeForce NOW 공급자를 선택하세요. + 저장소 사용량 + 게임 스토어 연결 + 목록에 공개되지 않는 임시 링크를 만들기 전에 민감한 값이 제거됩니다. 공유하려면 휴대폰으로 QR 코드를 스캔하세요. + 로그 업로드 후 QR 표시 + 프로필 변경 요약과 추정 원인을 조사할 수 있도록 PrintedWaste와 OpenNOW 관리자에게 보냅니다. + 스트림 진단 정보를 보낼까요? + 진단 정보 보내기 + 지금 OpenNOW에 연결된 로컬 스트림이 없습니다. + 스트림 프로필이 변경되었습니다 + 이렇게 된 이유 + 신고와 정리된 진단 정보를 보내는 중… + 저장된 스트림 설정은 변경되지 않았습니다. + 클라우드 세션이 이미 실행 중입니다 + 종료하고 새로 시작 + 스트림에 보낼 텍스트 입력 또는 편집 + 게임 캐시가 이미 비어 있었습니다 + 게임 캐시를 지웠습니다 + 앱 데이터를 지우고 OpenNOW를 다시 시작하는 중 + 휴대폰에서 안전하게 로그인했습니다 + 스토어 연결을 해제했습니다 + 다음 스트림에서 튜토리얼이 표시됩니다 + 드래그 + + + 서버 %1$d대 + + diff --git a/android/app/src/main/res/values-nl/strings.xml b/android/app/src/main/res/values-nl/strings.xml new file mode 100644 index 000000000..21d5c7149 --- /dev/null +++ b/android/app/src/main/res/values-nl/strings.xml @@ -0,0 +1,836 @@ + + + OpenNOW + OpenNOW wordt gestart + Inloggen met %1$s + Op een ander apparaat inloggen met %1$s + Gebruik deze code om in te loggen + %1$s + Wachten op aanmelding + Code verloopt over %1$d:%2$02d + Winkel + Zoeken + Bibliotheek + Instellingen + Games zoeken + Instellingen doorzoeken + Algemeen + Updates, privacy en appgegevens + Taal + App-taal + Systeemstandaard + Engels + Stream + Resolutie, FPS, codec, HDR, proxy + Invoer + Microfoon, muis, toetsenbord, aanraakbediening, trillen + Interface + Uiterlijk, bibliotheek, statusbalk en geluiden + Account + Aanmelding, opslag, gekoppelde winkels + Geavanceerd + Geavanceerde opties, experimenten, diagnostiek en logboeken + Over + Versie, dankbetuigingen en ondersteuning + Gamenamen tonen + Zoekopdracht wissen + Spraakgestuurd zoeken + %1$d games + Geen games geladen + Geen overeenkomende games in de bibliotheek + Wis de zoekopdracht om alle games in je bibliotheek te tonen. + Wis de filters om alle games in je bibliotheek te tonen. + Wis de zoekopdracht of filters om alle games in je bibliotheek te tonen. + Geen overeenkomende games in de winkel + Wis de zoekopdracht om meer games te tonen. + Wis de filters om meer games te tonen. + Wis de zoekopdracht of filters om meer games te tonen. + Verder spelen + Binnenkort + Nieuwe titels op GeForce NOW + Doorgaan met spelen + In de wachtrij + Favorieten + Aanbevelingen + Alles bekijken + Spelen + Doorgaan + Hervatten + Opslaan + Opgeslagen + Als favoriet markeren + Uit favorieten verwijderen + Annuleren + Aan + Uit + Zichtbaar + Verborgen + Terug + Openen + Herstellen + Sluiten + Streambediening + Afsluiten + Gereed + Toetsenbordinvoer openen + Weergave + Invoer + Ondersteuning + Controller + Aanraakindeling + Audio + Gedempt + Statusbalk + %1$s · %2$d items + Stream verscherpen + Mate van verscherping + Uitrekken om te passen + Live + %1$d Mbps actief in deze sessie + Instellingen › Stream geldt pas voor de volgende sessie + Microfoon + Toestemming vereist + Steam-menu + Home naar de gestreamde pc sturen + Esc + Enter + + Controllermuis + Rechter stick · A klikken · B rechtsklikken + Vingermuis + Direct klikken + Aanraakcontroller + Ingebouwde aanraakbediening gedetecteerd + Deze game ondersteunt ingebouwde aanraakbediening. Je kunt hieronder alsnog de aanraakcontroller van OpenNOW inschakelen. + Deze game ondersteunt ingebouwde aanraakbediening. De aanraakcontroller van OpenNOW is voor deze sessie ingeschakeld. + Ingebouwde bediening actief + Joysticks + Vast + Dynamisch + Trillen via telefoon als alternatief + Controllermuismodus + L-stick beweegt · R-stick scrolt · A klikt · B rechtsklikt + Muismodus + Muisemulatie instellen + Aanraakbediening + Controllerindeling, joysticks en trillen + Een probleem melden + Controles uitvoeren en opgeschoonde diagnostiek verzenden + Sleepbewerkingsmodus + Aanraakindeling herstellen + Posities naar standaard herstellen + Indelingsschaal + Knopgrootte + Dekking + Randafstand + Onderafstand + Linkerpositie + Rechterpositie + Joysticks + Analoge aanraakbediening afstellen + Dynamische plaatsing + Begint gecentreerd onder je duim + Gebruikt het opgeslagen vaste middelpunt + Stickgrootte + Dode zone + De dynamische modus behoudt het opgeslagen stickgebied, maar behandelt de plek waar je duim eerst landt als neutraal. Dit voorkomt plotselinge beweging wanneer je het exacte middelpunt mist. + Statusbalk + Kies de indeling en informatie + Uiterlijk + Positie + Items + FPS + Ping + Bitsnelheid + Batterij + Verbinding + Resolutie + Codec + Server + Dec. / Jit. + Verlies + Toetsenbord + %1$d/100 + %1$s + Sessierapporten niet meer tonen + Verbinding + Niet gemeten + Latentie + Streamsnelheid + Pakketverlies + Jitter + Framesnelheid + Decodering + gem. %1$d ms + piek %1$d ms + piek %1$s + Stabiel + Kan de helderheid beïnvloeden + Timingvariatie + Gemiddelde / doel-FPS + Per videoframe + Sessiebeheer + Stream afsluiten? + Wil je %1$s echt afsluiten? + Je huidige cloudgamingsessie wordt gesloten. + Verder spelen + Stream afsluiten + Bugrapportage + Een bug melden + Een streambug melden + Een probleem en opgeschoonde diagnostiek verzenden + Beschrijving tonen + Beschrijving verbergen + Ping %1$s + Dec. %1$s ms + Jit. %1$s ms + Verlies %1$s%% + %1$d frames per seconde + Ping %1$d milliseconden + Decodeertijd %1$s milliseconden per frame + Jitter %1$s milliseconden + Pakketverlies %1$s procent + goed + redelijk + slecht + Sluiten + Spelen op %1$s + Filters wissen + Naar boven + Automatisch + Binnenkort + Launcher kiezen + Launchers + Standaard + Geselecteerd + Beschikbare launcher + Niet opnieuw vragen — deze winkel als standaard instellen + Doorgaan met standaardwinkel: %1$s + Tip: houd Spelen ingedrukt om later een andere winkel te kiezen. + Houd Spelen ingedrukt om een winkel te kiezen + Stream + Interface + Kwaliteit + Video + Verbinding + Audio en toetsenbord + Aanwijzerinvoer + Muisvergrendeling + Houdt een externe muis tijdens het streamen binnen de game. Door Streambediening te openen wordt deze vrijgegeven. + Controller en aanraking + Uiterlijk + Bibliotheek en navigatie + Statusbalk + Geluiden en sessies + Sessierapport tonen + Toon na elke stream een kwaliteitsoverzicht. + Geavanceerde hulpmiddelen + Dankwoord + Resolutie + Beeldverhouding + Streamvoorinstelling + Aanbevolen + Aangepast + Laag (databesparing) + Gemiddeld + Hoog + FPS + Bitsnelheid Mbps + Codec + Kleur + Alleen H.264/H.265 + AV1 gebruikt 8-bits kleur. Kies H.265 voor 10-bits; HDR is beperkt tot compatibele Android TV-modi. + AV1 gebruikt 8-bits kleur in OpenNOW. Overgeschakeld naar 8-bits en HDR uitgeschakeld. Kies H.265 of H.264 voor 10-bits. + HDR (Performance & Ultimate) + HDR-streaming is niet beschikbaar op draagbare Android-apparaten. 10-bits SDR blijft beschikbaar met H.265. + HDR op Android TV vereist H.265 met maximaal 60 FPS en een resolutie tot 3840 × 2160. + Regio + Sessieproxy + Leidt het maken van GFN-sessies en wachtrijcontroles via deze proxy. Laat uit voor directe verzoeken. + Proxy-URL + Codecdiagnose kopiëren + Codecdiagnose gekopieerd + De codeccontrole is nog niet uitgevoerd. + Sessieproxy inschakelen? + Het maken van GFN-sessies, wachtrijcontroles, hervatten, stoppen en updates van wachtrijadvertenties worden via de ingevoerde proxy geleid. + Een onjuiste of geblokkeerde proxy kan starten, wachtrijvoortgang, hervatten van actieve sessies of sessieopruiming verstoren. + Gebruik alleen een vertrouwde proxy. De beheerder kan mogelijk aanvraagtijden, doelhosts en gevoelige metadata van sessieverkeer zien. + Proxy inschakelen + Experimenteel streamen + Kan het starten van sessies laten mislukken. + L4S + Vraagt NVIDIA’s transportpad met lage latentie en weinig verlies wanneer server en netwerk dit ondersteunen. Laat uit als je netwerk instabiel wordt. + Cloud G-Sync-/VRR-verzoek + Vraagt de cloudsessie variabele vernieuwingsintervallen te gebruiken wanneer je apparaat, scherm, abonnement en GFN-sessie dit ondersteunen. + Microfoon + Stuurt je standaard Android-microfoon naar de gestreamde game. Je kunt deze dempen via Streambediening. + Microfoontoestemming is niet verleend. OpenNOW houdt microfoonstreaming uitgeschakeld. + Systeemkleuren gebruiken + Accent + Startpagina + Winkel + Bibliotheek + Zoeken naar updates uitschakelen + Geavanceerde opties + Toont experimentele catalogus- en afstelopties. Het tabblad Geavanceerde diagnostiek blijft beschikbaar. + Expressieve kaartstijl + Gebruikt helderdere kaartvlakken en zachtere hoeken. Schakel uit voor een vlakkere, rustigere Material-stijl. + Catalogusachtergrond + Toont een achtergrondafbeelding achter Winkel en Bibliotheek op draagbare schermen. + Achtergrondafbeelding + Aangepaste afbeelding + Ingebouwde achtergrond + Kleurrijk abstract (standaard) + Originele OpenNOW + Absolute Cinema + Afbeelding kiezen + Standaard gebruiken + Afstand tot schermrand + Compacte gamekaarten + Winkellabels tonen + Grootte van gamekaarten + Streamknoppen verbergen + Knop voor schermtoetsenbord + Toont een compact toetsenbordpictogram in de streamstatusbalk. + Statusbalk standaard tonen + Positie van statistiekenoverlay + Serverkiezer verbergen + Knopgeluiden + Speelt een kort UI-geluid bij controllernavigatie en het indrukken van schermbediening. + Intromuziek afspelen + Intromuziek start + Gedempt + Wordt afgespeeld + Muziek afspelen wanneer de wachtrij eindigt + Muziek dempen + Stream uitrekken om te vullen + Slimme sessietimer + Dank aan iedereen die OpenNOW voor iedereen helpt verbeteren. + DarkevilPT + Communityondersteuning + Doneren + Donatielink gekopieerd + OpenNOW + Pixelblauw + Felroze + Limoen + Koraal + Violet + Native streamer (experimenteel) + Onderschept de hardwaredecoder om leveranciersspecifieke eigenschappen voor lage latentie toe te voegen. Kan instabiel zijn. + Native touch Automatisch gebruikt de gamepadmodus voor streams met hoge resolutie of hoge FPS, zodat de gekozen streammodus behouden blijft. Kies Elke game om native touch voorrang te geven. + Minimaliseren + Bekijken + Op tv spelen + Stream wordt gestart + Wachtrijpositie %1$d + Wachten op een gamecomputer + Verbinding maken met stream + Sessie wordt hervat + Gamecomputer wordt ingesteld + Sessie wordt gestart + Wachtrijstatus + %1$s is klaar om te spelen! + Je GFN-wachtrij is klaar. Tik om terug te keren naar de app. + Streamen gaat door terwijl het scherm uit staat + Niet in bezit + Onbekende uitgever + Cloudsessie hervatten + App %1$s + Wachtrij %1$d + Wordt gestart + Er is nog geen beschrijving beschikbaar voor deze game. + Toetsenbordindeling + Gametaal + Plakken vanaf klembord + Volgende + Opnieuw proberen + Starten + Overslaan + Vernieuwen + Verzenden + OK + Ongedaan maken + Installeren + Beheren + Toestaan + Actief + Gereed + Controleren + Beste beschikbare route + Voor u + Wachten + Sessietimer + Cache wissen + Tutorial resetten + Instellingen resetten + Resetten en opnieuw starten + Wisselen + Account toevoegen + Afmelden + Alle accounts afmelden + Provider kiezen + Speeltijdstatistieken + Cloudopslag + Opslag toevoegen + Opslaglocatie wijzigen + Geen actieve stream + Terug naar bibliotheek + Cloudsessie beëindigen + Stap %1$d van %2$d + Druk op Gereed + Controller gedetecteerd + De controller op het scherm is verborgen omdat er een fysieke controller is aangesloten. + Niet meer tonen + Telefoonkoppeling starten… + Koppelen met de OpenNOW-telefoonapp + Installeer en open eerst OpenNOW op je Android-telefoon. Verbind de telefoon en tv met hetzelfde wifi-netwerk en scan deze QR-code met de telefooncamera. De link verloopt na vijf minuten. + Tv koppelen + Koppelen met een tv + Houd deze telefoon en de tv op hetzelfde wifi-netwerk. Scan hier de QR-code van de tv, of zoek de tv en voer de 4-cijferige code in. + Verbonden met %1$s. Games tonen nu de actie Op tv spelen. + Verbonden met %1$s + Aanmelden op tv + Tv vergeten + Scan een QR-code of zoek een tv op je netwerk + Tv-QR scannen + QR-scanner kon niet worden geopend + Tv zoeken + Zoeken… + Tv-code + Voer de 4-cijferige code in die op deze tv staat. + Koppelen + Accounts en diensten + Profielen, lidmaatschap, opslag en gamestores + Nieuwe games + Resultaten + Uitgelichte bibliotheekbanner + Touchcontrollerstijl + Kleur van touchcontroller + Knopletters + Richten met gyroscoop + Er is geen OpenNOW-tv gevonden op dit netwerk. + Accountprofiel openen + Gebruikersnaam + Niveau + E-mail + Accountopties + %1$s • %2$s + Niet beschikbaar + Ontwikkelaarsopties + Stromen resetten, de runtime inspecteren en lokale status opnieuw opbouwen + Voor ontwikkeling en ondersteuning + Deze acties resetten alleen de eigen lokale status van OpenNOW en tonen informatie die al in de diagnostische export staat. Destructieve acties vragen eerst om bevestiging. Je kunt deze pagina onderaan de lijst weer verbergen. + Stromen en meldingen + Catalogus en winkels + Stream + Interface + Diagnostiek + Destructief + Resetten + Wissen + Uitvoeren + Toepassen + Kopiëren + Opnieuw afspelen + Verbergen + Eerste start opnieuw afspelen + Installatie, gidsen, meldingen, toestemming en browsestatus in één keer + De installatie wordt opnieuw uitgevoerd, alle eenmalige meldingen komen terug, de analysetoestemming wordt ingetrokken tot je opnieuw antwoordt, en de volgorde van Winkel en Bibliotheek wordt gereset. Accounts, favorieten en streaminstellingen blijven ongemoeid. + Installatie opnieuw uitvoeren + Toont de schermen voor de eerste start bij de volgende keer opstarten + Streamgids opnieuw tonen + Verschijnt weer wanneer de volgende stream begint + Controllermelding opnieuw tonen + Verschijnt weer wanneer de volgende controller wordt verbonden + Opnieuw om analysetoestemming vragen + Blijft uitgeschakeld tot de vraag opnieuw is beantwoord + Upgrademigraties opnieuw afspelen + Past de eenmalige presentatie- en tv-lay-outstandaarden opnieuw toe + Spelcache wissen + Verwijdert opgeslagen resultaten van Winkel, Bibliotheek en zoekopdrachten + Catalogus opnieuw ophalen + Laadt Winkel en Bibliotheek nu opnieuw van de aanbieder + Browsestatus resetten + Volgorde en filters van Winkel en Bibliotheek terug naar standaard + Launcherkeuzes vergeten + Alle onthouden winkelkeuzes per spel + Favorieten wissen + %1$d opgeslagen + Elk favoriet spel wordt verwijderd. Dit kan niet ongedaan worden gemaakt. + Appplank leegmaken + %1$d geïnstalleerde apps vastgezet + Gemeten aanbeveling toepassen + Aanraaklay-out resetten + Overlaygrootte, dekking en elke knopverschuiving + Serverwachtrijen vernieuwen + Vraagt de PrintedWaste-zonelijst en pings opnieuw op + Interface resetten + Accent, achtergrond, kaartlay-out en animatiestandaarden + Diagnostisch logboek kopiëren + Geschoond, dezelfde tekst die het foutrapport bijvoegt + Runtimeoverzicht kopiëren + De tabel hierboven, als tekst + Controleren op updates + Voert de updatecontrole meteen uit + Uitloggen bij alle accounts + %1$d opgeslagen + Elk opgeslagen account wordt van dit apparaat verwijderd en OpenNOW keert terug naar het inlogscherm. + Appgegevens wissen en opnieuw starten + Zet OpenNOW terug naar een verse installatie + Accounts, instellingen, opgeslagen spellen en lokale bestanden worden verwijderd, en OpenNOW start opnieuw als een verse installatie. Dit kan niet ongedaan worden gemaakt. + Ontwikkelaarsopties verbergen + Tik tien keer op het buildnummer in Over om ze terug te halen + Build + Variant + Apparaat + Android + Lay-outprofiel + Abonnement + Aanbieder + Streamprofiel + Hardwaredecoders + Winkel-/Bibliotheekspellen + Geen + Uitgelogd + Android TV + Handheld + Status van eerste start hersteld + De installatie wordt bij de volgende start uitgevoerd + De streamgids wordt weer getoond + De controllermelding wordt weer getoond + Er wordt opnieuw om analysetoestemming gevraagd + Upgrademigraties worden opnieuw afgespeeld + Browsestatus gereset + Launcherkeuzes vergeten + Favorieten gewist + Appplank leeggemaakt + Gemeten aanbeveling toegepast + Aanraaklay-out gereset + Interface gereset + Diagnostisch logboek gekopieerd + Runtimeoverzicht gekopieerd + Ontwikkelaarsopties verborgen + Nog %1$d keer tikken om ontwikkelaarsopties te tonen + Ontwikkelaarsopties staan nu in Instellingen + Ontwikkelaarsopties worden al getoond + Volgende + Niet beschikbaar + Trillen + Controllertrilling indien beschikbaar; anders de trilling van het apparaat + Trilluitvoer + Sommige handhelds melden een trilmotor op hun ingebouwde pad die nergens op is aangesloten. Forceer de telefoonmotor als de trilling in het spel stil blijft. + Automatisch + Controller + Telefoon + Richten met aanraking + Joystick vergrendelen + Zone vergrendelen / aanraakrichten + Sleep ergens binnen de rechterzone voor relatief muisrichten. + RICHTZONE + Discord-community + Krijg hulp en volg foutrapporten op met de OpenNOW-community. + Communityondersteuning en opvolging van foutrapporten + Deelnemen + Discord-uitnodiging gekopieerd + Sorteren en filteren + Sorteren en filteren, %1$d actief + Sorteren + Populair + Laatst gespeeld + Filters + Besturing + Mobiele aanraakbesturing + Het aantal pixels dat de cloud-pc verstuurt. Hogere resoluties ogen scherper, maar vragen meer decoder-, GPU- en netwerkcapaciteit. + Past de vorm van de stream aan het scherm aan. Een verkeerde verhouding kan zwarte balken of uitrekking veroorzaken; het maakt de decoder niet sneller. + ‘Aanbevolen’ gebruikt het scherm, geheugen, aantal processors, Android-profiel en geverifieerde WebRTC-hardwaredecoders van dit apparaat. ‘Aangepast’ behoudt je handmatige keuzes. + Gedetecteerde aanbeveling: %1$s + Beelden per seconde bepalen de vloeiendheid van beweging. Meer FPS geeft de decoder minder tijd per beeld en kan haperingen veroorzaken op tragere hardware. + De maximale videodatasnelheid. Een hogere bitsnelheid kan detail verbeteren, maar alleen als de verbinding genoeg stabiele capaciteit heeft; het verhoogt de FPS niet. + H.264 is het meest compatibel. H.265 gebruikt bandbreedte efficiënter en heeft de voorkeur bij hoge resolutie als er een geverifieerde hardwaredecoder is. AV1 is hier 8-bits en wordt alleen gebruikt op apparaten met een compatibel hardwarepad. + 8-bits 4:2:0 is het lichtst en meest compatibel. 10-bits verbetert kleurovergangen, maar verhoogt de decoder- en bandbreedte-eisen. Android normaliseert niet-ondersteunde combinaties automatisch. + HDR vereist een compatibel Android TV-scherm, H.265, 10-bits video en een ondersteund abonnement. Het verhoogt de verwerkingslast en wordt niet aanbevolen om vertraging op te sporen. + Mijn eigen apps toevoegen + Toont een plank in de Bibliotheek waar je geïnstalleerde Android-apps en -spellen kunt toevoegen, starten en verwijderen. + Als standaardlauncher instellen + Opent de launcherkiezer van Android zodat OpenNOW het startscherm kan worden. Je kunt dit later weer wijzigen in de Android-instellingen. + Launcher kiezen + OpenNOW is de standaard + Beheren + Mijn eigen apps + App toevoegen + Kies een app + Geïnstalleerde apps laden… + Mijn apps tonen, %1$d geïnstalleerd + Mijn apps verbergen, %1$d geïnstalleerd + Er zijn geen andere startbare apps gevonden. + %1$s verwijderen + Dit verwijdert alleen de snelkoppeling van je plank. De app blijft geïnstalleerd. + Verwijderen + Uitgelicht + Elke skin is een andere controller, niet alleen een andere kleur: de vorm van de knoppen, of het d-pad één kruis is of vier losse toetsen, en de ruimte waarin de stick beweegt veranderen mee. + Kleurt de skins die rond een accent zijn gebouwd opnieuw. Klassiek, Omtrek, Vorst en Hoog contrast zijn bewust monochroom. + ‘Uit’ laat de knoppen leeg zodra de lay-out spiergeheugen is. + Skin + Grootte van de actieknoppen + Grootte van het d-pad + Grootte van triggers en schouderknoppen + Grootte van menu en stickklik + Grootte van de linkerstick + Grootte van de rechterstick + Grootte van de stickkop + Kantel de telefoon voor relatief muisrichten. + Dit apparaat meldt geen gyroscoop. + Gyroscoopgevoeligheid + Gyroscoop-dode zone + Gyroscoopdemping + Gyroscoop horizontaal omkeren + Gyroscoop verticaal omkeren + Richten met beweging + Roterende banner boven het Bibliotheekraster op telefoons in staande stand. + Deze app is niet meer geïnstalleerd of kan niet worden geopend. + Bibliotheekvolgorde + Laatst gespeeld + Titel A–Z + Bewegende selectieranden + Animeert geselecteerde spellen, menu-items, serverkeuzes en launcheropties. Schakel dit uit voor rustigere selectie. + Absolute Cinema-effecten + Gebruikt bewegende oranje en blauwe focusringen met behoud van je gekozen interfacekleur. + Ik ben gek + Laat Absolute Cinema los op de hele interface. Artwork, beschrijvingen, bedieningselementen en meer krijgen het effect bij aanwijzen en focussen. + Favorietpictogram op spelkaarten tonen + Toont een favorietknop op spelkaarten voor mobiel, handheld en tv. + Standaard aan. Vult het scherm in plaats van zwarte balken te laten, door het beeld alleen op de afwijkende as uit te rekken — nooit door bij te snijden. Schakel dit uit voor exacte geometrie. + Past na het decoderen een extra GPU-filter toe. Dit kan het waargenomen detail verbeteren, maar verhoogt de renderbelasting op tragere apparaten. + Bepaalt de sterkte van het nabewerkingsfilter voor scherpte. Het verandert de bronresolutie van de stream niet. + Absolute Cinema + Switch + Aan de slag + Volgende + Terug + Overslaan + Voltooien + Native GeForce NOW voor Android + Maak het van jou + Alles hier wordt meteen toegepast terwijl je kiest. + Accent + Interface-animaties + Glans, focusgloed en carrouselbeweging. Uitschakelen respecteert ook de animatie-instelling van het systeem. + Voorbeeld + Lay-out + Elke keuze tekent het voorbeeld opnieuw + Speltitels onder de artwork + ‘Uit’ laat het raster als pure boxart. + Vierkante kaarten + Snijdt de boxart bij tot een vierkant zodat er meer spellen op het scherm passen. + Favorietknop op de artwork + Bewaart een spel in je Bibliotheek zonder het te openen. + Afgeronde hoeken + Zachtere kaart- en paneelranden door de hele app. + Absolute Cinema + Bewegende energieranden rond wat er ook gefocust is. Normaal gesproken een controller- en tv-behandeling. + Terugkoppeling + Beide reageren bij je volgende tik + Haptiek + Een korte trilling wanneer je iets selecteert, en controllertrilling in het spel waar het apparaat dat ondersteunt. + Interfacegeluiden + Een toon bij het indrukken van knoppen en menunavigatie. Heeft geen invloed op het spelgeluid. + Achtergrond + Uit + Standaard + Geen + App-achtergrond + Achtergrondafbeelding + Jouw afbeelding + Streamkwaliteit + Gemeten aan het scherm, de chipset en de decoders van dit apparaat. + Dit apparaat meten + Aanbevolen + Databesparing + 720p, 30 FPS, 12 Mbps + Beste kwaliteit + Tot %1$s bij %2$d FPS met jouw abonnement + Zelf instellen + Kies hieronder resolutie, beeldsnelheid en bitsnelheid + %1$s-abonnement + Jouw abonnement streamt tot %1$s bij %2$d FPS. Hogere opties staan vermeld met het niveau dat ze ontgrendelt. + Je GeForce NOW-abonnement upgraden verhoogt deze limiet — OpenNOW beperkt hem niet. + Tijdens het spelen + Kies hoe de stream aanvoelt. + Voorbeeld + Aanraakmuis + Direct + Tik waar je wilt klikken + Trackpad + Veeg om te bewegen, tik dan + Uit + Gebruik een controller of fysieke muis + Tik = bewegen + klikken + Veeg, tik dan + Controller / muis + Spelen + Statusregel + FPS, ping, batterij en verbinding in één oogopslag. + Positie + 60 FPS • 24 ms • Wi-Fi + Als er iets misgaat + Haperingen, zwarte schermen, dode controllers. + De ingebouwde foutrapportage + Open die vanuit de bediening tijdens de stream, of vanuit het rapport dat na een sessie wordt aangeboden. + Hij toetst eerst je instellingen aan de sessie en markeert bekende slechte combinaties, meestal met een oplossing. + Hij stuurt je beschrijving plus geschoonde diagnostiek — instellingen, decoder- en netwerkmetingen, apparaatmodel. Geen accountgegevens. + Sessierapport na elke stream + Vertraging, beeldritme en pakketverlies wanneer een sessie eindigt, met een snelkoppeling naar de foutrapportage. + Anonieme diagnostiek delen + Helpt patronen te vinden in crashes en prestatieproblemen. Gevoelige gegevens worden verwijderd en niets wordt verkocht. Staat dit uit, dan bevat een crashrapport mogelijk te weinig om te onderzoeken. + Klaar + Je kunt alles wijzigen in Instellingen. + Jouw keuzes + Streamkwaliteit + Aanraakmuis + Statusregel + Aan + Uit + Installatie + Installatie opnieuw uitvoeren + Bekijk uiterlijk, streamkwaliteit, spelbesturing, status en foutrapportage opnieuw + Vereist %1$s + GeForce NOW vermeldt %1$s alleen als %2$s, en dit account gebruikt %3$s. De sessie wordt hoogstwaarschijnlijk geweigerd of teruggezet naar een lager profiel. + Het recht op toegang wordt bepaald door GeForce NOW, niet door OpenNOW, en de catalogus is soms verouderd — je kunt het dus nog steeds proberen. + Toch proberen + Fout kopiëren + Ontkoppelen + Terug + Inloggen + Analyses delen + Deel anonieme diagnostiek zodat wij patronen kunnen vinden in fouten, crashes en prestatieproblemen. Gevoelige gegevens worden verwijderd en wij verkopen je gegevens niet. + Staat delen uit tijdens een crash, dan hebben we mogelijk te weinig informatie om je rapport te onderzoeken. Het staat standaard uit en kan worden gewijzigd in de privacy-instellingen. + Uit laten + Diagnostiek delen? + Nieuwste build controleren… + Google Play controleren… + Deze sessie controleren… + Controles + Hetzelfde logboek met tijdstempel dat beschikbaar is via Instellingen > Geavanceerd > Debuglogboeken wordt automatisch bijgevoegd. Er worden geen andere bestanden toegevoegd. + Je gegevens worden niet verkocht en worden alleen gebruikt om fouten te onderzoeken en op te lossen. + Het automatische logboek verwijdert accountnamen, inloggegevens, sessie-ID’s en netwerkadressen vóór het uploaden. De ruwe apparaat-ID wordt niet verzonden. + Wat wordt er verzameld? + De titel en beschrijving die je typt worden precies zo verzonden als je ze schrijft, dus vermeld geen persoonlijke of gevoelige informatie. + Beheerders van PrintedWaste en OpenNOW kunnen de rapporttekst, appversie/-build, apparaatmodel, Android-versie, aanbieder en abonnementscategorie, huidige spel, streamstatus/-instellingen, een pseudonieme installatie-identificatie ter voorkoming van misbruik, en een geschoond diagnostisch logboek inzien. + Dit rapport en de bijgevoegde geschoonde diagnostiek naar de PrintedWaste-API sturen? + Foutrapport verzenden? + Ik geef toestemming om dit rapport te verzenden. + Ik begrijp wat er wordt geüpload en geef toestemming om het naar de PrintedWaste-API te sturen. + Beschrijf de fout in het Engels. Sessiediagnostiek wordt bijgevoegd. + Wat is er gebeurd? + Wat was je aan het doen, wat ging er mis, en kun je het reproduceren? + Engels vereist + Stel OpenNOW of de apparaattaal in op Engels voordat je rapporteert. + Beschrijf het probleem zonder je spel te verlaten. + Ik begrijp dat OpenNOW een waarschijnlijke oorzaak heeft gevonden. Toch verzenden; ik kan toekomstige rapportagetoegang verliezen. + OVEREENKOMENDE SUGGESTIES + Er worden geen irrelevante oplossingen voorgesteld voor deze controle. + Live controles van dit apparaat en deze sessie + Voordat je rapporteert + Versiecontrole opnieuw proberen + Controleren en verzenden + Nog een verzenden + Toch verzenden + Verzenden… + Foutrapport verzonden + Gebeurt het nog steeds na een overeenkomende suggestie? Ga door en het gemeten bewijs wordt automatisch bijgevoegd. + Titel van het probleem + Stream bevroor na opnieuw verbinden + Bijwerken in Google Play + Rapport uploaden + Foutrapport uploaden? + Rapport uploaden… + Engels gebruiken voor OpenNOW + PrintedWaste-wachtrijen en vertraging controleren + Beschrijving + Filters + Wachtrijroutering voor het gratis niveau + Schermafbeeldingen + Terug-knop van de afstandsbediening + Details + Het apparaat, accounttype, streamprofiel, de huidige status en de tijdelijke URL zijn naar het klembord gekopieerd. + Diagnostiek gekopieerd + OpenNOW verwijdert tokens, account-identificaties, e-mailadressen, sessie-ID’s en netwerkadressen vóór het uploaden. + De willekeurige link staat niet in een lijst maar is niet versleuteld, en de dienst verwijdert uploads binnen 24 uur. + Tijdelijke diagnostische link maken? + Gevoelige waarden verwijderen en een tijdelijke link maken… + Diagnostiek voorbereiden + De QR-code kon niet worden gemaakt. Sluit dit venster en probeer het opnieuw. + Scan deze QR-code met je telefoon. De geschoonde link verloopt binnen 24 uur. + Diagnostische link scannen + Schonen en uploaden + Geen browser beschikbaar + Kan de winkelpagina niet openen + Kan de winkelverbinding niet starten + Kan de winkel niet ontkoppelen + Toegangstoken + Kan de logboeken niet exporteren + Logboeken geëxporteerd + KOPPELCODE + Native GeForce NOW-client voor Android + Plak een NVIDIA-toegangstoken of de JSON van het tokenantwoord. OpenNOW verifieert het toegangstoken voordat het account wordt opgeslagen. + Inloggen met token + Gebruik alleen inloggegevens van een account dat van jou is. + Log in met een token zonder browser, of exporteer diagnostiek voordat je inlogt. + Inloghulpmiddelen + Inloggen met code gebruiken + Advertentie + Live positie + Wachtrij + TERUG + Meld het + Een fout tegengekomen? + Geleverd profiel + Dit was een korte sessie, dus de score kan meer variëren dan gebruikelijk. + Sessierapport + Wat je nu kunt doen + Waarom het profiel is gewijzigd + Deze instellingen liggen boven de gedetecteerde aanbeveling + Achtergrondactiviteit + Geoptimaliseerd (kan verlopen op de achtergrond) + Onbeperkt (toegestaan op de achtergrond) + De batterijoptimalisatie van Android beperkt de achtergrondactiviteit van de app, wat verbindingstime-outs kan veroorzaken of de voortgang in de GFN-wachtrij kan pauzeren wanneer de app geminimaliseerd is. + Opgeslagen winkel-, bibliotheek- en zoekresultaten worden verwijderd. Je account en instellingen blijven ongewijzigd. + Spelcache wissen? + Verbindingshulp + Exporteert startstatus, wachtrijstatus, streamupdates, herstelgebeurtenissen, instellingen, codecmogelijkheden en recente geschoonde CloudMatch-JSON-antwoorden. + Ontwikkelaar + Voor dit account is geen add-on voor permanente opslag actief. + Release-opmerkingen + Accounts, instellingen, opgeslagen spellen, tutorialstatus en lokale appbestanden worden verwijderd. OpenNOW start opnieuw als een verse installatie. + ‘Tutorial resetten’ laat alleen de streamgids opnieuw verschijnen. ‘Instellingen resetten’ is destructief: het wist lokale appgegevens en start OpenNOW opnieuw. + Instellingen en appgegevens resetten? + Selecteer de GeForce NOW-aanbieder voor het nieuwe account. + Opslaggebruik + Winkelverbindingen + Gevoelige waarden worden verwijderd voordat er een niet-vermelde, tijdelijke link wordt gemaakt. Scan de QR-code met je telefoon om die te delen. + Logboeken uploaden en QR tonen + Dit stuurt de samenvatting van de profielwijziging en de waarschijnlijke oorzaak naar de beheerders van PrintedWaste en OpenNOW zodat zij het kunnen onderzoeken. + Streamdiagnostiek verzenden? + Diagnostiek verzenden + OpenNOW heeft op dit moment geen lokale stream gekoppeld. + Streamprofiel gewijzigd + Waarom het gebeurde + Het rapport en de geschoonde diagnostiek verzenden… + Je opgeslagen streaminstellingen zijn niet gewijzigd. + Cloudsessie al actief + Beëindigen en nieuwe starten + Streamtekst typen of bewerken + De spelcache was al leeg + Spelcache gewist + Appgegevens wissen en OpenNOW opnieuw starten + Veilig ingelogd vanaf de telefoon + Winkel ontkoppeld + De tutorial wordt bij de volgende stream getoond + Slepen + + + %1$d server + %1$d servers + + diff --git a/android/app/src/main/res/values-pl/strings.xml b/android/app/src/main/res/values-pl/strings.xml new file mode 100644 index 000000000..20647694f --- /dev/null +++ b/android/app/src/main/res/values-pl/strings.xml @@ -0,0 +1,838 @@ + + + OpenNOW + Uruchamianie OpenNOW + Zaloguj się przez %1$s + Zaloguj się na innym urządzeniu przez %1$s + Użyj tego kodu, aby się zalogować + %1$s + Oczekiwanie na logowanie + Kod wygaśnie za %1$d:%2$02d + Sklep + Szukaj + Biblioteka + Ustawienia + Szukaj gier + Szukaj w ustawieniach + Ogólne + Aktualizacje, prywatność i dane aplikacji + Język + Język aplikacji + Domyślny systemu + Angielski + Strumień + Rozdzielczość, FPS, kodek, HDR, proxy + Sterowanie + Mikrofon, mysz, klawiatura, dotyk i wibracje + Interfejs + Wygląd, biblioteka, pasek stanu i dźwięki + Konto + Logowanie, pamięć i połączone sklepy + Zaawansowane + Opcje zaawansowane, eksperymenty, diagnostyka i dzienniki + Informacje + Wersja, autorzy i pomoc + Pokaż tytuły gier + Wyczyść wyszukiwanie + Wyszukiwanie głosowe + Gry: %1$d + Nie wczytano gier + Brak pasujących gier w bibliotece + Wyczyść wyszukiwanie, aby wyświetlić wszystkie gry w bibliotece. + Wyczyść filtry, aby wyświetlić wszystkie gry w bibliotece. + Wyczyść wyszukiwanie lub filtry, aby wyświetlić wszystkie gry w bibliotece. + Brak pasujących gier w sklepie + Wyczyść wyszukiwanie, aby wyświetlić więcej gier. + Wyczyść filtry, aby wyświetlić więcej gier. + Wyczyść wyszukiwanie lub filtry, aby wyświetlić więcej gier. + Wróć do gry + Wkrótce + Nowości w GeForce NOW + Kontynuuj grę + W kolejce + Ulubione + Polecane + Zobacz wszystko + Graj + Kontynuuj + Wznów + Zapisz + Zapisano + Dodaj do ulubionych + Usuń z ulubionych + Anuluj + Wł. + Wył. + Widoczne + Ukryte + Wstecz + Otwórz + Resetuj + Zamknij + Sterowanie strumieniem + Wyjdź + Gotowe + Otwórz wysyłanie klawiszy + Obraz + Sterowanie + Pomoc + Kontroler + Układ dotykowy + Dźwięk + Wyciszono + Pasek stanu + %1$s · %2$d elementów + Wyostrzanie strumienia + Poziom ostrości + Rozciągnij, aby dopasować + Na żywo + W tej sesji aktywne jest %1$d Mb/s + Ustawienia › Strumień obowiązuje od następnej sesji + Mikrofon + Wymagane uprawnienie + Menu Steam + Wyślij klawisz Home do zdalnego komputera + Esc + Enter + + Mysz kontrolera + Prawy drążek · A kliknięcie · B prawy przycisk + Mysz obsługiwana palcem + Kliknięcie bezpośrednie + Kontroler dotykowy + Wykryto wbudowane sterowanie dotykowe + Ta gra obsługuje wbudowane sterowanie dotykowe. Jeśli wolisz, możesz również włączyć poniżej kontroler dotykowy OpenNOW. + Ta gra obsługuje wbudowane sterowanie dotykowe. W tej sesji kontroler dotykowy OpenNOW jest włączony. + Wbudowane sterowanie aktywne + Drążki + Stałe + Dynamiczne + Wibracje telefonu jako zastępstwo + Tryb myszy kontrolera + Lewy drążek porusza · Prawy przewija · A klika · B klika prawym + Tryb myszy + Skonfiguruj emulację myszy + Sterowanie dotykowe + Układ kontrolera, drążki i wibracje + Zgłoś problem + Uruchom testy i wyślij zanonimizowaną diagnostykę + Tryb edycji przez przeciąganie + Zresetuj układ dotykowy + Przywróć domyślne pozycje + Skala układu + Rozmiar przycisków + Krycie + Odstęp od krawędzi + Odstęp od dołu + Pozycja po lewej + Pozycja po prawej + Drążki + Dostosuj dotykowe sterowanie analogowe + Dynamiczne położenie + Zaczyna wyśrodkowany pod kciukiem + Używa zapisanego stałego środka + Rozmiar drążka + Martwa strefa + Tryb dynamiczny zachowuje zapisany obszar drążka, ale uznaje pierwsze dotknięcie kciuka za pozycję neutralną. Zapobiega to nagłemu ruchowi, gdy nie trafisz dokładnie w środek. + Pasek stanu + Wybierz układ i informacje + Wygląd + Pozycja + Elementy + FPS + Ping + Przepływność + Bateria + Połączenie + Rozdzielczość + Kodek + Serwer + Dek. / Jit. + Straty + Klawiatura + %1$d/100 + %1$s + Nie pokazuj ponownie raportów z sesji + Połączenie + Nie zmierzono + Opóźnienie + Szybkość strumienia + Utrata pakietów + Wahania + Liczba klatek + Dekodowanie + średnio %1$d ms + szczyt %1$d ms + szczyt %1$s + Stabilne + Może wpływać na ostrość + Zmienność czasu + Średnie / docelowe FPS + Na klatkę obrazu + Sterowanie sesją + Zakończyć strumień? + Czy na pewno chcesz zakończyć %1$s? + Bieżąca sesja grania w chmurze zostanie zamknięta. + Graj dalej + Zakończ strumień + Zgłaszanie błędów + Zgłoś błąd + Zgłoś błąd strumienia + Wyślij opis problemu i zanonimizowaną diagnostykę + Pokaż opis + Ukryj opis + Ping %1$s + Dek. %1$s ms + Jit. %1$s ms + Straty %1$s%% + %1$d klatek na sekundę + Ping %1$d milisekund + Czas dekodowania %1$s milisekund na klatkę + Wahania %1$s milisekund + Utrata pakietów %1$s procent + dobra + przeciętna + słaba + Zamknij + Graj na %1$s + Wyczyść filtry + Wróć na górę + Automatycznie + Wkrótce + Wybierz platformę + Platformy + Domyślna + Wybrana + Dostępna platforma + Nie pytaj ponownie — ustaw ten sklep jako domyślny + Kontynuowanie z domyślnym sklepem: %1$s + Wskazówka: przytrzymaj Graj, aby później wybrać inny sklep. + Przytrzymaj Graj, aby wybrać sklep + Strumień + Interfejs + Jakość + Obraz + Połączenie + Dźwięk i klawiatura + Sterowanie wskaźnikiem + Blokada myszy + Przechwytuje zewnętrzną mysz w grze podczas strumieniowania. Otwarcie sterowania strumieniem zwalnia ją. + Kontroler i dotyk + Wygląd + Biblioteka i nawigacja + Pasek stanu + Dźwięki i sesje + Pokaż raport z sesji + Pokaż podsumowanie jakości po każdym strumieniu. + Narzędzia zaawansowane + Podziękowania + Rozdzielczość + Proporcje obrazu + Ustawienie strumienia + Zalecane + Niestandardowe + Niskie (oszczędzanie danych) + Średnie + Wysokie + FPS + Przepływność Mb/s + Kodek + Kolor + Tylko H.264/H.265 + AV1 używa 8-bitowego koloru. Wybierz H.265 dla 10 bitów; HDR jest ograniczony do zgodnych trybów Android TV. + AV1 używa 8-bitowego koloru w OpenNOW. Przełączono na 8 bitów i wyłączono HDR. Wybierz H.265 lub H.264, aby użyć 10 bitów. + HDR (Performance & Ultimate) + Strumieniowanie HDR nie jest dostępne na przenośnych urządzeniach Android. 10-bitowy SDR jest nadal dostępny z H.265. + HDR na Android TV wymaga H.265 przy maksymalnie 60 FPS i rozdzielczości do 3840 × 2160. + Region + Proxy sesji + Kieruje tworzenie sesji GFN i sprawdzanie kolejki przez to proxy. Wyłącz dla bezpośrednich żądań. + Adres URL proxy + Kopiuj diagnostykę kodeka + Skopiowano diagnostykę kodeka + Test kodeka nie został jeszcze uruchomiony. + Włączyć proxy sesji? + Tworzenie sesji GFN, sprawdzanie kolejki, wznawianie, zatrzymywanie i aktualizacje reklam w kolejce będą kierowane przez podane proxy. + Nieprawidłowe lub zablokowane proxy może zakłócić uruchamianie, postęp kolejki, wznawianie aktywnej sesji lub jej zamykanie. + Używaj tylko zaufanego proxy. Jego operator może widzieć czas żądań, hosty docelowe i poufne metadane ruchu sesji. + Włącz proxy + Eksperymentalne strumieniowanie + Może powodować błędy uruchamiania sesji. + L4S + Żąda ścieżki transportu NVIDIA o niskim opóźnieniu i małych stratach, gdy serwer i sieć ją obsługują. Wyłącz, jeśli sieć staje się niestabilna. + Żądanie Cloud G-Sync / VRR + Prosi sesję w chmurze o użycie zmiennej częstotliwości odświeżania, jeśli obsługują ją urządzenie, ekran, plan i sesja GFN. + Mikrofon + Przesyła domyślny mikrofon Androida do zdalnej gry. Możesz go wyciszyć w sterowaniu strumieniem. + Nie przyznano uprawnienia do mikrofonu. OpenNOW pozostawi strumieniowanie mikrofonu wyłączone. + Użyj kolorów systemu + Akcent + Strona startowa + Sklep + Biblioteka + Wyłącz sprawdzanie aktualizacji + Opcje zaawansowane + Pokazuje eksperymentalne opcje katalogu i dostrajania. Karta zaawansowanej diagnostyki pozostaje dostępna. + Wyrazisty styl kart + Używa jaśniejszych powierzchni kart i łagodniejszych rogów. Wyłącz, aby uzyskać bardziej płaski i spokojny styl Material. + Tło katalogu + Pokazuje obraz tła za Sklepem i Biblioteką na ekranach urządzeń przenośnych. + Obraz tła + Własny obraz + Wbudowane tło + Kolorowa abstrakcja (domyślnie) + Oryginalne OpenNOW + Absolute Cinema + Wybierz obraz + Użyj domyślnego + Odstęp od krawędzi ekranu + Kompaktowe karty gier + Pokaż etykiety sklepów + Rozmiar kart gier + Ukryj przyciski strumienia + Przycisk klawiatury ekranowej + Pokazuje małą ikonę klawiatury na pasku stanu strumienia. + Domyślnie pokazuj pasek stanu + Pozycja nakładki statystyk + Ukryj wybór serwera + Dźwięki przycisków + Odtwarza krótki dźwięk interfejsu podczas nawigacji kontrolerem i naciskania przycisków ekranowych. + Odtwarzaj muzykę powitalną + Muzyka powitalna zaczyna + Wyciszona + Odtwarzana + Odtwarzaj muzykę po zakończeniu kolejki + Wycisz muzykę + Rozciągnij strumień, aby wypełnić ekran + Inteligentny licznik sesji + Dziękujemy osobom, które pomagają ulepszać OpenNOW dla wszystkich. + DarkevilPT + Wsparcie społeczności + Wesprzyj + Skopiowano link do wsparcia + OpenNOW + Błękit Pixel + Jaskrawy róż + Limonkowy + Koralowy + Fioletowy + Natywny streamer (eksperymentalny) + Przechwytuje dekoder sprzętowy, aby ustawić właściwości niskiego opóźnienia producenta. Może działać niestabilnie. + Automatyczny natywny dotyk używa trybu gamepada dla strumieni o wysokiej rozdzielczości lub FPS, aby zachować wybrany tryb. Wybierz Każda gra, aby nadać priorytet natywnemu dotykowi. + Minimalizuj + Wyświetl + Graj na telewizorze + Uruchamianie strumienia + Pozycja w kolejce: %1$d + Oczekiwanie na komputer + Łączenie ze strumieniem + Wznawianie sesji + Przygotowywanie komputera + Uruchamianie sesji + Stan kolejki + Gra %1$s jest gotowa! + Oczekiwanie w kolejce GFN dobiegło końca. Dotknij, aby wrócić do aplikacji. + Strumieniowanie trwa po wyłączeniu ekranu + Brak w bibliotece + Nieznany wydawca + Wznów sesję w chmurze + Aplikacja %1$s + Kolejka %1$d + Uruchamianie + Opis tej gry nie jest jeszcze dostępny. + Układ klawiatury + Język gry + Wklejanie ze schowka + Dalej + Spróbuj ponownie + Uruchom + Pomiń + Odśwież + Wyślij + OK + Cofnij + Zainstaluj + Zarządzaj + Zezwól + Aktywna + Gotowe + Sprawdzanie + Najlepsza dostępna trasa + Przed Tobą + Oczekiwanie + Licznik sesji + Wyczyść pamięć podręczną + Zresetuj samouczek + Zresetuj ustawienia + Zresetuj i uruchom ponownie + Przełącz + Dodaj konto + Wyloguj się + Wyloguj się ze wszystkich kont + Wybierz dostawcę + Statystyki czasu gry + Pamięć w chmurze + Dodaj pamięć + Zmień lokalizację pamięci + Brak aktywnej transmisji + Wróć do biblioteki + Zakończ sesję w chmurze + Krok %1$d z %2$d + Naciśnij Gotowe + Wykryto kontroler + Kontroler ekranowy został ukryty, ponieważ podłączono kontroler fizyczny. + Nie pokazuj ponownie + Uruchamianie parowania telefonu… + Sparuj z aplikacją OpenNOW na telefonie + Najpierw zainstaluj i otwórz OpenNOW na telefonie z Androidem. Połącz telefon i telewizor z tą samą siecią Wi‑Fi, a następnie zeskanuj ten kod QR aparatem telefonu. Łącze wygaśnie po pięciu minutach. + Parowanie z telewizorem + Sparuj z telewizorem + Telefon i telewizor muszą być w tej samej sieci Wi‑Fi. Zeskanuj tutaj kod QR telewizora albo znajdź telewizor i wpisz jego 4-cyfrowy kod. + Połączono z %1$s. Gry pokazują teraz opcję Graj na telewizorze. + Połączono z %1$s + Zaloguj telewizor + Zapomnij telewizor + Zeskanuj kod QR lub znajdź telewizor w sieci + Skanuj QR telewizora + Nie można otworzyć skanera QR + Znajdź telewizor + Wyszukiwanie… + Kod telewizora + Wpisz 4-cyfrowy kod wyświetlany na tym telewizorze. + Sparuj + Konta i usługi + Profile, członkostwo, pamięć i sklepy z grami + Nowe gry + Wyniki + Wyróżniony baner biblioteki + Wygląd kontrolera dotykowego + Kolor kontrolera dotykowego + Litery przycisków + Celowanie żyroskopem + Nie znaleziono telewizora OpenNOW w tej sieci. + Otwórz profil konta + Nazwa użytkownika + Poziom + E-mail + Opcje konta + %1$s • %2$s + Niedostępne + Opcje programisty + Resetuj przepływy, sprawdzaj środowisko i odbuduj stan lokalny + Do celów rozwojowych i wsparcia + Te działania resetują wyłącznie lokalny stan OpenNOW i pokazują informacje, które są już w eksporcie diagnostycznym. Destrukcyjne wymagają potwierdzenia. Możesz ponownie ukryć tę stronę na dole listy. + Przepływy i komunikaty + Katalog i sklepy + Transmisja + Interfejs + Diagnostyka + Destrukcyjne + Resetuj + Wyczyść + Uruchom + Zastosuj + Kopiuj + Odtwórz ponownie + Ukryj + Odtwórz pierwsze uruchomienie + Konfiguracja, przewodniki, komunikaty, zgoda i stan przeglądania naraz + Konfiguracja zostanie uruchomiona ponownie, wszystkie jednorazowe komunikaty wrócą, zgoda na analitykę zostanie wycofana do czasu ponownej odpowiedzi, a kolejność w Sklepie i Bibliotece zostanie zresetowana. Konta, ulubione i ustawienia transmisji pozostaną nietknięte. + Uruchom konfigurację ponownie + Pokazuje ekrany pierwszego uruchomienia przy następnym starcie + Pokaż ponownie przewodnik transmisji + Pojawi się ponownie przy następnym starcie transmisji + Pokaż ponownie komunikat kontrolera + Pojawi się ponownie przy następnym podłączeniu kontrolera + Zapytaj ponownie o zgodę na analitykę + Pozostaje wyłączone, dopóki pytanie nie zostanie ponownie rozstrzygnięte + Odtwórz migracje aktualizacji + Ponownie stosuje jednorazowe ustawienia prezentacji i układu TV + Wyczyść pamięć podręczną gier + Usuwa zapisane wyniki Sklepu, Biblioteki i wyszukiwania + Pobierz katalog ponownie + Ładuje teraz Sklep i Bibliotekę od dostawcy + Resetuj stan przeglądania + Kolejność i filtry Sklepu oraz Biblioteki wracają do domyślnych + Zapomnij wybory launchera + Wszystkie zapamiętane wybory sklepu dla poszczególnych gier + Wyczyść ulubione + %1$d zapisanych + Każda ulubiona gra zostanie usunięta. Tej operacji nie można cofnąć. + Wyczyść półkę aplikacji + %1$d przypiętych zainstalowanych aplikacji + Zastosuj zmierzoną rekomendację + Resetuj układ dotykowy + Rozmiar nakładki, krycie i wszystkie przesunięcia przycisków + Odśwież kolejki serwerów + Ponownie odpytuje listę stref PrintedWaste i pingi + Resetuj interfejs + Akcent, tło, układ kart i domyślne animacje + Kopiuj dziennik diagnostyczny + Oczyszczony, ten sam tekst, który dołącza zgłoszenie błędu + Kopiuj podsumowanie środowiska + Powyższa tabela jako tekst + Sprawdź aktualizacje + Uruchamia sprawdzanie aktualizacji natychmiast + Wyloguj się ze wszystkich kont + %1$d zapisanych + Wszystkie zapisane konta zostaną usunięte z tego urządzenia, a OpenNOW wróci do ekranu logowania. + Wymaż dane aplikacji i uruchom ponownie + Przywraca OpenNOW do stanu świeżej instalacji + Konta, ustawienia, zapisane gry i pliki lokalne zostaną usunięte, a OpenNOW uruchomi się ponownie jak świeża instalacja. Tej operacji nie można cofnąć. + Ukryj opcje programisty + Dotknij dziesięć razy numeru kompilacji w sekcji Informacje, aby je przywrócić + Kompilacja + Wariant + Urządzenie + Android + Profil układu + Abonament + Dostawca + Profil transmisji + Dekodery sprzętowe + Gry ze Sklepu / Biblioteki + Brak + Wylogowano + Android TV + Konsola przenośna + Przywrócono stan pierwszego uruchomienia + Konfiguracja uruchomi się przy następnym starcie + Przewodnik transmisji zostanie pokazany ponownie + Komunikat kontrolera zostanie pokazany ponownie + Zgoda na analitykę zostanie zapytana ponownie + Migracje aktualizacji zostaną odtworzone + Zresetowano stan przeglądania + Zapomniano wybory launchera + Wyczyszczono ulubione + Wyczyszczono półkę aplikacji + Zastosowano zmierzoną rekomendację + Zresetowano układ dotykowy + Zresetowano interfejs + Skopiowano dziennik diagnostyczny + Skopiowano podsumowanie środowiska + Ukryto opcje programisty + Jeszcze %1$d dotknięć, aby pokazać opcje programisty + Opcje programisty są teraz w Ustawieniach + Opcje programisty są już widoczne + Dalej + Niedostępne + Wibracje + Wibracje kontrolera, gdy są dostępne; w przeciwnym razie wibracje urządzenia + Wyjście wibracji + Niektóre konsole przenośne zgłaszają silnik wibracji we wbudowanym padzie, który nie jest do niczego podłączony. Wymuś silnik telefonu, jeśli wibracje w grze pozostają nieme. + Automatycznie + Kontroler + Telefon + Celowanie dotykiem + Zablokuj joystick + Zablokuj strefę / celowanie dotykiem + Przeciągnij w dowolnym miejscu prawej strefy, aby celować względnym ruchem myszy. + STREFA CELOWANIA + Społeczność Discord + Uzyskaj pomoc i śledź zgłoszenia błędów razem ze społecznością OpenNOW. + Wsparcie społeczności i śledzenie zgłoszeń błędów + Dołącz + Skopiowano zaproszenie do Discorda + Sortuj i filtruj + Sortuj i filtruj, aktywne: %1$d + Sortowanie + Popularne + Ostatnio grane + Filtry + Sterowanie + Mobilne sterowanie dotykowe + Liczba pikseli wysyłanych przez komputer w chmurze. Wyższe rozdzielczości wyglądają ostrzej, ale wymagają większej wydajności dekodera, GPU i sieci. + Dopasowuje kształt transmisji do wyświetlacza. Niedopasowane proporcje mogą dodać czarne pasy lub rozciągnięcie; nie przyspieszają dekodera. + „Zalecane” korzysta z wyświetlacza, pamięci, liczby procesorów, profilu Androida i zweryfikowanych sprzętowych dekoderów WebRTC tego urządzenia. „Niestandardowe” zachowuje Twoje ręczne wybory. + Wykryta rekomendacja: %1$s + Liczba klatek na sekundę decyduje o płynności ruchu. Więcej FPS daje dekoderowi mniej czasu na klatkę i może powodować przycięcia na wolniejszym sprzęcie. + Maksymalna przepływność wideo. Wyższy bitrate może poprawić szczegółowość, ale tylko gdy łącze ma wystarczającą stabilną przepustowość; nie zwiększa FPS. + H.264 jest najbardziej zgodny. H.265 wydajniej wykorzystuje pasmo i jest preferowany przy wysokiej rozdzielczości, gdy istnieje zweryfikowany dekoder sprzętowy. AV1 działa tu w 8 bitach i jest używany tylko na urządzeniach ze zgodną ścieżką sprzętową. + 8-bitowy 4:2:0 jest najlżejszy i najbardziej zgodny. 10 bitów poprawia gradienty, ale zwiększa wymagania dekodera i pasma. Android automatycznie normalizuje nieobsługiwane kombinacje. + HDR wymaga zgodnego wyświetlacza Android TV, H.265, wideo 10-bitowego i obsługiwanego abonamentu. Zwiększa obciążenie przetwarzania i nie jest zalecany przy diagnozowaniu opóźnień. + Dodaj własne aplikacje + Pokazuje półkę w Bibliotece, na której możesz dodawać, uruchamiać i usuwać zainstalowane aplikacje i gry Android. + Ustaw jako domyślny launcher + Otwiera wybór launchera w Androidzie, aby OpenNOW mógł zostać ekranem głównym. Możesz to zmienić ponownie w ustawieniach Androida. + Wybierz launcher + OpenNOW jest domyślny + Zarządzaj + Moje aplikacje + Dodaj aplikację + Wybierz aplikację + Wczytywanie zainstalowanych aplikacji… + Pokaż moje aplikacje, zainstalowanych: %1$d + Ukryj moje aplikacje, zainstalowanych: %1$d + Nie znaleziono innych aplikacji, które można uruchomić. + Usuń %1$s + To usuwa tylko skrót z Twojej półki. Aplikacja pozostaje zainstalowana. + Usuń + Wyróżnione + Każda skórka to inny kontroler, a nie tylko inny kolor: kształt przycisków, to, czy krzyżak jest jednym elementem czy czterema osobnymi klawiszami, oraz obszar, po którym porusza się gałka, zmieniają się razem z nią. + Zmienia kolory skórek zbudowanych wokół akcentu. Klasyczna, Kontur, Szron i Wysoki kontrast są z założenia monochromatyczne. + „Wyłączone” zostawia przyciski puste, gdy układ wejdzie już w pamięć mięśniową. + Skórka + Rozmiar przycisków akcji + Rozmiar krzyżaka + Rozmiar spustów i przycisków bocznych + Rozmiar menu i kliknięcia gałki + Rozmiar lewej gałki + Rozmiar prawej gałki + Rozmiar główki gałki + Przechyl telefon, aby celować względnym ruchem myszy. + To urządzenie nie zgłasza żyroskopu. + Czułość żyroskopu + Martwa strefa żyroskopu + Wygładzanie żyroskopu + Odwróć żyroskop w poziomie + Odwróć żyroskop w pionie + Celowanie ruchem + Obracający się baner nad siatką Biblioteki na telefonach w orientacji pionowej. + Ta aplikacja nie jest już zainstalowana lub nie można jej otworzyć. + Kolejność biblioteki + Ostatnio grane + Tytuł A–Z + Animowane obramowania zaznaczenia + Animuje zaznaczone gry, pozycje menu, wybory serwerów i opcje launchera. Wyłącz, aby zaznaczenie było spokojniejsze. + Efekty Absolute Cinema + Używa animowanych pomarańczowych i niebieskich pierścieni fokusu, zachowując wybrany kolor interfejsu. + Jestem szalony + Puść Absolute Cinema na cały interfejs. Grafiki, opisy, elementy sterowania i więcej otrzymują efekt przy najechaniu i zaznaczeniu. + Pokaż ikonę ulubionych na kartach gier + Pokazuje przycisk ulubionych na kartach gier na telefonie, konsoli przenośnej i TV. + Domyślnie włączone. Wypełnia wyświetlacz zamiast zostawiać czarne pasy, rozciągając obraz tylko na niedopasowanej osi — nigdy go nie przycinając. Wyłącz, aby zachować dokładną geometrię. + Stosuje dodatkowy filtr GPU po dekodowaniu. Może poprawić postrzeganą szczegółowość, ale zwiększa obciążenie renderowania na wolniejszych urządzeniach. + Steruje siłą filtra wyostrzania w postprodukcji. Nie zmienia rozdzielczości transmisji źródłowej. + Absolute Cinema + Switch + Rozpocznij + Dalej + Wstecz + Pomiń + Zakończ + Natywne GeForce NOW na Androida + Dostosuj do siebie + Wszystko tutaj stosuje się od razu po wybraniu. + Akcent + Animacje interfejsu + Połysk, poświata fokusu i ruch karuzeli. Wyłączenie respektuje też systemowe ustawienie animacji. + Podgląd + Układ + Każdy z nich przerysowuje podgląd + Tytuły gier pod grafiką + „Wyłączone” zostawia siatkę jako czystą grafikę okładek. + Kwadratowe karty + Przycina okładkę do kwadratu, aby na ekranie zmieściło się więcej gier. + Przycisk ulubionych na grafice + Zapisuje grę w Bibliotece bez jej otwierania. + Zaokrąglone rogi + Łagodniejsze krawędzie kart i paneli w całej aplikacji. + Absolute Cinema + Animowane ramki energii wokół zaznaczonego elementu. Zwykle rozwiązanie dla kontrolera i TV. + Reakcje + Obie uruchomią się przy następnym dotknięciu + Wibracje + Krótkie drgnięcie przy wyborze i wibracje kontrolera w grze tam, gdzie urządzenie je obsługuje. + Dźwięki interfejsu + Dźwięk przy naciskaniu przycisków i nawigacji po menu. Nie wpływa na dźwięk gry. + Tło + Wyłączone + Domyślne + Brak + Tło aplikacji + Tapeta + Twój obraz + Jakość transmisji + Zmierzona na podstawie wyświetlacza, układu i dekoderów tego urządzenia. + Pomiar tego urządzenia + Zalecana + Oszczędzanie danych + 720p, 30 FPS, 12 Mb/s + Najlepsza jakość + Do %1$s przy %2$d FPS w Twoim planie + Ustawię samodzielnie + Wybierz poniżej rozdzielczość, liczbę klatek i bitrate + Abonament %1$s + Twój plan transmituje do %1$s przy %2$d FPS. Wyższe opcje są wymienione wraz z poziomem, który je odblokowuje. + Podniesienie abonamentu GeForce NOW zwiększa ten limit — OpenNOW go nie ogranicza. + Podczas gry + Wybierz, jak ma się zachowywać transmisja. + Podgląd + Mysz dotykowa + Bezpośrednio + Dotknij tam, gdzie chcesz kliknąć + Panel dotykowy + Przesuń, aby poruszyć, potem dotknij + Wyłączone + Użyj kontrolera lub fizycznej myszy + Dotknięcie = ruch + kliknięcie + Przesuń, potem dotknij + Kontroler / mysz + Graj + Pasek stanu + FPS, ping, bateria i połączenie na pierwszy rzut oka. + Pozycja + 60 FPS • 24 ms • Wi-Fi + Gdy coś się zepsuje + Przycięcia, czarne ekrany, martwe kontrolery. + Wbudowane zgłaszanie błędów + Otwórz je z elementów sterowania w trakcie transmisji lub z raportu proponowanego po zakończeniu sesji. + Najpierw porównuje Twoje ustawienia z sesją i oznacza znane problematyczne kombinacje, zwykle z rozwiązaniem. + Wysyła Twój opis oraz oczyszczoną diagnostykę — ustawienia, pomiary dekodera i sieci, model urządzenia. Bez danych konta. + Raport sesji po każdej transmisji + Opóźnienie, tempo klatek i utrata pakietów po zakończeniu sesji, ze skrótem do zgłaszania błędów. + Udostępniaj anonimową diagnostykę + Pomaga znaleźć wzorce wśród awarii i problemów z wydajnością. Dane wrażliwe są usuwane i nic nie jest sprzedawane. Przy wyłączonej opcji raport awarii może nie zawierać dość danych do zbadania. + Gotowe + Wszystko możesz zmienić w Ustawieniach. + Twoje wybory + Jakość transmisji + Mysz dotykowa + Pasek stanu + Włączone + Wyłączone + Konfiguracja + Uruchom konfigurację ponownie + Przejrzyj wygląd, jakość transmisji, sterowanie w grze, pasek stanu i zgłaszanie błędów + Wymaga %1$s + GeForce NOW podaje %1$s tylko jako %2$s, a to konto korzysta z %3$s. Sesja najprawdopodobniej zostanie odrzucona lub obniżona do niższego profilu. + O uprawnieniach decyduje GeForce NOW, a nie OpenNOW, a katalog bywa nieaktualny — więc i tak możesz spróbować. + Spróbuj mimo to + Kopiuj błąd + Odłącz + Wróć + Zaloguj się + Udostępniaj analitykę + Udostępniaj anonimową diagnostykę, aby pomóc nam znaleźć wzorce w błędach, awariach i problemach z wydajnością. Dane wrażliwe są usuwane i nie sprzedajemy Twoich danych. + Jeśli udostępnianie jest wyłączone w czasie awarii, możemy nie mieć wystarczających informacji, aby zbadać Twoje zgłoszenie. Domyślnie jest wyłączone i można je zmienić w ustawieniach prywatności. + Zostaw wyłączone + Udostępnić diagnostykę? + Sprawdzanie najnowszej kompilacji… + Sprawdzanie Google Play… + Sprawdzanie tej sesji… + Kontrole + Ten sam dziennik ze znacznikami czasu dostępny w Ustawienia > Zaawansowane > Dzienniki debugowania jest dołączany automatycznie. Żadne inne pliki nie są dodawane. + Twoje dane nie są sprzedawane i służą wyłącznie do badania i naprawiania błędów. + Automatyczny dziennik usuwa nazwy kont, dane logowania, identyfikatory sesji i adresy sieciowe przed wysłaniem. Surowy identyfikator urządzenia nie jest wysyłany. + Co jest zbierane? + Wpisany przez Ciebie tytuł i opis są wysyłane dokładnie tak, jak je napiszesz, więc nie umieszczaj w nich informacji osobistych ani wrażliwych. + Opiekunowie PrintedWaste i OpenNOW mogą zobaczyć treść zgłoszenia, wersję aplikacji, model urządzenia, wersję Androida, dostawcę i kategorię abonamentu, bieżącą grę, stan i ustawienia transmisji, pseudonimowy identyfikator instalacji służący zapobieganiu nadużyciom oraz oczyszczony dziennik diagnostyczny. + Wysłać to zgłoszenie i załączoną oczyszczoną diagnostykę do API PrintedWaste? + Wysłać zgłoszenie błędu? + Wyrażam zgodę na wysłanie tego zgłoszenia. + Rozumiem, co zostanie przesłane, i zgadzam się na wysłanie tego do API PrintedWaste. + Opisz błąd po angielsku. Diagnostyka sesji jest dołączona. + Co się stało? + Co robiłeś, co poszło nie tak i czy potrafisz to powtórzyć? + Wymagany język angielski + Ustaw OpenNOW lub język urządzenia na angielski przed zgłoszeniem. + Opisz problem bez wychodzenia z gry. + Rozumiem, że OpenNOW znalazł prawdopodobną przyczynę. Wyślij mimo to; mogę stracić przyszły dostęp do zgłaszania. + DOPASOWANE SUGESTIE + Dla tej kontroli nie są sugerowane nieistotne rozwiązania. + Kontrole na żywo z tego urządzenia i tej sesji + Zanim zgłosisz + Ponów sprawdzanie wersji + Sprawdź i wyślij + Wyślij kolejne + Wyślij mimo to + Wysyłanie… + Zgłoszenie błędu wysłane + Nadal występuje po zastosowaniu dopasowanej sugestii? Kontynuuj, a zmierzone dowody zostaną dołączone automatycznie. + Tytuł problemu + Transmisja zamarła po ponownym połączeniu + Zaktualizuj w Google Play + Prześlij zgłoszenie + Przesłać zgłoszenie błędu? + Przesyłanie zgłoszenia… + Użyj angielskiego w OpenNOW + Sprawdzanie kolejek i opóźnień PrintedWaste + Opis + Filtry + Kierowanie kolejki poziomu darmowego + Zrzuty ekranu + Przycisk Wstecz na pilocie + Szczegóły + Urządzenie, typ konta, profil transmisji, bieżący stan i tymczasowy adres URL zostały skopiowane do schowka. + Skopiowano diagnostykę + OpenNOW usunie tokeny, identyfikatory kont, adresy e-mail, identyfikatory sesji i adresy sieciowe przed wysłaniem. + Losowy link nie jest publiczny, ale nie jest szyfrowany, a serwis usuwa przesłane dane w ciągu 24 godzin. + Utworzyć tymczasowy link diagnostyczny? + Usuwanie danych wrażliwych i tworzenie tymczasowego linku… + Przygotowywanie diagnostyki + Nie udało się utworzyć kodu QR. Zamknij to okno i spróbuj ponownie. + Zeskanuj ten kod QR telefonem. Oczyszczony link wygasa w ciągu 24 godzin. + Zeskanuj link diagnostyczny + Oczyść i prześlij + Brak dostępnej przeglądarki + Nie udało się otworzyć strony sklepu + Nie udało się rozpocząć łączenia ze sklepem + Nie udało się odłączyć sklepu + Token dostępu + Nie udało się wyeksportować dzienników + Wyeksportowano dzienniki + KOD PAROWANIA + Natywny klient GeForce NOW na Androida + Wklej token dostępu NVIDIA lub odpowiedź JSON z tokenem. OpenNOW weryfikuje token dostępu przed zapisaniem konta. + Zaloguj się tokenem + Używaj wyłącznie danych logowania do konta, które należy do Ciebie. + Zaloguj się tokenem bez przeglądarki lub wyeksportuj diagnostykę przed zalogowaniem. + Narzędzia logowania + Użyj logowania kodem + Reklama + Pozycja na żywo + Kolejka + WSTECZ + Zgłoś to + Napotkałeś błąd? + Dostarczony profil + To była krótka sesja, więc wynik może się wahać bardziej niż zwykle. + Raport sesji + Co zrobić dalej + Dlaczego profil się zmienił + Te ustawienia przekraczają wykrytą rekomendację + Aktywność w tle + Zoptymalizowana (może wygasnąć w tle) + Bez ograniczeń (dozwolona w tle) + Optymalizacja baterii w Androidzie ogranicza działanie aplikacji w tle, co może powodować przekroczenie limitu czasu połączenia lub wstrzymać postęp w kolejce GFN, gdy aplikacja jest zminimalizowana. + Zapisane wyniki sklepu, biblioteki i wyszukiwania zostaną usunięte. Twoje konto i ustawienia pozostaną bez zmian. + Wyczyścić pamięć podręczną gier? + Pomoc z połączeniem + Eksportuje stan uruchamiania, stan kolejki, aktualizacje transmisji, zdarzenia odzyskiwania, ustawienia, możliwości kodeków oraz ostatnie oczyszczone odpowiedzi JSON CloudMatch. + Programista + Dla tego konta nie jest aktywny żaden dodatek trwałego magazynu. + Informacje o wydaniu + Konta, ustawienia, zapisane gry, stan samouczka i lokalne pliki aplikacji zostaną usunięte. OpenNOW uruchomi się ponownie jak świeża instalacja. + „Resetuj samouczek” sprawia tylko, że przewodnik transmisji pojawi się ponownie. „Resetuj ustawienia” jest destrukcyjne: czyści lokalne dane aplikacji i ponownie uruchamia OpenNOW. + Zresetować ustawienia i dane aplikacji? + Wybierz dostawcę GeForce NOW dla nowego konta. + Wykorzystanie magazynu + Połączenia ze sklepami + Dane wrażliwe są usuwane przed utworzeniem niepublicznego, tymczasowego linku. Zeskanuj kod QR telefonem, aby go udostępnić. + Prześlij dzienniki i pokaż kod QR + To wysyła podsumowanie zmiany profilu i prawdopodobną przyczynę do opiekunów PrintedWaste i OpenNOW, aby mogli to zbadać. + Wysłać diagnostykę transmisji? + Wyślij diagnostykę + OpenNOW nie ma teraz podłączonej lokalnej transmisji. + Profil transmisji się zmienił + Dlaczego tak się stało + Wysyłanie zgłoszenia i oczyszczonej diagnostyki… + Twoje zapisane ustawienia transmisji nie zostały zmienione. + Sesja w chmurze jest już aktywna + Zakończ i uruchom nową + Wpisz lub edytuj tekst transmisji + Pamięć podręczna gier była już pusta + Wyczyszczono pamięć podręczną gier + Czyszczenie danych aplikacji i ponowne uruchamianie OpenNOW + Zalogowano bezpiecznie z telefonu + Odłączono sklep + Samouczek pojawi się przy następnej transmisji + Przeciągnij + + + %1$d serwer + %1$d serwery + %1$d serwerów + %1$d serwera + + diff --git a/android/app/src/main/res/values-pt/strings.xml b/android/app/src/main/res/values-pt/strings.xml new file mode 100644 index 000000000..1b5cc954e --- /dev/null +++ b/android/app/src/main/res/values-pt/strings.xml @@ -0,0 +1,837 @@ + + + OpenNOW + A iniciar o OpenNOW + Iniciar sessão com %1$s + Iniciar sessão noutro dispositivo com %1$s + Utilize este código para iniciar sessão + %1$s + A aguardar o início de sessão + O código expira dentro de %1$d:%2$02d + Loja + Pesquisar + Biblioteca + Definições + Pesquisar jogos + Pesquisar definições + Geral + Atualizações, privacidade e dados da aplicação + Idioma + Idioma da aplicação + Predefinição do sistema + Inglês + Transmissão + Resolução, FPS, codec, HDR, proxy + Entrada + Microfone, rato, teclado, controlos táteis e vibração + Interface + Aspeto, biblioteca, barra de estado e sons + Conta + Início de sessão, armazenamento e lojas associadas + Avançado + Opções avançadas, experiências, diagnósticos e registos + Acerca de + Versão, créditos e apoio + Mostrar títulos dos jogos + Limpar pesquisa + Pesquisa por voz + %1$d jogos + Nenhum jogo carregado + Nenhum jogo correspondente na biblioteca + Limpe a pesquisa para mostrar todos os jogos da sua biblioteca. + Limpe os filtros para mostrar todos os jogos da sua biblioteca. + Limpe a pesquisa ou os filtros para mostrar todos os jogos da sua biblioteca. + Nenhum jogo correspondente na loja + Limpe a pesquisa para mostrar mais jogos. + Limpe os filtros para mostrar mais jogos. + Limpe a pesquisa ou os filtros para mostrar mais jogos. + Voltar ao jogo + Brevemente + Novidades no GeForce NOW + Continuar a jogar + Na fila + Favoritos + Recomendações + Ver tudo + Jogar + Continuar + Retomar + Guardar + Guardado + Adicionar aos favoritos + Remover dos favoritos + Cancelar + Ligado + Desligado + Visível + Oculto + Voltar + Abrir + Repor + Fechar + Controlos da transmissão + Sair + Concluído + Abrir envio pelo teclado + Ecrã + Entrada + Apoio + Comando + Disposição tátil + Áudio + Sem som + Barra de estado + %1$s · %2$d itens + Nitidez da transmissão + Nível de nitidez + Esticar para ajustar + Em direto + %1$d Mbps ativos nesta sessão + Definições › Transmissão aplica-se apenas à próxima sessão + Microfone + É necessária autorização + Menu Steam + Enviar Início para o PC remoto + Esc + Enter + + Rato com comando + Manípulo direito · A clica · B clica com o botão direito + Rato com o dedo + Clique direto + Comando tátil + Foram detetados controlos táteis integrados + Este jogo suporta controlos táteis integrados. Se preferir, pode ativar abaixo o comando tátil do OpenNOW. + Este jogo suporta controlos táteis integrados. O comando tátil do OpenNOW está ativo nesta sessão. + Controlos integrados ativos + Manípulos + Fixo + Dinâmico + Vibração do telemóvel como alternativa + Modo de rato com comando + Manípulo E move · Manípulo D desloca · A clica · B clica com o botão direito + Modo de rato + Configurar a emulação do rato + Controlos táteis + Disposição do comando, manípulos e vibração + Comunicar um problema + Executar verificações e enviar diagnósticos anonimizados + Modo de edição por arrastamento + Repor disposição tátil + Repor as posições predefinidas + Escala da disposição + Tamanho dos botões + Opacidade + Espaçamento das margens + Espaçamento inferior + Posição esquerda + Posição direita + Manípulos + Ajustar os controlos analógicos táteis + Posicionamento dinâmico + Começa centrado sob o polegar + Utiliza o centro fixo guardado + Tamanho do manípulo + Zona morta + O modo dinâmico mantém a área guardada do manípulo, mas considera neutro o primeiro ponto onde o polegar toca. Isto evita movimentos bruscos quando não acerta exatamente no centro. + Barra de estado + Escolher a disposição e as informações + Aspeto + Posição + Itens + FPS + Ping + Taxa de bits + Bateria + Ligação + Resolução + Codec + Servidor + Desc. / Var. + Perda + Teclado + %1$d/100 + %1$s + Não voltar a mostrar relatórios de sessão + Ligação + Não medido + Latência + Velocidade da transmissão + Perda de pacotes + Variação + Taxa de fotogramas + Descodificação + média de %1$d ms + pico de %1$d ms + pico de %1$s + Estável + Pode afetar a nitidez + Variação de temporização + FPS médio / pretendido + Por fotograma de vídeo + Controlo da sessão + Sair da transmissão? + Quer mesmo sair de %1$s? + A sessão atual de jogos na nuvem será encerrada. + Continuar a jogar + Sair da transmissão + Comunicação de erros + Comunicar um erro + Comunicar um erro da transmissão + Enviar um problema e diagnósticos anonimizados + Mostrar descrição + Ocultar descrição + Ping %1$s + Desc. %1$s ms + Var. %1$s ms + Perda %1$s%% + %1$d fotogramas por segundo + Ping de %1$d milissegundos + Tempo de descodificação de %1$s milissegundos por fotograma + Variação de %1$s milissegundos + Perda de pacotes de %1$s por cento + boa + razoável + fraca + Fechar + Jogar em %1$s + Limpar filtros + Voltar ao início + Automático + Brevemente + Escolher plataforma + Plataformas + Predefinida + Selecionada + Plataforma disponível + Não voltar a perguntar — tornar esta a loja predefinida + A continuar com a loja predefinida: %1$s + Sugestão: mantenha Jogar premido para escolher outra loja mais tarde. + Mantenha Jogar premido para escolher uma loja + Transmissão + Interface + Qualidade + Vídeo + Ligação + Áudio e teclado + Entrada do ponteiro + Bloqueio do rato + Mantém um rato externo preso dentro do jogo durante a transmissão. Abrir os Controlos da transmissão liberta-o. + Comando e toque + Aspeto + Biblioteca e navegação + Barra de estado + Sons e sessões + Mostrar relatório da sessão + Mostrar um resumo da qualidade após cada transmissão. + Ferramentas avançadas + Agradecimentos + Resolução + Proporção + Predefinição da transmissão + Recomendada + Personalizada + Baixa (poupar dados) + Média + Alta + FPS + Taxa de bits Mbps + Codec + Cor + Apenas H.264/H.265 + O AV1 utiliza cor de 8 bits. Escolha H.265 para 10 bits; o HDR está limitado a modos Android TV compatíveis. + O AV1 utiliza cor de 8 bits no OpenNOW. Mudou para 8 bits e desativou o HDR. Escolha H.265 ou H.264 para utilizar 10 bits. + HDR (Performance & Ultimate) + A transmissão HDR não está disponível em dispositivos Android portáteis. O SDR de 10 bits continua disponível com H.265. + O HDR no Android TV requer H.265 a 60 FPS ou menos e uma resolução até 3840 × 2160. + Região + Proxy da sessão + Encaminha a criação da sessão GFN e a consulta da fila através deste proxy. Deixe desativado para pedidos diretos. + URL do proxy + Copiar diagnóstico do codec + Diagnóstico do codec copiado + O teste do codec ainda não foi executado. + Ativar o proxy da sessão? + A criação da sessão GFN, a consulta da fila, o retomar, a paragem e as atualizações de anúncios da fila serão encaminhados através do proxy introduzido. + Um proxy incorreto ou bloqueado pode impedir o arranque, o progresso da fila, o retomar de uma sessão ativa ou a limpeza da sessão. + Utilize apenas um proxy de confiança. O operador poderá observar os horários dos pedidos, os anfitriões de destino e metadados confidenciais do tráfego da sessão. + Ativar proxy + Transmissão experimental + Pode causar falhas ao iniciar sessões. + L4S + Solicita o transporte da NVIDIA de baixa latência e poucas perdas quando suportado pelo servidor e pela rede. Deixe desativado se a rede ficar instável. + Pedido Cloud G-Sync / VRR + Pede à sessão na nuvem que utilize uma frequência de atualização variável quando suportada pelo dispositivo, ecrã, plano e sessão GFN. + Microfone + Envia o microfone Android predefinido para o jogo remoto. Pode silenciá-lo nos Controlos da transmissão. + A autorização do microfone não foi concedida. O OpenNOW manterá a transmissão do microfone desativada. + Utilizar cores do sistema + Destaque + Página inicial + Loja + Biblioteca + Desativar a procura de atualizações + Opções avançadas + Mostra opções experimentais de catálogo e ajuste. O separador de diagnóstico avançado permanece disponível. + Estilo expressivo dos cartões + Utiliza superfícies de cartões mais claras e cantos mais suaves. Desative para um estilo Material mais plano e discreto. + Fundo do catálogo + Mostra uma imagem de fundo atrás da Loja e da Biblioteca em ecrãs portáteis. + Imagem de fundo + Imagem personalizada + Fundo integrado + Abstrato colorido (predefinição) + OpenNOW original + Absolute Cinema + Escolher imagem + Utilizar predefinição + Espaçamento das margens do ecrã + Cartões de jogo compactos + Mostrar etiquetas das lojas + Tamanho dos cartões de jogo + Ocultar botões da transmissão + Botão do teclado no ecrã + Mostra um ícone de teclado compacto na barra de estado da transmissão. + Mostrar a barra de estado por predefinição + Posição das estatísticas sobrepostas + Ocultar seletor de servidor + Sons ao premir botões + Reproduz um breve som da interface ao navegar com o comando e premir controlos no ecrã. + Reproduzir música de introdução + A música de introdução começa + Sem som + A tocar + Reproduzir música quando a fila terminar + Silenciar música + Esticar a transmissão para preencher + Temporizador de sessão inteligente + Obrigado a todos os que ajudam a melhorar o OpenNOW para todos. + DarkevilPT + Apoio da comunidade + Doar + Ligação de doação copiada + OpenNOW + Azul Pixel + Rosa forte + Lima + Coral + Violeta + Transmissor nativo (experimental) + Interceta o descodificador de hardware para aplicar propriedades de baixa latência do fabricante. Pode ser instável. + O toque nativo Automático utiliza o modo de comando em transmissões de alta resolução ou FPS elevados para preservar o modo escolhido. Escolha Todos os jogos para dar prioridade ao toque nativo. + Minimizar + Ver + Jogar na TV + A iniciar a transmissão + Posição na fila: %1$d + A aguardar uma máquina + A ligar a transmissão + A retomar a sessão + A preparar a máquina + A iniciar a sessão + Estado da fila + %1$s está pronto para jogar! + A sua fila GFN terminou. Toque para voltar à aplicação. + A transmissão continua enquanto o ecrã está desligado + Não adquirido + Editora desconhecida + Retomar sessão na nuvem + Aplicação %1$s + Fila %1$d + A iniciar + Ainda não existe uma descrição disponível para este jogo. + Disposição do teclado + Idioma do jogo + Colar da área de transferência + Seguinte + Tentar novamente + Iniciar + Ignorar + Atualizar + Enviar + OK + Anular + Instalar + Gerir + Permitir + Ativo + Pronto + A verificar + Melhor rota disponível + À frente + Espera + Temporizador da sessão + Limpar cache + Repor tutorial + Repor definições + Repor e reiniciar + Mudar + Adicionar conta + Terminar sessão + Terminar sessão em todas as contas + Escolher fornecedor + Estatísticas de tempo de jogo + Armazenamento na nuvem + Adicionar armazenamento + Alterar localização do armazenamento + Nenhuma transmissão ativa + Voltar à biblioteca + Terminar sessão na nuvem + Passo %1$d de %2$d + Prima Concluído + Comando detetado + O comando no ecrã foi ocultado porque está ligado um comando físico. + Não mostrar novamente + A iniciar o emparelhamento do telemóvel… + Emparelhar com a aplicação OpenNOW do telemóvel + Primeiro, instale e abra o OpenNOW no seu telemóvel Android. Ligue o telemóvel e a TV à mesma rede Wi‑Fi e leia este código QR com a câmara do telemóvel. A ligação expira após cinco minutos. + Emparelhamento com TV + Emparelhar com uma TV + Mantenha este telemóvel e a TV na mesma rede Wi‑Fi. Leia aqui o código QR da TV ou procure a TV e introduza o código de 4 dígitos. + Ligado a %1$s. Os jogos mostram agora a ação Jogar na TV. + Ligado a %1$s + Iniciar sessão na TV + Esquecer TV + Leia um código QR ou procure uma TV na rede + Ler QR da TV + Não foi possível abrir o leitor de QR + Procurar TV + A procurar… + Código da TV + Introduza o código de 4 dígitos apresentado nesta TV. + Emparelhar + Contas e serviços + Perfis, subscrição, armazenamento e lojas de jogos + Jogos novos + Resultados + Faixa em destaque da Biblioteca + Visual do comando tátil + Cor do comando tátil + Letras dos botões + Mira com giroscópio + Não foi encontrada nenhuma TV OpenNOW nesta rede. + Abrir perfil da conta + Nome de utilizador + Nível + E-mail + Opções da conta + %1$s • %2$s + Indisponível + Opções de programador + Repor fluxos, inspecionar a execução e reconstruir o estado local + Para desenvolvimento e suporte + Estas ações apenas repõem o estado local do OpenNOW e mostram informação que já consta da exportação de diagnóstico. As destrutivas pedem confirmação. Pode voltar a ocultar esta página a partir do fim da lista. + Fluxos e avisos + Catálogo e lojas + Transmissão + Interface + Diagnóstico + Destrutivo + Repor + Limpar + Executar + Aplicar + Copiar + Repetir + Ocultar + Repetir o primeiro arranque + Configuração, guias, avisos, consentimento e estado de navegação de uma só vez + A configuração será executada novamente, todos os avisos únicos voltam a aparecer, o consentimento de análise é retirado até voltar a responder, e a ordenação da Loja e da Biblioteca é reposta. Contas, favoritos e definições de transmissão não são alterados. + Executar a configuração novamente + Mostra os ecrãs de primeiro arranque no próximo início + Mostrar novamente o guia de transmissão + Reaparece na próxima vez que uma transmissão iniciar + Mostrar novamente o aviso do comando + Reaparece na próxima vez que ligar um comando + Pedir novamente o consentimento de análise + Fica desativado até a pergunta ser respondida novamente + Repetir as migrações de atualização + Reaplica os valores únicos de apresentação e esquema de TV + Limpar a cache de jogos + Elimina os resultados em cache da Loja, Biblioteca e pesquisa + Recarregar o catálogo + Recarrega a Loja e a Biblioteca a partir do fornecedor agora + Repor o estado de navegação + Ordenação e filtros da Loja e da Biblioteca nos valores predefinidos + Esquecer as escolhas de lançador + Todas as seleções de loja memorizadas por jogo + Limpar favoritos + %1$d guardados + Todos os jogos marcados como favoritos serão removidos. Não é possível anular. + Limpar a prateleira de apps + %1$d apps instaladas afixadas + Aplicar a recomendação medida + Repor o esquema tátil + Tamanho da sobreposição, opacidade e todos os desvios de botões + Atualizar as filas de servidores + Volta a consultar a lista de zonas PrintedWaste e os pings + Repor a interface + Destaque, fundo, esquema de cartões e valores de animação + Copiar o registo de diagnóstico + Depurado, o mesmo texto que o relatório de erros anexa + Copiar o resumo de execução + A tabela acima, em texto + Procurar atualizações + Executa a verificação de atualizações imediatamente + Terminar sessão em todas as contas + %1$d guardadas + Todas as contas guardadas serão removidas deste dispositivo e o OpenNOW regressará ao ecrã de início de sessão. + Apagar dados da app e reiniciar + Devolve o OpenNOW a uma instalação nova + Contas, definições, jogos em cache e ficheiros locais serão eliminados, e o OpenNOW reiniciará como uma instalação nova. Não é possível anular. + Ocultar opções de programador + Toque dez vezes no número da compilação em Acerca de para as recuperar + Compilação + Variante + Dispositivo + Android + Perfil de esquema + Subscrição + Fornecedor + Perfil de transmissão + Descodificadores por hardware + Jogos da Loja / Biblioteca + Nenhum + Sessão terminada + Android TV + Portátil + Estado de primeiro arranque restaurado + A configuração será executada no próximo arranque + O guia de transmissão voltará a ser mostrado + O aviso do comando voltará a ser mostrado + O consentimento de análise será pedido novamente + As migrações de atualização serão repetidas + Estado de navegação reposto + Escolhas de lançador esquecidas + Favoritos limpos + Prateleira de apps limpa + Recomendação medida aplicada + Esquema tátil reposto + Interface reposta + Registo de diagnóstico copiado + Resumo de execução copiado + Opções de programador ocultadas + Mais %1$d toques para mostrar as opções de programador + As opções de programador estão agora nas Definições + As opções de programador já estão visíveis + Seguinte + Indisponível + Vibração + Vibração do comando quando disponível; caso contrário, a do dispositivo + Saída de vibração + Algumas consolas portáteis indicam um motor de vibração no comando integrado que não está ligado a nada. Force o motor do telemóvel se a vibração no jogo continuar silenciosa. + Automática + Comando + Telemóvel + Pontaria tátil + Bloquear joystick + Bloquear zona / pontaria tátil + Arraste em qualquer ponto da zona direita para pontaria com rato relativo. + ZONA DE PONTARIA + Comunidade do Discord + Obtenha ajuda e acompanhe relatórios de erros com a comunidade OpenNOW. + Apoio da comunidade e acompanhamento de relatórios de erros + Aderir + Convite do Discord copiado + Ordenar e filtrar + Ordenar e filtrar, %1$d ativos + Ordenar + Populares + Jogados recentemente + Filtros + Controlos + Controlos táteis móveis + O número de píxeis enviados pelo PC na nuvem. Resoluções mais altas parecem mais nítidas, mas exigem mais capacidade de descodificação, GPU e rede. + Ajusta a forma da transmissão ao ecrã. Uma proporção incorreta pode acrescentar barras pretas ou esticar a imagem; não torna o descodificador mais rápido. + «Recomendado» usa o ecrã, a memória, o número de processadores, o perfil Android e os descodificadores WebRTC por hardware verificados deste dispositivo. «Personalizado» mantém as suas escolhas manuais. + Recomendação detetada: %1$s + As imagens por segundo controlam a fluidez do movimento. Mais FPS dão menos tempo por imagem ao descodificador e podem causar quebras em hardware mais lento. + A taxa máxima de dados de vídeo. Uma taxa de bits mais alta pode melhorar o detalhe, mas só quando a ligação tem capacidade estável suficiente; não aumenta os FPS. + H.264 é o mais compatível. H.265 usa a largura de banda de forma mais eficiente e é preferível em resoluções altas quando existe um descodificador por hardware verificado. AV1 é de 8 bits aqui e é usado apenas em dispositivos com um caminho de hardware compatível. + 8 bits 4:2:0 é o mais leve e compatível. 10 bits melhora os gradientes, mas aumenta os requisitos de descodificação e largura de banda. O Android normaliza automaticamente as combinações não suportadas. + O HDR requer um ecrã Android TV compatível, H.265, vídeo de 10 bits e uma subscrição suportada. Acrescenta carga de processamento e não é recomendado para diagnosticar latência. + Adicionar as minhas apps + Mostra uma prateleira na Biblioteca onde pode adicionar, abrir e remover apps e jogos Android instalados. + Tornar este o seu lançador predefinido + Abre o seletor de lançadores do Android para que o OpenNOW possa ser o ecrã principal. Pode voltar a alterar isto nas definições do Android. + Escolher lançador + O OpenNOW é o predefinido + Gerir + As minhas apps + Adicionar app + Escolher uma app + A carregar apps instaladas… + Mostrar as minhas apps, %1$d instaladas + Ocultar as minhas apps, %1$d instaladas + Não foram encontradas outras apps que possam ser abertas. + Remover %1$s + Isto remove apenas o atalho da sua prateleira. A app continua instalada. + Remover + Em destaque + Cada aspeto é um comando diferente, não apenas uma cor diferente: o recorte dos botões, se o direcional é uma cruz única ou quatro teclas separadas, e o espaço em que o stick se move mudam com ele. + Recolore os aspetos construídos em torno de um destaque. Clássico, Contorno, Gelo e Alto contraste são monocromáticos por conceção. + «Desligado» deixa os botões em branco assim que o esquema for memória muscular. + Aspeto + Tamanho dos botões frontais + Tamanho do direcional + Tamanho dos gatilhos e botões superiores + Tamanho do menu e do clique de stick + Tamanho do stick esquerdo + Tamanho do stick direito + Tamanho da cabeça do stick + Incline o telemóvel para pontaria com rato relativo. + Este dispositivo não indica um giroscópio. + Sensibilidade do giroscópio + Zona morta do giroscópio + Suavização do giroscópio + Inverter giroscópio na horizontal + Inverter giroscópio na vertical + Pontaria por movimento + Faixa rotativa acima da grelha da Biblioteca em telemóveis na vertical. + Esta app já não está instalada ou não pode ser aberta. + Ordem da biblioteca + Jogados recentemente + Título A–Z + Contornos de seleção animados + Anima jogos selecionados, itens de menu, escolhas de servidor e opções de lançador. Desative para uma seleção mais discreta. + Efeitos Absolute Cinema + Usa anéis de foco animados em laranja e azul mantendo a cor de interface que escolheu. + Estou maluco + Solte o Absolute Cinema por toda a interface. As imagens, descrições, controlos e mais recebem o efeito ao passar por cima e ao focar. + Mostrar ícone de favorito nos cartões + Mostra um botão de favorito nos cartões de jogo para telemóvel, portátil e TV. + Ativado por predefinição. Preenche o ecrã em vez de deixar barras pretas, esticando a imagem apenas no eixo que não corresponde — nunca cortando-a. Desative para geometria exata. + Aplica um filtro de GPU adicional após a descodificação. Pode melhorar o detalhe percebido, mas acrescenta carga de composição em dispositivos mais lentos. + Controla a intensidade do filtro de nitidez de pós-processamento. Não altera a resolução da transmissão de origem. + Absolute Cinema + Switch + Começar + Seguinte + Voltar + Ignorar + Concluir + GeForce NOW nativo para Android + Torne-o seu + Tudo aqui é aplicado à medida que escolhe. + Destaque + Animações da interface + Brilhos, halos de foco e movimento do carrossel. Desativar também respeita a definição de animações do sistema. + Pré-visualização + Esquema + Cada um redesenha a pré-visualização + Títulos dos jogos sob a imagem + «Desligado» deixa a grelha apenas com a arte da capa. + Cartões quadrados + Corta a capa num quadrado para caberem mais jogos no ecrã. + Botão de favorito sobre a imagem + Guarda um jogo na sua Biblioteca sem o abrir. + Cantos arredondados + Margens de cartões e painéis mais suaves em toda a app. + Absolute Cinema + Molduras de energia animadas à volta do que estiver focado. Normalmente um tratamento para comando e TV. + Resposta + Ambas são acionadas no seu próximo toque + Vibração + Uma vibração curta ao selecionar algo e vibração do comando no jogo onde o dispositivo o suporte. + Sons da interface + Um som ao premir botões e navegar nos menus. Não afeta o áudio do jogo. + Fundo + Desligado + Predefinido + Nenhum + Fundo da app + Imagem de fundo + A sua imagem + Qualidade da transmissão + Medida a partir do ecrã, do chipset e dos descodificadores deste dispositivo. + A medir este dispositivo + Recomendada + Poupança de dados + 720p, 30 FPS, 12 Mbps + Melhor qualidade + Até %1$s a %2$d FPS no seu plano + Configurar eu próprio + Escolha abaixo a resolução, a taxa de imagens e a taxa de bits + Subscrição %1$s + O seu plano transmite até %1$s a %2$d FPS. As opções superiores aparecem com o nível que as desbloqueia. + Melhorar a sua subscrição GeForce NOW aumenta este limite — o OpenNOW não o restringe. + Durante o jogo + Escolha como a transmissão se comporta. + Pré-visualização + Rato tátil + Direto + Toque onde quer clicar + Painel tátil + Deslize para mover e depois toque + Desligado + Use um comando ou um rato físico + Tocar = mover + clicar + Deslize e depois toque + Comando / rato + Jogar + Linha de estado + FPS, ping, bateria e ligação num relance. + Posição + 60 FPS • 24 ms • Wi-Fi + Quando algo falha + Quebras, ecrãs pretos, comandos que não respondem. + O relatório de erros integrado + Abra-o a partir dos controlos durante a transmissão ou do relatório apresentado no fim de uma sessão. + Primeiro compara as suas definições com a sessão e assinala combinações problemáticas conhecidas, normalmente com uma solução. + Envia a sua descrição mais um diagnóstico depurado — definições, medições de descodificador e rede, modelo do dispositivo. Nenhum dado da conta. + Relatório de sessão após cada transmissão + Latência, ritmo de imagens e perda de pacotes no fim de uma sessão, com um atalho para o relatório de erros. + Partilhar diagnóstico anónimo + Ajuda a encontrar padrões em falhas e problemas de desempenho. Os dados sensíveis são removidos e nada é vendido. Com isto desligado, um relatório de falha pode não conter o suficiente para investigar. + Concluído + Pode alterar qualquer coisa nas Definições. + As suas escolhas + Qualidade da transmissão + Rato tátil + Linha de estado + Ligado + Desligado + Configuração + Executar a configuração novamente + Rever o aspeto, a qualidade da transmissão, os controlos de jogo, o estado e os relatórios de erros + Requer %1$s + O GeForce NOW indica %1$s apenas como %2$s, e esta conta está em %3$s. A sessão será muito provavelmente recusada ou reduzida para um perfil inferior. + O direito de acesso é decidido pelo GeForce NOW, não pelo OpenNOW, e o catálogo está por vezes desatualizado — por isso ainda pode tentar. + Tentar mesmo assim + Copiar erro + Desligar + Voltar + Iniciar sessão + Partilhar análises + Partilhe diagnósticos anónimos para nos ajudar a encontrar padrões em erros, falhas e problemas de desempenho. Os dados sensíveis são removidos e não vendemos os seus dados. + Se a partilha estiver desligada durante uma falha, poderemos não ter informação suficiente para investigar o seu relatório. Está desligada por predefinição e pode ser alterada nas definições de privacidade. + Manter desligado + Partilhar diagnóstico? + A verificar a compilação mais recente… + A verificar o Google Play… + A verificar esta sessão… + Verificações + O mesmo registo com data e hora disponível em Definições > Avançado > Registos de depuração é anexado automaticamente. Não são acrescentados outros ficheiros. + Os seus dados não são vendidos e são usados apenas para investigar e corrigir erros. + O registo automático remove nomes de conta, credenciais, IDs de sessão e endereços de rede antes do envio. O ID bruto do dispositivo não é enviado. + O que é recolhido? + O título e a descrição que escrever são enviados exatamente como os redigiu, por isso não inclua informação pessoal ou sensível. + Os responsáveis do PrintedWaste e do OpenNOW podem ver o texto do relatório, a versão/compilação da app, o modelo do dispositivo, a versão do Android, o fornecedor e a categoria de subscrição, o jogo atual, o estado e as definições da transmissão, um identificador de instalação pseudonimizado para prevenção de abusos e um registo de diagnóstico depurado. + Enviar este relatório e o diagnóstico depurado anexado para a API do PrintedWaste? + Enviar relatório de erro? + Consinto no envio deste relatório. + Compreendo o que será enviado e consinto no seu envio para a API do PrintedWaste. + Descreva o erro em inglês. Os diagnósticos da sessão são anexados. + O que aconteceu? + O que estava a fazer, o que correu mal e consegue reproduzi-lo? + É necessário inglês + Defina o OpenNOW ou o idioma do dispositivo para inglês antes de reportar. + Descreva o problema sem sair do seu jogo. + Compreendo que o OpenNOW encontrou uma causa provável. Enviar mesmo assim; posso perder o acesso futuro a reportar. + SUGESTÕES CORRESPONDENTES + Não estão a ser sugeridas correções irrelevantes para esta verificação. + Verificações em direto deste dispositivo e desta sessão + Antes de reportar + Repetir a verificação de versão + Rever e enviar + Enviar outro + Enviar mesmo assim + A enviar… + Relatório de erro enviado + Continua a acontecer após alguma sugestão correspondente? Continue e as provas medidas serão anexadas automaticamente. + Título do problema + A transmissão bloqueou após reconectar + Atualizar no Google Play + Enviar relatório + Enviar relatório de erro? + A enviar relatório… + Usar inglês no OpenNOW + A verificar as filas e a latência do PrintedWaste + Descrição + Filtros + Encaminhamento de fila do nível gratuito + Capturas de ecrã + Botão Voltar do comando + Detalhes + O dispositivo, o tipo de conta, o perfil de transmissão, o estado atual e o URL temporário foram copiados para a área de transferência. + Diagnóstico copiado + O OpenNOW removerá tokens, identificadores de conta, endereços de e-mail, IDs de sessão e endereços de rede antes do envio. + A ligação aleatória não está listada mas não está encriptada, e o serviço elimina os envios em 24 horas. + Criar ligação de diagnóstico temporária? + A remover valores sensíveis e a criar uma ligação temporária… + A preparar o diagnóstico + Não foi possível criar o código QR. Feche esta janela e tente novamente. + Leia este código QR com o seu telemóvel. A ligação depurada expira em 24 horas. + Ler a ligação de diagnóstico + Depurar e enviar + Nenhum navegador disponível + Não foi possível abrir a página da loja + Não foi possível iniciar a ligação à loja + Não foi possível desligar a loja + Token de acesso + Não foi possível exportar os registos + Registos exportados + CÓDIGO DE EMPARELHAMENTO + Cliente GeForce NOW nativo para Android + Cole um token de acesso da NVIDIA ou o JSON de resposta do token. O OpenNOW verifica o token de acesso antes de guardar a conta. + Iniciar sessão com token + Use apenas credenciais de uma conta que lhe pertença. + Use um token para iniciar sessão sem o navegador, ou exporte o diagnóstico antes de iniciar sessão. + Ferramentas de início de sessão + Usar início de sessão por código + Publicidade + Posição em direto + Fila + VOLTAR + Reporte-o + Encontrou um erro? + Perfil entregue + Esta foi uma sessão curta, por isso a pontuação pode variar mais do que o habitual. + Relatório de sessão + O que fazer a seguir + Porque é que o perfil mudou + Estas definições estão acima da recomendação detetada + Atividade em segundo plano + Otimizada (pode expirar em segundo plano) + Ilimitada (permitida em segundo plano) + A otimização de bateria do Android restringe a atividade da app em segundo plano, o que pode causar expiração de ligações ou suspender o progresso na fila da GFN quando a app está minimizada. + Os resultados em cache da loja, biblioteca e pesquisa serão removidos. A sua conta e definições ficam inalteradas. + Limpar a cache de jogos? + Ajuda de ligação + Exporta o estado de arranque, o estado da fila, as atualizações da transmissão, os eventos de recuperação, as definições, as capacidades de códec e as respostas JSON depuradas recentes do CloudMatch. + Programador + Não existe nenhum extra de armazenamento persistente ativo para esta conta. + Notas da versão + Contas, definições, jogos em cache, estado do tutorial e ficheiros locais da app serão removidos. O OpenNOW reiniciará como uma instalação nova. + «Repor tutorial» apenas faz o guia de transmissão voltar a aparecer. «Repor definições» é destrutivo: limpa os dados locais da app e reinicia o OpenNOW. + Repor definições e dados da app? + Selecione o fornecedor GeForce NOW a usar na nova conta. + Utilização do armazenamento + Ligações a lojas + Os valores sensíveis são removidos antes de ser criada uma ligação temporária não listada. Leia o código QR com o seu telemóvel para a partilhar. + Enviar registos e mostrar QR + Isto envia o resumo da alteração de perfil e a causa provável aos responsáveis do PrintedWaste e do OpenNOW para que possam investigar. + Enviar diagnóstico da transmissão? + Enviar diagnóstico + O OpenNOW não tem nenhuma transmissão local associada neste momento. + Perfil de transmissão alterado + Porque é que aconteceu + A enviar o relatório e o diagnóstico depurado… + As suas definições de transmissão guardadas não foram alteradas. + Sessão na nuvem já ativa + Terminar e iniciar uma nova + Escrever ou editar o texto da transmissão + A cache de jogos já estava vazia + Cache de jogos limpa + A limpar os dados da app e a reiniciar o OpenNOW + Sessão iniciada em segurança a partir do telemóvel + Loja desligada + O tutorial será mostrado na próxima transmissão + Arrastar + + + %1$d servidor + %1$d de servidores + %1$d servidores + + diff --git a/android/app/src/main/res/values-ro/strings.xml b/android/app/src/main/res/values-ro/strings.xml new file mode 100644 index 000000000..f97ab11f5 --- /dev/null +++ b/android/app/src/main/res/values-ro/strings.xml @@ -0,0 +1,837 @@ + + + OpenNOW + Se pornește OpenNOW + Conectare cu %1$s + Conectare pe alt dispozitiv cu %1$s + Folosește acest cod pentru conectare + %1$s + Se așteaptă conectarea + Codul expiră în %1$d:%2$02d + Magazin + Căutare + Bibliotecă + Setări + Caută jocuri + Caută în setări + General + Actualizări, confidențialitate și datele aplicației + Limbă + Limba aplicației + Valoarea sistemului + Engleză + Flux + Rezoluție, FPS, codec, HDR, proxy + Control + Microfon, mouse, tastatură, comenzi tactile, vibrații + Interfață + Aspect, bibliotecă, bară de stare și sunete + Cont + Conectare, stocare, magazine asociate + Avansat + Opțiuni avansate, experimente, diagnosticare și jurnale + Despre + Versiune, contribuții și asistență + Afișează titlurile jocurilor + Șterge căutarea + Căutare vocală + %1$d jocuri + Nu s-au încărcat jocuri + Niciun joc din bibliotecă nu corespunde + Șterge căutarea pentru a afișa toate jocurile din bibliotecă. + Șterge filtrele pentru a afișa toate jocurile din bibliotecă. + Șterge căutarea sau filtrele pentru a afișa toate jocurile din bibliotecă. + Niciun joc din magazin nu corespunde + Șterge căutarea pentru a afișa mai multe jocuri. + Șterge filtrele pentru a afișa mai multe jocuri. + Șterge căutarea sau filtrele pentru a afișa mai multe jocuri. + Revino în joc + În curând + Titluri noi pe GeForce NOW + Continuă jocul + În coadă + Favorite + Recomandări + Vezi tot + Joacă + Continuă + Reia + Salvează + Salvat + Adaugă la favorite + Elimină din favorite + Anulează + Pornit + Oprit + Vizibil + Ascuns + Înapoi + Deschide + Resetează + Închide + Comenzi flux + Ieșire + Gata + Deschide trimiterea de taste + Afișaj + Control + Asistență + Controler + Aspect tactil + Audio + Dezactivat + Bară de stare + %1$s · %2$d elemente + Claritate flux + Nivel de claritate + Întinde pentru potrivire + În direct + %1$d Mbps activi în această sesiune + Setări › Flux se aplică doar sesiunii următoare + Microfon + Este necesară permisiunea + Meniu Steam + Trimite tasta Acasă către PC-ul de la distanță + Esc + Enter + + Mouse cu controlerul + Stick dreapta · A clic · B clic dreapta + Mouse cu degetul + Clic direct + Controler tactil + S-au detectat comenzi tactile integrate + Acest joc acceptă comenzi tactile integrate. Dacă preferi, poți activa mai jos controlerul tactil OpenNOW. + Acest joc acceptă comenzi tactile integrate. Controlerul tactil OpenNOW este activat pentru această sesiune. + Comenzi integrate active + Joystickuri + Fix + Dinamic + Vibrația telefonului ca alternativă + Mod mouse cu controlerul + Stick S mișcă · Stick D derulează · A clic · B clic dreapta + Mod mouse + Configurează emularea mouse-ului + Comenzi tactile + Aspect controler, joystickuri și vibrații + Raportează o problemă + Rulează verificări și trimite diagnostice anonimizate + Mod de editare prin tragere + Resetează aspectul tactil + Resetează pozițiile la valorile implicite + Scală aspect + Dimensiune butoane + Opacitate + Spațiere la margine + Spațiere jos + Poziție stânga + Poziție dreapta + Joystickuri + Reglează comenzile analogice tactile + Poziționare dinamică + Pornește centrat sub degetul mare + Folosește centrul fix salvat + Dimensiune joystick + Zonă moartă + Modul dinamic păstrează zona salvată a joystickului, dar tratează primul punct atins de degetul mare ca neutru. Astfel evită mișcarea bruscă atunci când nu nimerești exact centrul. + Bară de stare + Alege aspectul și informațiile + Aspect + Poziție + Elemente + FPS + Ping + Rată de biți + Baterie + Conexiune + Rezoluție + Codec + Server + Dec. / Jit. + Pierderi + Tastatură + %1$d/100 + %1$s + Nu mai afișa rapoartele de sesiune + Conexiune + Nemăsurat + Latență + Viteză flux + Pierdere pachete + Variație + Frecvență cadre + Decodare + medie %1$d ms + vârf %1$d ms + vârf %1$s + Stabil + Poate afecta claritatea + Variație de temporizare + FPS mediu / țintă + Per cadru video + Control sesiune + Ieși din flux? + Sigur vrei să ieși din %1$s? + Sesiunea curentă de joc în cloud va fi închisă. + Continuă jocul + Ieși din flux + Raportare erori + Raportează o eroare + Raportează o eroare a fluxului + Trimite problema și diagnostice anonimizate + Afișează descrierea + Ascunde descrierea + Ping %1$s + Dec. %1$s ms + Var. %1$s ms + Pierderi %1$s%% + %1$d cadre pe secundă + Ping %1$d milisecunde + Timp de decodare %1$s milisecunde pe cadru + Variație %1$s milisecunde + Pierdere pachete %1$s procente + bună + acceptabilă + slabă + Închide + Joacă pe %1$s + Șterge filtrele + Înapoi sus + Automat + În curând + Alege platforma + Platforme + Implicit + Selectat + Platformă disponibilă + Nu mai întreba — setează acest magazin ca implicit + Se continuă cu magazinul implicit: %1$s + Sfat: ține apăsat Joacă pentru a alege alt magazin mai târziu. + Ține apăsat Joacă pentru a alege un magazin + Flux + Interfață + Calitate + Video + Conexiune + Audio și tastatură + Control indicator + Blocare mouse + Păstrează un mouse extern capturat în joc în timpul redării. Deschiderea comenzilor fluxului îl eliberează. + Controler și atingere + Aspect + Bibliotecă și navigare + Bară de stare + Sunete și sesiuni + Afișează raportul sesiunii + Afișează un rezumat al calității după fiecare flux. + Instrumente avansate + Mulțumiri + Rezoluție + Raport de aspect + Presetare flux + Recomandat + Personalizat + Redus (economisire date) + Mediu + Ridicat + FPS + Rată de biți Mbps + Codec + Culoare + Doar H.264/H.265 + AV1 folosește culoare pe 8 biți. Alege H.265 pentru 10 biți; HDR este limitat la modurile Android TV compatibile. + AV1 folosește culoare pe 8 biți în OpenNOW. S-a trecut la 8 biți și HDR a fost dezactivat. Alege H.265 sau H.264 pentru 10 biți. + HDR (Performance & Ultimate) + Redarea HDR nu este disponibilă pe dispozitivele Android portabile. SDR pe 10 biți rămâne disponibil cu H.265. + HDR pe Android TV necesită H.265 la maximum 60 FPS și o rezoluție de până la 3840 × 2160. + Regiune + Proxy sesiune + Direcționează crearea sesiunii GFN și verificarea cozii prin acest proxy. Lasă oprit pentru cereri directe. + URL proxy + Copiază diagnosticul codec + Diagnosticul codec a fost copiat + Testul codec nu a fost încă rulat. + Activezi proxy-ul sesiunii? + Crearea sesiunii GFN, verificarea cozii, reluarea, oprirea și actualizările reclamelor din coadă vor fi direcționate prin proxy-ul introdus. + Un proxy greșit sau blocat poate împiedica pornirea, progresul cozii, reluarea unei sesiuni active sau închiderea sesiunii. + Folosește doar un proxy de încredere. Operatorul poate vedea momentele cererilor, gazdele destinație și metadate sensibile despre traficul sesiunii. + Activează proxy-ul + Redare experimentală + Poate provoca erori la pornirea sesiunii. + L4S + Solicită ruta de transport NVIDIA cu latență și pierderi reduse când serverul și rețeaua o acceptă. Lasă oprit dacă rețeaua devine instabilă. + Solicitare Cloud G-Sync / VRR + Solicită sesiunii cloud o rată de reîmprospătare variabilă când dispozitivul, ecranul, abonamentul și sesiunea GFN o acceptă. + Microfon + Trimite microfonul Android implicit către jocul de la distanță. Îl poți dezactiva din comenzile fluxului. + Permisiunea pentru microfon nu a fost acordată. OpenNOW va menține redarea microfonului dezactivată. + Folosește culorile sistemului + Accent + Pagină de pornire + Magazin + Bibliotecă + Dezactivează verificarea actualizărilor + Opțiuni avansate + Afișează opțiuni experimentale pentru catalog și reglaje. Fila de diagnosticare avansată rămâne disponibilă. + Stil expresiv al cardurilor + Folosește suprafețe mai luminoase și colțuri mai moi. Dezactivează pentru un stil Material mai plat și discret. + Fundal catalog + Afișează o imagine de fundal în spatele Magazinului și Bibliotecii pe ecranele portabile. + Imagine de fundal + Imagine personalizată + Fundal integrat + Abstract colorat (implicit) + OpenNOW original + Absolute Cinema + Alege imaginea + Folosește valoarea implicită + Spațiere la marginea ecranului + Carduri de joc compacte + Afișează etichetele magazinelor + Dimensiunea cardurilor de joc + Ascunde butoanele fluxului + Buton tastatură pe ecran + Afișează o pictogramă compactă de tastatură în bara de stare a fluxului. + Afișează implicit bara de stare + Poziția statisticilor suprapuse + Ascunde selectorul de server + Sunete la apăsarea butoanelor + Redă un sunet scurt la navigarea cu controlerul și apăsarea comenzilor de pe ecran. + Redă muzica de introducere + Muzica de introducere începe + Dezactivată + Redată + Redă muzică la terminarea cozii + Dezactivează muzica + Întinde fluxul pentru a umple ecranul + Cronometru inteligent de sesiune + Mulțumim tuturor celor care ajută la îmbunătățirea OpenNOW. + DarkevilPT + Asistență comunitară + Donează + Linkul pentru donații a fost copiat + OpenNOW + Albastru Pixel + Roz intens + Verde lime + Coral + Violet + Streamer nativ (experimental) + Interceptează decodorul hardware pentru a aplica proprietăți de latență redusă ale producătorului. Poate fi instabil. + Atingerea nativă Automată folosește modul gamepad pentru fluxuri de rezoluție sau FPS ridicate, păstrând modul ales. Alege Fiecare joc pentru a acorda prioritate atingerii native. + Minimizează + Vezi + Joacă pe televizor + Se pornește fluxul + Poziția în coadă: %1$d + Se așteaptă un sistem de joc + Se conectează fluxul + Se reia sesiunea + Se pregătește sistemul de joc + Se pornește sesiunea + Starea cozii + %1$s este gata de joc! + Așteptarea în coada GFN s-a încheiat. Atinge pentru a reveni la aplicație. + Redarea continuă când ecranul este oprit + Nu este deținut + Editor necunoscut + Reia sesiunea cloud + Aplicația %1$s + Coada %1$d + Se pornește + Încă nu este disponibilă o descriere pentru acest joc. + Aspect tastatură + Limba jocului + Lipire din clipboard + Înainte + Încearcă din nou + Lansează + Omite + Reîmprospătează + Trimite + OK + Anulează + Instalează + Gestionează + Permite + Activ + Gata + Se verifică + Cea mai bună rută disponibilă + Înaintea ta + Așteptare + Cronometru sesiune + Șterge memoria cache + Resetează tutorialul + Resetează setările + Resetează și relansează + Schimbă + Adaugă cont + Deconectează-te + Deconectează toate conturile + Alege furnizorul + Statistici timp de joc + Stocare în cloud + Adaugă spațiu de stocare + Schimbă locația stocării + Niciun stream activ + Înapoi la bibliotecă + Încheie sesiunea cloud + Pasul %1$d din %2$d + Apasă Gata + Controler detectat + Controlerul de pe ecran a fost ascuns deoarece este conectat un controler fizic. + Nu mai afișa + Se pornește asocierea telefonului… + Asociază cu aplicația OpenNOW de pe telefon + Mai întâi instalează și deschide OpenNOW pe telefonul Android. Conectează telefonul și televizorul la aceeași rețea Wi‑Fi, apoi scanează acest cod QR cu camera telefonului. Linkul expiră după cinci minute. + Asociere TV + Asociază cu un televizor + Ține telefonul și televizorul în aceeași rețea Wi‑Fi. Scanează aici codul QR al televizorului sau găsește televizorul și introdu codul său de 4 cifre. + Conectat la %1$s. Jocurile afișează acum acțiunea Joacă pe TV. + Conectat la %1$s + Autentifică TV-ul + Uită televizorul + Scanează un cod QR sau găsește un televizor în rețea + Scanează QR-ul TV + Scanerul QR nu a putut fi deschis + Găsește TV + Se caută… + Cod TV + Introdu codul de 4 cifre afișat pe acest televizor. + Asociază + Conturi și servicii + Profiluri, abonament, stocare și magazine de jocuri + Jocuri noi + Rezultate + Banner recomandat în Bibliotecă + Aspectul controlerului tactil + Culoarea controlerului tactil + Literele butoanelor + Țintire cu giroscop + Nu a fost găsit niciun televizor OpenNOW în această rețea. + Deschide profilul contului + Nume de utilizator + Nivel + E-mail + Opțiuni cont + %1$s • %2$s + Indisponibil + Opțiuni pentru dezvoltatori + Resetează fluxuri, inspectează execuția și reconstruiește starea locală + Pentru dezvoltare și asistență + Aceste acțiuni resetează doar starea locală a OpenNOW și afișează informații care există deja în exportul de diagnostic. Cele distructive cer confirmare. Poți ascunde din nou această pagină din josul listei. + Fluxuri și solicitări + Catalog și magazine + Transmisiune + Interfață + Diagnostic + Distructiv + Resetează + Golește + Rulează + Aplică + Copiază + Reia + Ascunde + Reia prima pornire + Configurare, ghiduri, solicitări, consimțământ și stare de navigare, toate odată + Configurarea va rula din nou, toate solicitările unice reapar, consimțământul pentru analiză este retras până răspunzi din nou, iar ordinea din Magazin și Bibliotecă se resetează. Conturile, favoritele și setările de transmisiune rămân neatinse. + Rulează configurarea din nou + Afișează ecranele de primă pornire la următoarea lansare + Afișează din nou ghidul de transmisiune + Reapare la următoarea pornire a unei transmisiuni + Afișează din nou solicitarea pentru controler + Reapare la următoarea conectare a unui controler + Cere din nou consimțământul pentru analiză + Rămâne dezactivat până când întrebarea primește un nou răspuns + Reia migrările de actualizare + Reaplică valorile unice de prezentare și de aspect pentru TV + Golește memoria cache a jocurilor + Elimină rezultatele memorate din Magazin, Bibliotecă și căutări + Reîncarcă catalogul + Reîncarcă acum Magazinul și Biblioteca de la furnizor + Resetează starea de navigare + Ordinea și filtrele din Magazin și Bibliotecă revin la valorile implicite + Uită alegerile de lansator + Toate selecțiile de magazin memorate pentru fiecare joc + Golește favoritele + %1$d salvate + Toate jocurile marcate ca favorite vor fi eliminate. Această acțiune nu poate fi anulată. + Golește raftul de aplicații + %1$d aplicații instalate fixate + Aplică recomandarea măsurată + Resetează aspectul tactil + Dimensiunea suprapunerii, opacitatea și toate decalajele butoanelor + Reîmprospătează cozile serverelor + Interoghează din nou lista de zone PrintedWaste și ping-urile + Resetează interfața + Accent, fundal, aspectul cardurilor și valorile de animație + Copiază jurnalul de diagnostic + Curățat, același text pe care îl atașează raportul de eroare + Copiază rezumatul de execuție + Tabelul de mai sus, ca text + Caută actualizări + Rulează imediat verificarea actualizărilor + Deconectează-te de la toate conturile + %1$d salvate + Toate conturile salvate vor fi eliminate de pe acest dispozitiv, iar OpenNOW va reveni la ecranul de autentificare. + Șterge datele aplicației și repornește + Readuce OpenNOW la o instalare nouă + Conturile, setările, jocurile memorate și fișierele locale vor fi șterse, iar OpenNOW va reporni ca o instalare nouă. Această acțiune nu poate fi anulată. + Ascunde opțiunile pentru dezvoltatori + Atinge de zece ori numărul versiunii din Despre pentru a le readuce + Versiune + Variantă + Dispozitiv + Android + Profil de aspect + Abonament + Furnizor + Profil de transmisiune + Decodoare hardware + Jocuri din Magazin / Bibliotecă + Niciunul + Deconectat + Android TV + Portabil + Starea primei porniri a fost restaurată + Configurarea va rula la următoarea pornire + Ghidul de transmisiune va fi afișat din nou + Solicitarea pentru controler va fi afișată din nou + Consimțământul pentru analiză va fi cerut din nou + Migrările de actualizare vor fi reluate + Starea de navigare a fost resetată + Alegerile de lansator au fost uitate + Favoritele au fost golite + Raftul de aplicații a fost golit + Recomandarea măsurată a fost aplicată + Aspectul tactil a fost resetat + Interfața a fost resetată + Jurnalul de diagnostic a fost copiat + Rezumatul de execuție a fost copiat + Opțiunile pentru dezvoltatori au fost ascunse + Încă %1$d atingeri pentru a afișa opțiunile pentru dezvoltatori + Opțiunile pentru dezvoltatori sunt acum în Setări + Opțiunile pentru dezvoltatori sunt deja afișate + Înainte + Indisponibil + Vibrație + Vibrația controlerului când este disponibilă; altfel, cea a dispozitivului + Ieșire vibrație + Unele console portabile raportează un motor de vibrație pe controlerul integrat care nu este conectat la nimic. Forțează motorul telefonului dacă vibrația din joc rămâne tăcută. + Automat + Controler + Telefon + Ochire tactilă + Blochează joystick-ul + Blochează zona / ochire tactilă + Trage oriunde în zona din dreapta pentru ochire cu mouse relativ. + ZONĂ DE OCHIRE + Comunitatea Discord + Primește ajutor și urmărește rapoartele de eroare împreună cu comunitatea OpenNOW. + Asistență din partea comunității și urmărirea rapoartelor de eroare + Alătură-te + Invitația Discord a fost copiată + Sortează și filtrează + Sortează și filtrează, %1$d active + Sortare + Populare + Jucate recent + Filtre + Comenzi + Comenzi tactile mobile + Numărul de pixeli trimiși de PC-ul din cloud. Rezoluțiile mai mari par mai clare, dar necesită mai multă capacitate de decodare, GPU și rețea. + Potrivește forma transmisiunii cu ecranul. Un raport nepotrivit poate adăuga bare negre sau întindere; nu face decodorul mai rapid. + „Recomandat” folosește ecranul, memoria, numărul de procesoare, profilul Android și decodoarele hardware WebRTC verificate ale acestui dispozitiv. „Personalizat” păstrează alegerile tale manuale. + Recomandare detectată: %1$s + Cadrele pe secundă controlează fluiditatea mișcării. Mai multe FPS lasă decodorului mai puțin timp pentru fiecare cadru și pot cauza sacadări pe hardware mai lent. + Rata maximă de date video. O rată de biți mai mare poate îmbunătăți detaliul, dar doar când conexiunea are suficientă capacitate stabilă; nu crește FPS-ul. + H.264 este cel mai compatibil. H.265 folosește lățimea de bandă mai eficient și este preferabil la rezoluție înaltă când există un decodor hardware verificat. AV1 este pe 8 biți aici și se folosește doar pe dispozitive cu o cale hardware compatibilă. + 8 biți 4:2:0 este cel mai ușor și mai compatibil. 10 biți îmbunătățește degradeurile, dar crește cerințele de decodare și lățime de bandă. Android normalizează automat combinațiile neacceptate. + HDR necesită un ecran Android TV compatibil, H.265, video pe 10 biți și un abonament acceptat. Adaugă sarcină de procesare și nu este recomandat pentru depanarea întârzierilor. + Adaugă propriile aplicații + Afișează un raft în Bibliotecă unde poți adăuga, lansa și elimina aplicații și jocuri Android instalate. + Setează ca lansator implicit + Deschide selectorul de lansatoare din Android pentru ca OpenNOW să poată deveni ecranul principal. Poți schimba acest lucru din nou în setările Android. + Alege lansatorul + OpenNOW este implicit + Gestionează + Propriile aplicații + Adaugă aplicație + Alege o aplicație + Se încarcă aplicațiile instalate… + Arată aplicațiile mele, %1$d instalate + Ascunde aplicațiile mele, %1$d instalate + Nu au fost găsite alte aplicații care pot fi lansate. + Elimină %1$s + Aceasta elimină doar comanda rapidă din raftul tău. Aplicația rămâne instalată. + Elimină + Recomandat + Fiecare aspect este un controler diferit, nu doar o culoare diferită: forma butoanelor, dacă crucea direcțională este una singură sau patru taste separate și spațiul în care se mișcă stick-ul se schimbă odată cu el. + Recolorează aspectele construite în jurul unui accent. Clasic, Contur, Îngheț și Contrast ridicat sunt monocrome prin concepție. + „Dezactivat” lasă butoanele goale odată ce aspectul a intrat în memoria musculară. + Aspect + Dimensiunea butoanelor frontale + Dimensiunea crucii direcționale + Dimensiunea trăgacelor și a butoanelor laterale + Dimensiunea meniului și a clicului de stick + Dimensiunea stick-ului stâng + Dimensiunea stick-ului drept + Dimensiunea capului stick-ului + Înclină telefonul pentru ochire cu mouse relativ. + Acest dispozitiv nu raportează un giroscop. + Sensibilitatea giroscopului + Zona moartă a giroscopului + Netezirea giroscopului + Inversează giroscopul pe orizontală + Inversează giroscopul pe verticală + Ochire prin mișcare + Banner rotativ deasupra grilei Bibliotecii pe telefoane în modul portret. + Această aplicație nu mai este instalată sau nu poate fi deschisă. + Ordinea bibliotecii + Jucate recent + Titlu A–Z + Contururi animate la selecție + Animează jocurile selectate, elementele de meniu, alegerile de server și opțiunile de lansator. Dezactivează pentru o selecție mai discretă. + Efecte Absolute Cinema + Folosește inele de focalizare animate portocalii și albastre, păstrând culoarea de interfață aleasă de tine. + Sunt nebun + Lasă Absolute Cinema liber pe toată interfața. Grafica, descrierile, comenzile și altele primesc efectul la trecerea cu cursorul și la focalizare. + Afișează pictograma de favorit pe carduri + Afișează un buton de favorit pe cardurile de joc pentru mobil, portabil și TV. + Activat implicit. Umple ecranul în loc să lase bare negre, întinzând imaginea doar pe axa nepotrivită — niciodată prin decupare. Dezactivează pentru geometrie exactă. + Aplică un filtru GPU suplimentar după decodare. Poate îmbunătăți detaliul perceput, dar adaugă sarcină de randare pe dispozitivele mai lente. + Controlează intensitatea filtrului de claritate din post-procesare. Nu modifică rezoluția transmisiunii sursă. + Absolute Cinema + Switch + Începe + Înainte + Înapoi + Omite + Finalizează + GeForce NOW nativ pentru Android + Fă-l al tău + Totul de aici se aplică pe măsură ce alegi. + Accent + Animații ale interfeței + Sclipiri, străluciri de focalizare și mișcarea caruselului. Dezactivarea respectă și setarea de animație a sistemului. + Previzualizare + Aspect + Fiecare redesenează previzualizarea + Titluri de joc sub grafică + „Dezactivat” lasă grila doar cu grafica de copertă. + Carduri pătrate + Decupează coperta în pătrat ca să încapă mai multe jocuri pe ecran. + Buton de favorit pe grafică + Salvează un joc în Biblioteca ta fără să-l deschizi. + Colțuri rotunjite + Margini mai fine ale cardurilor și panourilor în toată aplicația. + Absolute Cinema + Rame de energie animate în jurul elementului focalizat. De obicei un tratament pentru controler și TV. + Feedback + Ambele se declanșează la următoarea atingere + Vibrații + O vibrație scurtă când selectezi ceva și vibrația controlerului în joc, acolo unde dispozitivul o acceptă. + Sunete ale interfeței + Un ton la apăsarea butoanelor și la navigarea prin meniuri. Nu afectează sunetul jocului. + Fundal + Dezactivat + Implicit + Niciunul + Fundalul aplicației + Imagine de fundal + Imaginea ta + Calitatea transmisiunii + Măsurată pe baza ecranului, a chipsetului și a decodoarelor acestui dispozitiv. + Se măsoară acest dispozitiv + Recomandată + Economie de date + 720p, 30 FPS, 12 Mbps + Cea mai bună calitate + Până la %1$s la %2$d FPS cu planul tău + Setez eu + Alege mai jos rezoluția, rata de cadre și rata de biți + Abonament %1$s + Planul tău transmite până la %1$s la %2$d FPS. Opțiunile superioare sunt listate cu nivelul care le deblochează. + Trecerea la un abonament GeForce NOW superior ridică această limită — OpenNOW nu o restricționează. + În timpul jocului + Alege cum se simte transmisiunea. + Previzualizare + Mouse tactil + Direct + Atinge acolo unde vrei să dai clic + Trackpad + Glisează pentru a muta, apoi atinge + Dezactivat + Folosește un controler sau un mouse fizic + Atingere = mutare + clic + Glisează, apoi atinge + Controler / mouse + Joacă + Linie de stare + FPS, ping, baterie și conexiune dintr-o privire. + Poziție + 60 FPS • 24 ms • Wi-Fi + Când ceva se strică + Sacadări, ecrane negre, controlere moarte. + Raportorul de erori integrat + Deschide-l din comenzile în timpul transmisiunii sau din raportul oferit la finalul unei sesiuni. + Verifică mai întâi setările tale față de sesiune și semnalează combinațiile problematice cunoscute, de obicei cu o soluție. + Trimite descrierea ta plus diagnostice curățate — setări, măsurători de decodor și rețea, modelul dispozitivului. Fără detalii de cont. + Raport de sesiune după fiecare transmisiune + Latență, ritmul cadrelor și pierderea de pachete la finalul unei sesiuni, cu o scurtătură către raportorul de erori. + Partajează diagnostice anonime + Ajută la găsirea tiparelor între blocări și probleme de performanță. Datele sensibile sunt eliminate și nimic nu este vândut. Cu aceasta dezactivată, un raport de blocare poate să nu conțină destul pentru investigare. + Gata + Poți schimba orice în Setări. + Alegerile tale + Calitatea transmisiunii + Mouse tactil + Linie de stare + Activat + Dezactivat + Configurare + Rulează configurarea din nou + Revezi aspectul, calitatea transmisiunii, comenzile de joc, starea și raportarea erorilor + Necesită %1$s + GeForce NOW listează %1$s doar ca %2$s, iar acest cont este pe %3$s. Sesiunea va fi cel mai probabil refuzată sau coborâtă la un profil inferior. + Dreptul de acces este stabilit de GeForce NOW, nu de OpenNOW, iar catalogul este uneori depășit — așa că tot poți încerca. + Încearcă oricum + Copiază eroarea + Deconectează + Înapoi + Autentificare + Partajează analizele + Partajează diagnostice anonime ca să ne ajuți să găsim tipare în erori, blocări și probleme de performanță. Datele sensibile sunt eliminate și nu îți vindem datele. + Dacă partajarea este dezactivată în timpul unei blocări, s-ar putea să nu avem destule informații pentru a investiga raportul tău. Este dezactivată implicit și poate fi schimbată în setările de confidențialitate. + Lasă dezactivat + Partajezi diagnosticele? + Se verifică cea mai recentă versiune… + Se verifică Google Play… + Se verifică această sesiune… + Verificări + Același jurnal cu marcaj de timp disponibil în Setări > Avansat > Jurnale de depanare este atașat automat. Nu se adaugă alte fișiere. + Datele tale nu sunt vândute și sunt folosite doar pentru a investiga și a remedia erori. + Jurnalul automat elimină numele de cont, credențialele, ID-urile de sesiune și adresele de rețea înainte de încărcare. ID-ul brut al dispozitivului nu este trimis. + Ce se colectează? + Titlul și descrierea pe care le scrii sunt trimise exact așa cum le redactezi, deci nu include informații personale sau sensibile. + Întreținătorii PrintedWaste și OpenNOW pot vedea textul raportului, versiunea aplicației, modelul dispozitivului, versiunea de Android, furnizorul și categoria de abonament, jocul curent, starea și setările transmisiunii, un identificator de instalare pseudonim pentru prevenirea abuzurilor și un jurnal de diagnostic curățat. + Trimiți acest raport și diagnosticele curățate atașate către API-ul PrintedWaste? + Trimiți raportul de eroare? + Sunt de acord să trimit acest raport. + Înțeleg ce va fi încărcat și sunt de acord să fie trimis către API-ul PrintedWaste. + Descrie eroarea în engleză. Diagnosticele sesiunii sunt atașate. + Ce s-a întâmplat? + Ce făceai, ce a mers prost și poți reproduce problema? + Este necesară limba engleză + Setează OpenNOW sau limba dispozitivului pe engleză înainte de a raporta. + Descrie problema fără să părăsești jocul. + Înțeleg că OpenNOW a găsit o cauză probabilă. Trimite oricum; s-ar putea să pierd accesul viitor la raportare. + SUGESTII POTRIVITE + Nu sunt sugerate remedii irelevante pentru această verificare. + Verificări în timp real de pe acest dispozitiv și din această sesiune + Înainte să raportezi + Reîncearcă verificarea versiunii + Verifică și trimite + Trimite altul + Trimite oricum + Se trimite… + Raportul de eroare a fost trimis + Se întâmplă în continuare după o sugestie potrivită? Continuă și dovezile măsurate vor fi atașate automat. + Titlul problemei + Transmisiunea a înghețat după reconectare + Actualizează în Google Play + Încarcă raportul + Încarci raportul de eroare? + Se încarcă raportul… + Folosește engleza pentru OpenNOW + Se verifică cozile și latența PrintedWaste + Descriere + Filtre + Rutarea cozii pentru nivelul gratuit + Capturi de ecran + Butonul Înapoi al telecomenzii + Detalii + Dispozitivul, tipul de cont, profilul de transmisiune, starea curentă și adresa temporară au fost copiate în clipboard. + Diagnostice copiate + OpenNOW va elimina tokenurile, identificatorii de cont, adresele de e-mail, ID-urile de sesiune și adresele de rețea înainte de încărcare. + Linkul aleatoriu nu este listat, dar nu este criptat, iar serviciul șterge încărcările în 24 de ore. + Creezi un link temporar de diagnostic? + Se elimină valorile sensibile și se creează un link temporar… + Se pregătesc diagnosticele + Codul QR nu a putut fi creat. Închide această fereastră și încearcă din nou. + Scanează acest cod QR cu telefonul. Linkul curățat expiră în 24 de ore. + Scanează linkul de diagnostic + Curăță și încarcă + Niciun browser disponibil + Pagina magazinului nu a putut fi deschisă + Conectarea la magazin nu a putut fi pornită + Magazinul nu a putut fi deconectat + Token de acces + Jurnalele nu au putut fi exportate + Jurnale exportate + COD DE ASOCIERE + Client GeForce NOW nativ pentru Android + Lipește un token de acces NVIDIA sau JSON-ul de răspuns al tokenului. OpenNOW verifică tokenul de acces înainte de a salva contul. + Autentificare cu token + Folosește doar credențiale pentru un cont care îți aparține. + Folosește un token pentru autentificare fără browser sau exportă diagnosticele înainte de autentificare. + Instrumente de autentificare + Folosește autentificarea cu cod + Publicitate + Poziție în timp real + Coadă + ÎNAPOI + Raportează + Ai întâlnit o eroare? + Profil livrat + A fost o sesiune scurtă, așa că scorul poate varia mai mult decât de obicei. + Raport de sesiune + Ce poți face în continuare + De ce s-a schimbat profilul + Aceste setări depășesc recomandarea detectată + Activitate în fundal + Optimizată (poate expira în fundal) + Nelimitată (permisă în fundal) + Optimizarea bateriei din Android restricționează activitatea aplicației în fundal, ceea ce poate cauza expirarea conexiunilor sau poate opri progresul în coada GFN când aplicația este minimizată. + Rezultatele memorate din magazin, bibliotecă și căutări vor fi eliminate. Contul și setările tale rămân neschimbate. + Golești memoria cache a jocurilor? + Ajutor pentru conexiune + Exportă starea de lansare, starea cozii, actualizările transmisiunii, evenimentele de recuperare, setările, capabilitățile codecurilor și răspunsurile JSON CloudMatch curățate recente. + Dezvoltator + Pentru acest cont nu este activ niciun supliment de stocare permanentă. + Note de versiune + Conturile, setările, jocurile memorate, starea tutorialului și fișierele locale ale aplicației vor fi eliminate. OpenNOW va reporni ca o instalare nouă. + „Resetează tutorialul” face doar ca ghidul de transmisiune să reapară. „Resetează setările” este distructiv: șterge datele locale ale aplicației și repornește OpenNOW. + Resetezi setările și datele aplicației? + Selectează furnizorul GeForce NOW pentru contul nou. + Utilizarea stocării + Conexiuni cu magazine + Valorile sensibile sunt eliminate înainte de crearea unui link temporar nelistat. Scanează codul QR cu telefonul pentru a-l partaja. + Încarcă jurnalele și afișează QR + Aceasta trimite rezumatul schimbării de profil și cauza probabilă către întreținătorii PrintedWaste și OpenNOW pentru a putea investiga. + Trimiți diagnosticele transmisiunii? + Trimite diagnosticele + OpenNOW nu are nicio transmisiune locală atașată în acest moment. + Profilul transmisiunii s-a schimbat + De ce s-a întâmplat + Se trimit raportul și diagnosticele curățate… + Setările tale salvate de transmisiune nu au fost modificate. + Sesiune în cloud deja activă + Încheie și pornește una nouă + Scrie sau editează textul transmisiunii + Memoria cache a jocurilor era deja goală + Memoria cache a jocurilor a fost golită + Se șterg datele aplicației și se repornește OpenNOW + Autentificare securizată de pe telefon + Magazin deconectat + Tutorialul va fi afișat la următoarea transmisiune + Trage + + + %1$d server + %1$d servere + %1$d de servere + + diff --git a/android/app/src/main/res/values-ru/strings.xml b/android/app/src/main/res/values-ru/strings.xml new file mode 100644 index 000000000..eb3207052 --- /dev/null +++ b/android/app/src/main/res/values-ru/strings.xml @@ -0,0 +1,838 @@ + + + OpenNOW + Запуск OpenNOW + Войти через %1$s + Войти на другом устройстве через %1$s + Используйте этот код для входа + %1$s + Ожидание входа + Код истекает через %1$d:%2$02d + Магазин + Поиск + Библиотека + Настройки + Поиск игр + Поиск настроек + Общие + Обновления, конфиденциальность и данные приложения + Язык + Язык приложения + Системный язык + Английский + Трансляция + Разрешение, FPS, кодек, HDR, прокси + Управление + Микрофон, мышь, клавиатура, сенсорное управление, вибрация + Интерфейс + Оформление, библиотека, строка состояния и звуки + Учётная запись + Вход, хранилище, подключённые магазины + Дополнительно + Дополнительные параметры, эксперименты, диагностика и журналы + О приложении + Версия, благодарности и поддержка + Показывать названия игр + Очистить поиск + Голосовой поиск + Игр: %1$d + Игры не загружены + В библиотеке нет подходящих игр + Очистите поиск, чтобы показать все игры в библиотеке. + Сбросьте фильтры, чтобы показать все игры в библиотеке. + Очистите поиск или фильтры, чтобы показать все игры в библиотеке. + В магазине нет подходящих игр + Очистите поиск, чтобы показать больше игр. + Сбросьте фильтры, чтобы показать больше игр. + Очистите поиск или фильтры, чтобы показать больше игр. + Вернуться в игру + Скоро + Новинки GeForce NOW + Продолжить игру + В очереди + Избранное + Рекомендации + Показать все + Играть + Продолжить + Возобновить + Сохранить + Сохранено + Добавить в избранное + Удалить из избранного + Отмена + Вкл. + Выкл. + Видимо + Скрыто + Назад + Открыть + Сбросить + Закрыть + Управление трансляцией + Выйти + Готово + Открыть ввод с клавиатуры + Экран + Управление + Поддержка + Контроллер + Сенсорная раскладка + Аудио + Без звука + Строка состояния + %1$s · элементов: %2$d + Повышение резкости трансляции + Уровень резкости + Растянуть по размеру экрана + Сейчас + В этой сессии используется %1$d Мбит/с + Настройки › Трансляция применится только к следующей сессии + Микрофон + Требуется разрешение + Меню Steam + Отправить клавишу Home на удалённый компьютер + Esc + Enter + + Мышь с контроллера + Правый стик · A — щелчок · B — правый щелчок + Мышь пальцем + Прямое касание + Сенсорный контроллер + Обнаружено встроенное сенсорное управление + Эта игра поддерживает встроенное сенсорное управление. При желании можно включить сенсорный контроллер OpenNOW ниже. + Эта игра поддерживает встроенное сенсорное управление. В этой сессии включён сенсорный контроллер OpenNOW. + Встроенное управление активно + Джойстики + Фиксированные + Динамические + Вибрация телефона вместо контроллера + Режим мыши с контроллера + Левый стик перемещает · Правый прокручивает · A — щелчок · B — правый щелчок + Режим мыши + Настроить эмуляцию мыши + Сенсорное управление + Раскладка контроллера, джойстики и вибрация + Сообщить о проблеме + Запустить проверки и отправить обезличенную диагностику + Режим перемещения элементов + Сбросить сенсорную раскладку + Вернуть позиции по умолчанию + Масштаб раскладки + Размер кнопок + Непрозрачность + Отступ от края + Отступ снизу + Положение слева + Положение справа + Джойстики + Настройка сенсорных аналоговых элементов + Динамическое размещение + Появляется по центру под пальцем + Использует сохранённый фиксированный центр + Размер стика + Мёртвая зона + Динамический режим сохраняет область стика, но считает нейтральной точкой первое касание пальца. Это предотвращает резкое движение, если вы не попали точно в центр. + Строка состояния + Выберите компоновку и информацию + Внешний вид + Положение + Элементы + FPS + Пинг + Битрейт + Батарея + Соединение + Разрешение + Кодек + Сервер + Дек. / Джит. + Потери + Клавиатура + %1$d/100 + %1$s + Больше не показывать отчёты о сессиях + Соединение + Не измерено + Задержка + Скорость трансляции + Потеря пакетов + Джиттер + Частота кадров + Декодирование + в среднем %1$d мс + пик %1$d мс + пик %1$s + Стабильно + Может влиять на чёткость + Колебание времени + Средний / целевой FPS + На видеокадр + Управление сессией + Завершить трансляцию? + Действительно выйти из %1$s? + Текущая облачная игровая сессия будет закрыта. + Продолжить игру + Завершить трансляцию + Отчёт об ошибке + Сообщить об ошибке + Сообщить об ошибке трансляции + Отправить описание проблемы и обезличенную диагностику + Показать описание + Скрыть описание + Пинг %1$s + Дек. %1$s мс + Джит. %1$s мс + Потери %1$s%% + %1$d кадров в секунду + Пинг %1$d миллисекунд + Время декодирования %1$s миллисекунд на кадр + Джиттер %1$s миллисекунд + Потеря пакетов %1$s процентов + хорошее + среднее + плохое + Закрыть + Играть на %1$s + Сбросить фильтры + Наверх + Авто + Скоро + Выберите платформу + Платформы + По умолчанию + Выбрано + Доступная платформа + Больше не спрашивать — сделать этот магазин основным + Продолжение с основным магазином: %1$s + Совет: удерживайте «Играть», чтобы позже выбрать другой магазин. + Удерживайте «Играть», чтобы выбрать магазин + Трансляция + Интерфейс + Качество + Видео + Соединение + Аудио и клавиатура + Управление указателем + Захват мыши + Удерживает внешнюю мышь внутри игры во время трансляции. Открытие управления трансляцией освобождает её. + Контроллер и сенсорное управление + Внешний вид + Библиотека и навигация + Строка состояния + Звуки и сессии + Показывать отчёт о сессии + Показывать сводку качества после каждой трансляции. + Дополнительные инструменты + Благодарности + Разрешение + Соотношение сторон + Профиль трансляции + Рекомендуемый + Пользовательский + Низкий (экономия трафика) + Средний + Высокий + FPS + Битрейт, Мбит/с + Кодек + Цвет + Только H.264/H.265 + AV1 использует 8-битный цвет. Для 10 бит выберите H.265; HDR доступен только в совместимых режимах Android TV. + AV1 в OpenNOW использует 8-битный цвет. Выполнен переход на 8 бит и HDR отключён. Для 10 бит выберите H.265 или H.264. + HDR (Performance & Ultimate) + HDR-трансляция недоступна на портативных устройствах Android. 10-битный SDR остаётся доступен с H.265. + Для HDR на Android TV требуется H.265 при 60 FPS или ниже и разрешение до 3840 × 2160. + Регион + Прокси сессии + Направляет создание сессии GFN и проверку очереди через этот прокси. Оставьте выключенным для прямых запросов. + URL прокси + Копировать диагностику кодека + Диагностика кодека скопирована + Проверка кодека ещё не запускалась. + Включить прокси сессии? + Создание сессии GFN, проверка очереди, возобновление, остановка и обновление рекламы в очереди будут направлены через указанный прокси. + Неверный или заблокированный прокси может нарушить запуск, движение очереди, возобновление активной сессии или её завершение. + Используйте только доверенный прокси. Его оператор может видеть время запросов, целевые узлы и конфиденциальные метаданные трафика сессии. + Включить прокси + Экспериментальная трансляция + Может привести к сбоям запуска сессии. + L4S + Запрашивает транспорт NVIDIA с низкой задержкой и потерями, если его поддерживают сервер и сеть. Отключите, если сеть становится нестабильной. + Запрос Cloud G-Sync / VRR + Просит облачную сессию использовать переменную частоту обновления, если это поддерживают устройство, экран, тариф и сессия GFN. + Микрофон + Передаёт стандартный микрофон Android в удалённую игру. Его можно отключить в управлении трансляцией. + Разрешение на микрофон не предоставлено. OpenNOW оставит трансляцию микрофона выключенной. + Использовать системные цвета + Акцент + Начальная страница + Магазин + Библиотека + Отключить проверку обновлений + Дополнительные параметры + Показывает экспериментальные параметры каталога и настройки. Вкладка расширенной диагностики остаётся доступной. + Выразительный стиль карточек + Использует более яркие поверхности карточек и мягкие углы. Отключите для более плоского и спокойного стиля Material. + Фон каталога + Показывает фоновое изображение за Магазином и Библиотекой на экранах портативных устройств. + Фоновое изображение + Своё изображение + Встроенный фон + Цветная абстракция (по умолчанию) + Оригинальный OpenNOW + Absolute Cinema + Выбрать изображение + Использовать по умолчанию + Отступ от края экрана + Компактные карточки игр + Показывать названия магазинов + Размер карточек игр + Скрыть кнопки трансляции + Кнопка экранной клавиатуры + Показывает компактный значок клавиатуры в строке состояния трансляции. + Показывать строку состояния по умолчанию + Положение панели статистики + Скрыть выбор сервера + Звуки нажатия кнопок + Воспроизводит короткий звук интерфейса при навигации контроллером и нажатии экранных кнопок. + Воспроизводить вступительную музыку + Вступительная музыка запускается + Без звука + Со звуком + Воспроизводить музыку после завершения очереди + Отключить музыку + Растянуть трансляцию на весь экран + Умный таймер сессии + Спасибо всем, кто помогает делать OpenNOW лучше. + DarkevilPT + Поддержка сообщества + Поддержать + Ссылка для поддержки скопирована + OpenNOW + Синий Pixel + Ярко-розовый + Лаймовый + Коралловый + Фиолетовый + Нативный стример (экспериментальный) + Перехватывает аппаратный декодер для установки параметров низкой задержки производителя. Может работать нестабильно. + Автоматический нативный сенсорный режим использует геймпад для трансляций с высоким разрешением или FPS, чтобы сохранить выбранный режим. Выберите «Каждая игра», чтобы отдать приоритет нативному сенсорному управлению. + Свернуть + Показать + Играть на телевизоре + Запуск трансляции + Позиция в очереди: %1$d + Ожидание игрового компьютера + Подключение трансляции + Возобновление сессии + Подготовка игрового компьютера + Запуск сессии + Состояние очереди + %1$s готова к запуску! + Ожидание в очереди GFN завершено. Нажмите, чтобы вернуться в приложение. + Трансляция продолжается при выключенном экране + Нет в библиотеке + Неизвестный издатель + Возобновить облачную сессию + Приложение %1$s + Очередь %1$d + Запуск + Описание этой игры пока недоступно. + Раскладка клавиатуры + Язык игры + Вставка из буфера обмена + Далее + Повторить + Запустить + Пропустить + Обновить + Отправить + ОК + Отменить действие + Установить + Управлять + Разрешить + Активно + Готово + Проверка + Лучший доступный маршрут + Впереди + Ожидание + Таймер сеанса + Очистить кэш + Сбросить обучение + Сбросить настройки + Сбросить и перезапустить + Переключить + Добавить аккаунт + Выйти + Выйти из всех аккаунтов + Выбрать провайдера + Статистика игрового времени + Облачное хранилище + Добавить хранилище + Изменить расположение хранилища + Нет активной трансляции + Вернуться в библиотеку + Завершить облачный сеанс + Шаг %1$d из %2$d + Нажмите «Готово» + Обнаружен контроллер + Экранный контроллер скрыт, так как подключён физический контроллер. + Больше не показывать + Запуск сопряжения с телефоном… + Сопряжение с приложением OpenNOW на телефоне + Сначала установите и откройте OpenNOW на телефоне Android. Подключите телефон и телевизор к одной сети Wi‑Fi, затем отсканируйте этот QR-код камерой телефона. Ссылка действует пять минут. + Сопряжение с ТВ + Сопряжение с телевизором + Телефон и телевизор должны быть в одной сети Wi‑Fi. Отсканируйте здесь QR-код телевизора или найдите телевизор и введите его 4-значный код. + Подключено к %1$s. Теперь в играх доступно действие «Играть на ТВ». + Подключено к %1$s + Войти на ТВ + Забыть ТВ + Отсканируйте QR-код или найдите телевизор в сети + Сканировать QR ТВ + Не удалось открыть сканер QR + Найти ТВ + Поиск… + Код ТВ + Введите 4-значный код, показанный на этом телевизоре. + Сопрячь + Аккаунты и сервисы + Профили, подписка, хранилище и игровые магазины + Новые игры + Результаты + Избранный баннер библиотеки + Стиль сенсорного контроллера + Цвет сенсорного контроллера + Буквы кнопок + Прицеливание гироскопом + В этой сети не найден телевизор OpenNOW. + Открыть профиль аккаунта + Имя пользователя + Уровень + Эл. почта + Параметры аккаунта + %1$s • %2$s + Недоступно + Параметры разработчика + Сброс сценариев, проверка среды выполнения и пересборка локального состояния + Для разработки и поддержки + Эти действия сбрасывают только собственное локальное состояние OpenNOW и показывают сведения, которые уже есть в диагностическом экспорте. Разрушительные действия требуют подтверждения. Эту страницу можно снова скрыть внизу списка. + Сценарии и запросы + Каталог и магазины + Трансляция + Интерфейс + Диагностика + Разрушительные + Сбросить + Очистить + Запустить + Применить + Копировать + Повторить + Скрыть + Повторить первый запуск + Настройка, руководства, запросы, согласие и состояние просмотра сразу + Настройка запустится заново, все одноразовые запросы появятся снова, согласие на аналитику будет отозвано до повторного ответа, а порядок в Магазине и Библиотеке сбросится. Аккаунты, избранное и настройки трансляции не затрагиваются. + Запустить настройку заново + Показывает экраны первого запуска при следующем старте + Снова показать руководство по трансляции + Появится снова при следующем запуске трансляции + Снова показать запрос о контроллере + Появится снова при следующем подключении контроллера + Снова запросить согласие на аналитику + Остаётся отключённым, пока на вопрос не ответят снова + Повторить миграции обновления + Заново применяет одноразовые настройки оформления и раскладки для ТВ + Очистить кэш игр + Удаляет кэшированные результаты Магазина, Библиотеки и поиска + Перезагрузить каталог + Сейчас перезагружает Магазин и Библиотеку у поставщика + Сбросить состояние просмотра + Порядок и фильтры Магазина и Библиотеки вернутся к значениям по умолчанию + Забыть выбор лаунчера + Все запомненные выборы магазина для каждой игры + Очистить избранное + Сохранено: %1$d + Все игры из избранного будут удалены. Это действие нельзя отменить. + Очистить полку приложений + Закреплено установленных приложений: %1$d + Применить измеренную рекомендацию + Сбросить сенсорную раскладку + Размер наложения, непрозрачность и все смещения кнопок + Обновить очереди серверов + Заново запрашивает список зон PrintedWaste и пинги + Сбросить интерфейс + Акцент, фон, раскладка карточек и настройки анимации + Скопировать журнал диагностики + Очищенный, тот же текст, который прикладывает отчёт об ошибке + Скопировать сводку среды + Таблица выше в виде текста + Проверить обновления + Немедленно запускает проверку обновлений + Выйти из всех аккаунтов + Сохранено: %1$d + Все сохранённые аккаунты будут удалены с этого устройства, и OpenNOW вернётся к экрану входа. + Стереть данные приложения и перезапустить + Возвращает OpenNOW к состоянию новой установки + Аккаунты, настройки, кэшированные игры и локальные файлы будут удалены, а OpenNOW перезапустится как новая установка. Это действие нельзя отменить. + Скрыть параметры разработчика + Коснитесь номера сборки в разделе «О приложении» десять раз, чтобы вернуть их + Сборка + Вариант + Устройство + Android + Профиль раскладки + Подписка + Поставщик + Профиль трансляции + Аппаратные декодеры + Игры Магазина / Библиотеки + Нет + Выполнен выход + Android TV + Портативная консоль + Состояние первого запуска восстановлено + Настройка запустится при следующем старте + Руководство по трансляции будет показано снова + Запрос о контроллере будет показан снова + Согласие на аналитику будет запрошено снова + Миграции обновления будут повторены + Состояние просмотра сброшено + Выбор лаунчера забыт + Избранное очищено + Полка приложений очищена + Измеренная рекомендация применена + Сенсорная раскладка сброшена + Интерфейс сброшен + Журнал диагностики скопирован + Сводка среды скопирована + Параметры разработчика скрыты + Ещё %1$d нажатий, чтобы показать параметры разработчика + Параметры разработчика теперь в Настройках + Параметры разработчика уже отображаются + Далее + Недоступно + Вибрация + Вибрация контроллера, если доступна; иначе вибрация устройства + Вывод вибрации + Некоторые портативные консоли сообщают о моторе вибрации во встроенном геймпаде, который ни к чему не подключён. Принудительно включите мотор телефона, если вибрация в игре молчит. + Автоматически + Контроллер + Телефон + Сенсорное прицеливание + Заблокировать джойстик + Заблокировать зону / сенсорное прицеливание + Проведите в любом месте правой зоны для относительного прицеливания мышью. + ЗОНА ПРИЦЕЛИВАНИЯ + Сообщество Discord + Получайте помощь и отслеживайте отчёты об ошибках вместе с сообществом OpenNOW. + Поддержка сообщества и отслеживание отчётов об ошибках + Присоединиться + Приглашение в Discord скопировано + Сортировка и фильтры + Сортировка и фильтры, активно: %1$d + Сортировка + Популярные + Недавно сыгранные + Фильтры + Управление + Мобильное сенсорное управление + Количество пикселей, которое отправляет облачный ПК. Высокие разрешения выглядят чётче, но требуют больше ресурсов декодера, GPU и сети. + Подгоняет форму трансляции под экран. Несовпадающее соотношение может добавить чёрные полосы или растяжение; декодер быстрее не станет. + «Рекомендуется» использует экран, память, число процессоров, профиль Android и проверенные аппаратные декодеры WebRTC этого устройства. «Свои настройки» сохраняют ваш ручной выбор. + Обнаруженная рекомендация: %1$s + Кадры в секунду определяют плавность движения. Больше FPS оставляет декодеру меньше времени на кадр и может вызывать рывки на медленном железе. + Максимальный битрейт видео. Более высокий битрейт может улучшить детализацию, но только если у соединения достаточно стабильной пропускной способности; FPS он не повышает. + H.264 наиболее совместим. H.265 эффективнее использует полосу пропускания и предпочтителен при высоком разрешении, если есть проверенный аппаратный декодер. AV1 здесь 8-битный и используется только на устройствах с совместимым аппаратным путём. + 8 бит 4:2:0 — самый лёгкий и совместимый вариант. 10 бит улучшает градиенты, но повышает требования к декодеру и полосе пропускания. Android автоматически приводит неподдерживаемые сочетания к рабочим. + Для HDR нужны совместимый экран Android TV, H.265, 10-битное видео и поддерживаемая подписка. Он повышает нагрузку на обработку и не рекомендуется при диагностике задержек. + Добавить свои приложения + Показывает полку в Библиотеке, где можно добавлять, запускать и удалять установленные приложения и игры Android. + Сделать лаунчером по умолчанию + Открывает выбор лаунчера в Android, чтобы OpenNOW мог стать главным экраном. Это можно изменить снова в настройках Android. + Выбрать лаунчер + OpenNOW используется по умолчанию + Управлять + Мои приложения + Добавить приложение + Выберите приложение + Загрузка установленных приложений… + Показать мои приложения, установлено: %1$d + Скрыть мои приложения, установлено: %1$d + Других запускаемых приложений не найдено. + Удалить %1$s + Это удаляет только ярлык с вашей полки. Приложение остаётся установленным. + Удалить + Рекомендуемое + Каждый скин — это другой контроллер, а не просто другой цвет: форма кнопок, то, является ли крестовина единой или четырьмя отдельными клавишами, и область, в которой ходит стик, меняются вместе с ним. + Перекрашивает скины, построенные вокруг акцента. «Классика», «Контур», «Иней» и «Высокий контраст» монохромны по замыслу. + «Выкл.» оставляет кнопки без подписей, когда раскладка уже в мышечной памяти. + Скин + Размер основных кнопок + Размер крестовины + Размер триггеров и бамперов + Размер меню и нажатия стика + Размер левого стика + Размер правого стика + Размер головки стика + Наклоняйте телефон для относительного прицеливания мышью. + Это устройство не сообщает о гироскопе. + Чувствительность гироскопа + Мёртвая зона гироскопа + Сглаживание гироскопа + Инвертировать гироскоп по горизонтали + Инвертировать гироскоп по вертикали + Прицеливание движением + Сменяющийся баннер над сеткой Библиотеки на телефонах в портретной ориентации. + Это приложение больше не установлено или не может быть открыто. + Порядок библиотеки + Недавно сыгранные + Название А–Я + Анимированные контуры выбора + Анимирует выбранные игры, пункты меню, выбор серверов и параметры лаунчера. Отключите для более спокойного выделения. + Эффекты Absolute Cinema + Использует анимированные оранжевые и синие кольца фокуса, сохраняя выбранный вами цвет интерфейса. + Я сумасшедший + Выпустите Absolute Cinema на весь интерфейс. Обложки, описания, элементы управления и не только получают эффект при наведении и фокусе. + Показывать значок избранного на карточках + Показывает кнопку избранного на карточках игр для телефона, портативной консоли и ТВ. + Включено по умолчанию. Заполняет экран вместо чёрных полос, растягивая изображение только по несовпадающей оси — никогда не обрезая его. Отключите для точной геометрии. + Применяет дополнительный фильтр GPU после декодирования. Это может улучшить воспринимаемую детализацию, но повышает нагрузку на отрисовку на медленных устройствах. + Управляет силой фильтра резкости постобработки. Разрешение исходной трансляции не меняется. + Absolute Cinema + Switch + Начать + Далее + Назад + Пропустить + Готово + Нативный GeForce NOW для Android + Настройте под себя + Всё здесь применяется сразу при выборе. + Акцент + Анимации интерфейса + Мерцание, свечение фокуса и движение карусели. Отключение также учитывает системную настройку анимаций. + Предпросмотр + Раскладка + Каждый вариант перерисовывает предпросмотр + Названия игр под обложками + «Выкл.» оставляет сетку из одних обложек. + Квадратные карточки + Обрезает обложку до квадрата, чтобы на экран помещалось больше игр. + Кнопка избранного на обложке + Сохраняет игру в вашу Библиотеку, не открывая её. + Скруглённые углы + Более мягкие края карточек и панелей во всём приложении. + Absolute Cinema + Анимированные энергетические рамки вокруг того, что в фокусе. Обычно оформление для контроллера и ТВ. + Отклик + Оба сработают при следующем касании + Вибрация + Короткая вибрация при выборе и вибрация контроллера в игре там, где устройство её поддерживает. + Звуки интерфейса + Звук при нажатии кнопок и навигации по меню. На звук игры не влияет. + Фон + Выкл. + По умолчанию + Нет + Фон приложения + Обои + Ваше изображение + Качество трансляции + Измерено по экрану, чипсету и декодерам этого устройства. + Измерение устройства + Рекомендуется + Экономия трафика + 720p, 30 FPS, 12 Мбит/с + Максимальное качество + До %1$s при %2$d FPS на вашем тарифе + Настрою сам + Выберите ниже разрешение, частоту кадров и битрейт + Подписка %1$s + Ваш тариф транслирует до %1$s при %2$d FPS. Более высокие варианты перечислены с уровнем, который их открывает. + Повышение подписки GeForce NOW поднимает этот предел — OpenNOW его не ограничивает. + Во время игры + Выберите, как ощущается трансляция. + Предпросмотр + Сенсорная мышь + Напрямую + Касайтесь там, где хотите нажать + Трекпад + Проведите для перемещения, затем коснитесь + Выкл. + Используйте контроллер или физическую мышь + Касание = перемещение + нажатие + Проведите, затем коснитесь + Контроллер / мышь + Играть + Строка состояния + FPS, пинг, заряд и соединение — одним взглядом. + Положение + 60 FPS • 24 мс • Wi-Fi + Когда что-то ломается + Рывки, чёрные экраны, неработающие контроллеры. + Встроенный отчёт об ошибках + Откройте его из элементов управления во время трансляции или из отчёта, предлагаемого после завершения сессии. + Сначала он сверяет ваши настройки с сессией и отмечает известные плохие сочетания, обычно с решением. + Он отправляет ваше описание и очищенную диагностику — настройки, измерения декодера и сети, модель устройства. Без данных аккаунта. + Отчёт о сессии после каждой трансляции + Задержка, ритм кадров и потеря пакетов по завершении сессии, с быстрым переходом к отчёту об ошибке. + Делиться анонимной диагностикой + Помогает находить закономерности среди сбоев и проблем с производительностью. Конфиденциальные данные удаляются, и ничего не продаётся. Если это выключено, отчёт о сбое может не содержать достаточно данных для расследования. + Готово + Всё это можно изменить в Настройках. + Ваш выбор + Качество трансляции + Сенсорная мышь + Строка состояния + Вкл. + Выкл. + Настройка + Запустить настройку заново + Пересмотрите оформление, качество трансляции, управление в игре, строку состояния и отчёты об ошибках + Требуется %1$s + GeForce NOW указывает %1$s только как %2$s, а этот аккаунт использует %3$s. Скорее всего, сессия будет отклонена или понижена до более низкого профиля. + Право доступа определяет GeForce NOW, а не OpenNOW, и каталог иногда устаревает — так что попробовать всё же можно. + Всё равно попробовать + Копировать ошибку + Отключить + Назад + Войти + Делиться аналитикой + Делитесь анонимной диагностикой, чтобы помочь нам находить закономерности в ошибках, сбоях и проблемах с производительностью. Конфиденциальные данные удаляются, и мы не продаём ваши данные. + Если во время сбоя обмен данными отключён, у нас может не хватить сведений для расследования вашего отчёта. По умолчанию он выключен и меняется в настройках конфиденциальности. + Оставить выключенным + Поделиться диагностикой? + Проверка последней сборки… + Проверка Google Play… + Проверка этой сессии… + Проверки + Тот же журнал с отметками времени, доступный в «Настройки > Дополнительно > Журналы отладки», прикладывается автоматически. Другие файлы не добавляются. + Ваши данные не продаются и используются только для расследования и исправления ошибок. + Автоматический журнал удаляет имена аккаунтов, учётные данные, идентификаторы сессий и сетевые адреса перед отправкой. Исходный идентификатор устройства не отправляется. + Что собирается? + Введённые вами заголовок и описание отправляются ровно так, как написаны, поэтому не включайте личные или конфиденциальные сведения. + Сопровождающие PrintedWaste и OpenNOW могут видеть текст отчёта, версию/сборку приложения, модель устройства, версию Android, поставщика и категорию подписки, текущую игру, состояние и настройки трансляции, псевдонимный идентификатор установки для предотвращения злоупотреблений и очищенный журнал диагностики. + Отправить этот отчёт и приложенную очищенную диагностику в API PrintedWaste? + Отправить отчёт об ошибке? + Я согласен отправить этот отчёт. + Я понимаю, что будет отправлено, и согласен передать это в API PrintedWaste. + Опишите ошибку на английском. Диагностика сессии прилагается. + Что произошло? + Что вы делали, что пошло не так и можете ли вы это повторить? + Требуется английский язык + Перед отправкой отчёта переключите OpenNOW или язык устройства на английский. + Опишите проблему, не выходя из игры. + Я понимаю, что OpenNOW нашёл вероятную причину. Отправить всё равно; я могу потерять доступ к отправке отчётов в будущем. + ПОДХОДЯЩИЕ РЕКОМЕНДАЦИИ + Для этой проверки не предлагаются посторонние решения. + Проверки в реальном времени с этого устройства и этой сессии + Прежде чем отправить отчёт + Повторить проверку версии + Проверить и отправить + Отправить ещё один + Всё равно отправить + Отправка… + Отчёт об ошибке отправлен + Проблема сохраняется после подходящей рекомендации? Продолжите, и измеренные данные будут приложены автоматически. + Заголовок проблемы + Трансляция зависла после переподключения + Обновить в Google Play + Отправить отчёт + Отправить отчёт об ошибке? + Отправка отчёта… + Использовать английский в OpenNOW + Проверка очередей и задержки PrintedWaste + Описание + Фильтры + Маршрутизация очереди бесплатного уровня + Снимки экрана + Кнопка «Назад» на пульте + Подробности + Устройство, тип аккаунта, профиль трансляции, текущее состояние и временная ссылка скопированы в буфер обмена. + Диагностика скопирована + OpenNOW удалит токены, идентификаторы аккаунтов, адреса эл. почты, идентификаторы сессий и сетевые адреса перед отправкой. + Случайная ссылка не публикуется, но не шифруется, а сервис удаляет загрузки в течение 24 часов. + Создать временную диагностическую ссылку? + Удаление конфиденциальных значений и создание временной ссылки… + Подготовка диагностики + Не удалось создать QR-код. Закройте это окно и попробуйте снова. + Отсканируйте этот QR-код телефоном. Очищенная ссылка истекает в течение 24 часов. + Отсканируйте диагностическую ссылку + Очистить и отправить + Браузер недоступен + Не удалось открыть страницу магазина + Не удалось начать подключение к магазину + Не удалось отключить магазин + Токен доступа + Не удалось экспортировать журналы + Журналы экспортированы + КОД СОПРЯЖЕНИЯ + Нативный клиент GeForce NOW для Android + Вставьте токен доступа NVIDIA или JSON-ответ с токеном. OpenNOW проверяет токен доступа перед сохранением аккаунта. + Вход по токену + Используйте только учётные данные аккаунта, которым вы владеете. + Войдите по токену без браузера или экспортируйте диагностику перед входом. + Инструменты входа + Использовать вход по коду + Реклама + Текущая позиция + Очередь + НАЗАД + Сообщите о ней + Столкнулись с ошибкой? + Выданный профиль + Сессия была короткой, поэтому оценка может колебаться сильнее обычного. + Отчёт о сессии + Что делать дальше + Почему профиль изменился + Эти настройки выше обнаруженной рекомендации + Активность в фоне + Оптимизировано (в фоне возможен тайм-аут) + Без ограничений (разрешено в фоне) + Оптимизация батареи Android ограничивает работу приложения в фоне, из-за чего могут возникать тайм-ауты соединения или приостанавливаться продвижение в очереди GFN, когда приложение свёрнуто. + Кэшированные результаты магазина, библиотеки и поиска будут удалены. Ваш аккаунт и настройки останутся без изменений. + Очистить кэш игр? + Помощь с подключением + Экспортирует состояние запуска, состояние очереди, обновления трансляции, события восстановления, настройки, возможности кодеков и недавние очищенные JSON-ответы CloudMatch. + Разработчик + Для этого аккаунта не активировано дополнение постоянного хранилища. + Что нового + Аккаунты, настройки, кэшированные игры, состояние обучения и локальные файлы приложения будут удалены. OpenNOW перезапустится как новая установка. + «Сбросить обучение» лишь вернёт руководство по трансляции. «Сбросить настройки» — разрушительное действие: оно стирает локальные данные приложения и перезапускает OpenNOW. + Сбросить настройки и данные приложения? + Выберите поставщика GeForce NOW для нового аккаунта. + Использование хранилища + Подключения к магазинам + Конфиденциальные значения удаляются перед созданием непубличной временной ссылки. Отсканируйте QR-код телефоном, чтобы поделиться ею. + Отправить журналы и показать QR-код + Это отправит сводку об изменении профиля и вероятную причину сопровождающим PrintedWaste и OpenNOW, чтобы они могли разобраться. + Отправить диагностику трансляции? + Отправить диагностику + Сейчас к OpenNOW не подключена локальная трансляция. + Профиль трансляции изменился + Почему это произошло + Отправка отчёта и очищенной диагностики… + Ваши сохранённые настройки трансляции не изменились. + Облачная сессия уже активна + Завершить и начать новую + Введите или измените текст трансляции + Кэш игр уже был пуст + Кэш игр очищен + Очистка данных приложения и перезапуск OpenNOW + Безопасный вход с телефона выполнен + Магазин отключён + Обучение появится при следующей трансляции + Перетащить + + + %1$d сервер + %1$d сервера + %1$d серверов + %1$d сервера + + diff --git a/android/app/src/main/res/values-tr/strings.xml b/android/app/src/main/res/values-tr/strings.xml new file mode 100644 index 000000000..43d83d36c --- /dev/null +++ b/android/app/src/main/res/values-tr/strings.xml @@ -0,0 +1,836 @@ + + + OpenNOW + OpenNOW başlatılıyor + %1$s ile oturum aç + Başka bir cihazda %1$s ile oturum aç + Oturum açmak için bu kodu kullanın + %1$s + Oturum açma bekleniyor + Kodun süresi %1$d:%2$02d içinde dolacak + Mağaza + Ara + Kütüphane + Ayarlar + Oyun ara + Ayarlarda ara + Genel + Güncellemeler, gizlilik ve uygulama verileri + Dil + Uygulama dili + Sistem varsayılanı + İngilizce + Yayın + Çözünürlük, FPS, codec, HDR, proxy + Giriş + Mikrofon, fare, klavye, dokunmatik kontroller, titreşim + Arayüz + Görünüm, kütüphane, durum çubuğu ve sesler + Hesap + Oturum açma, depolama, bağlı mağazalar + Gelişmiş + Gelişmiş seçenekler, deneyler, tanılama ve günlükler + Hakkında + Sürüm, katkıda bulunanlar ve destek + Oyun adlarını göster + Aramayı temizle + Sesli arama + %1$d oyun + Yüklenmiş oyun yok + Kütüphanede eşleşen oyun yok + Kütüphanenizdeki tüm oyunları göstermek için aramayı temizleyin. + Kütüphanenizdeki tüm oyunları göstermek için filtreleri temizleyin. + Kütüphanenizdeki tüm oyunları göstermek için aramayı veya filtreleri temizleyin. + Mağazada eşleşen oyun yok + Daha fazla oyun göstermek için aramayı temizleyin. + Daha fazla oyun göstermek için filtreleri temizleyin. + Daha fazla oyun göstermek için aramayı veya filtreleri temizleyin. + Oyuna dön + Çok yakında + GeForce NOW’a yeni gelenler + Oynamaya devam et + Sırada + Favoriler + Öneriler + Tümünü gör + Oyna + Devam et + Sürdür + Kaydet + Kaydedildi + Favorilere ekle + Favorilerden çıkar + İptal + Açık + Kapalı + Görünür + Gizli + Geri + + Sıfırla + Kapat + Yayın Kontrolleri + Çık + Bitti + Klavye gönderimini aç + Ekran + Giriş + Destek + Kontrolcü + Dokunmatik Düzen + Ses + Sessiz + Durum çubuğu + %1$s · %2$d öğe + Yayın keskinleştirme + Keskinlik düzeyi + Sığdırmak için uzat + Canlı + Bu oturumda %1$d Mbps etkin + Ayarlar › Yayın yalnızca sonraki oturuma uygulanır + Mikrofon + İzin gerekli + Steam Menüsü + Uzak bilgisayara Ana Ekran tuşu gönder + Esc + Enter + + Kontrolcü faresi + Sağ çubuk · A tıklar · B sağ tıklar + Parmak faresi + Doğrudan tıklama + Dokunmatik kontrolcü + Oyunun yerleşik dokunmatik kontrolü algılandı + Bu oyun yerleşik dokunmatik kontrolleri destekliyor. İsterseniz aşağıdan OpenNOW dokunmatik kontrolcüsünü yine de açabilirsiniz. + Bu oyun yerleşik dokunmatik kontrolleri destekliyor. Bu oturumda OpenNOW dokunmatik kontrolcüsü etkin. + Yerleşik kontrol etkin + Kumanda çubukları + Sabit + Dinamik + Telefon titreşimini yedek olarak kullan + Kontrolcü fare modu + Sol çubuk hareket ettirir · Sağ çubuk kaydırır · A tıklar · B sağ tıklar + Fare modu + Fare emülasyonunu yapılandır + Dokunmatik kontroller + Kontrolcü düzeni, kumanda çubukları ve titreşim + Sorun bildir + Kontrolleri çalıştır ve kişisel verileri temizlenmiş tanılamayı gönder + Sürükleyerek düzenleme modu + Dokunmatik düzeni sıfırla + Konumları varsayılana döndür + Düzen ölçeği + Düğme boyutu + Opaklık + Kenar boşluğu + Alt boşluk + Sol konum + Sağ konum + Kumanda çubukları + Dokunmatik analog kontrolleri ayarla + Dinamik yerleşim + Başparmağınızın altında ortalanarak başlar + Kaydedilmiş sabit merkezi kullanır + Çubuk boyutu + Ölü bölge + Dinamik mod kayıtlı çubuk alanını korur ancak başparmağınızın ilk dokunduğu yeri merkez kabul eder. Tam merkeze dokunamadığınızda ani hareketi önler. + Durum çubuğu + Düzenini ve bilgilerini seçin + Görünüm + Konum + Öğeler + FPS + Ping + Bit hızı + Pil + Bağlantı + Çözünürlük + Codec + Sunucu + Kod çözme / Değişim + Kayıp + Klavye + %1$d/100 + %1$s + Oturum raporlarını bir daha gösterme + Bağlantı + Ölçülmedi + Gecikme + Yayın hızı + Paket kaybı + Değişim + Kare hızı + Kod çözme + ortalama %1$d ms + en yüksek %1$d ms + en yüksek %1$s + Kararlı + Netliği etkileyebilir + Zamanlama değişimi + Ortalama / hedef FPS + Video karesi başına + Oturum Kontrolü + Yayından çıkılsın mı? + %1$s oturumundan gerçekten çıkmak istiyor musunuz? + Mevcut bulut oyun oturumunuz kapatılacak. + Oynamaya devam et + Yayından çık + Hata bildirimi + Hata bildir + Yayın hatası bildir + Sorunu ve kişisel verileri temizlenmiş tanılamayı gönder + Açıklamayı göster + Açıklamayı gizle + Ping %1$s + Kod çözme %1$s ms + Değişim %1$s ms + Kayıp %1$s%% + Saniyede %1$d kare + Ping %1$d milisaniye + Kare başına kod çözme süresi %1$s milisaniye + Değişim %1$s milisaniye + Paket kaybı yüzde %1$s + iyi + orta + kötü + Kapat + %1$s üzerinde oyna + Filtreleri temizle + Başa dön + Otomatik + Çok yakında + Başlatıcı seç + Başlatıcılar + Varsayılan + Seçildi + Kullanılabilir başlatıcı + Bir daha sorma — bu mağazayı varsayılan yap + Varsayılan mağazayla devam ediliyor: %1$s + İpucu: Daha sonra farklı bir mağaza seçmek için Oyna’ya uzun basın. + Mağaza seçmek için Oyna’ya uzun basın + Yayın + Arayüz + Kalite + Video + Bağlantı + Ses ve klavye + İşaretçi girişi + Fare kilidi + Yayın sırasında harici fareyi oyunun içinde tutar. Yayın Kontrollerini açmak fareyi serbest bırakır. + Kontrolcü ve dokunmatik + Görünüm + Kütüphane ve gezinme + Durum çubuğu + Sesler ve oturumlar + Oturum raporunu göster + Her yayından sonra kalite özeti gösterir. + Gelişmiş araçlar + Teşekkürler + Çözünürlük + En-boy oranı + Yayın ön ayarı + Önerilen + Özel + Düşük (veri tasarrufu) + Orta + Yüksek + FPS + Bit hızı Mbps + Codec + Renk + Yalnızca H.264/H.265 + AV1, 8 bit renk kullanır. 10 bit için H.265’i seçin; HDR yalnızca uyumlu Android TV modlarında kullanılabilir. + OpenNOW’da AV1, 8 bit renk kullanır. 8 bite geçildi ve HDR kapatıldı. 10 bit için H.265 veya H.264’ü seçin. + HDR (Performance & Ultimate) + Android taşınabilir cihazlarda HDR yayın kullanılamaz. H.265 ile 10 bit SDR kullanılmaya devam edilebilir. + Android TV’de HDR için 60 FPS veya altında H.265 ve en fazla 3840 × 2160 çözünürlük gerekir. + Bölge + Oturum proxy’si + GFN oturumu oluşturma ve sıra sorgularını bu proxy üzerinden yönlendirir. Doğrudan istekler için kapalı bırakın. + Proxy URL’si + Codec tanılamasını kopyala + Codec tanılaması kopyalandı + Codec testi henüz çalıştırılmadı. + Oturum proxy’si etkinleştirilsin mi? + GFN oturumu oluşturma, sıra sorgulama, sürdürme, durdurma ve sıra reklamı güncelleme istekleri girdiğiniz proxy üzerinden yönlendirilecek. + Hatalı veya engellenmiş bir proxy başlatmayı, sıra ilerlemesini, etkin oturumu sürdürmeyi ya da oturum temizliğini bozabilir. + Yalnızca güvendiğiniz bir proxy kullanın. Proxy operatörü istek zamanlarını, hedef sunucuları ve hassas oturum trafiği meta verilerini görebilir. + Proxy’yi etkinleştir + Deneysel yayın + Oturum başlatma hatalarına yol açabilir. + L4S + Sunucu ve ağ desteklediğinde NVIDIA’nın düşük gecikmeli, düşük kayıplı aktarım yolunu ister. Ağınız kararsızlaşırsa kapalı bırakın. + Cloud G-Sync / VRR isteği + Cihazınız, ekranınız, planınız ve GFN oturumu desteklediğinde bulut oturumundan değişken yenileme zamanlaması kullanmasını ister. + Mikrofon + Varsayılan Android mikrofonunuzu uzak oyuna gönderir. Yayın Kontrollerinden sessize alabilirsiniz. + Mikrofon izni verilmedi. OpenNOW mikrofon yayınını kapalı tutacak. + Sistem renklerini kullan + Vurgu + Başlangıç sayfası + Mağaza + Kütüphane + Güncelleme denetimini kapat + Gelişmiş seçenekler + Deneysel katalog ve ayarlama seçeneklerini gösterir. Gelişmiş tanılama sekmesi kullanılabilir kalır. + Canlı kart stili + Daha parlak kart yüzeyleri ve daha yumuşak köşeler kullanır. Daha düz ve sakin bir Material stili için kapatın. + Katalog arka planı + Taşınabilir ekranlarda Mağaza ve Kütüphane arkasında bir arka plan resmi gösterir. + Arka plan resmi + Özel resim + Yerleşik arka plan + Renkli soyut (varsayılan) + Orijinal OpenNOW + Absolute Cinema + Resim seç + Varsayılanı kullan + Ekran kenarı boşluğu + Kompakt oyun kartları + Mağaza etiketlerini göster + Oyun kartı boyutu + Yayın düğmelerini gizle + Ekran klavyesi düğmesi + Yayın durum çubuğunda küçük bir klavye simgesi gösterir. + Durum çubuğunu varsayılan olarak göster + İstatistik katmanı konumu + Sunucu seçiciyi gizle + Düğme basma sesleri + Kontrolcüyle gezinirken ve ekran kontrollerine basarken kısa bir arayüz sesi çalar. + Giriş müziğini çal + Giriş müziği başlangıcı + Sessiz + Çalıyor + Sıra bittiğinde müzik çal + Müziği sessize al + Yayını ekranı dolduracak şekilde uzat + Akıllı oturum sayacı + OpenNOW’u herkes için geliştirmeye yardımcı olanlara teşekkürler. + DarkevilPT + Topluluk desteği + Bağış yap + Bağış bağlantısı kopyalandı + OpenNOW + Pixel mavisi + Canlı pembe + Limon yeşili + Mercan + Menekşe + Yerel yayıncı (Deneysel) + Üreticiye özel düşük gecikme özelliklerini uygulamak için donanım kod çözücüsüne müdahale eder. Kararsız olabilir. + Yerel dokunmatik Otomatik, seçilen yayın modunu korumak için yüksek çözünürlüklü veya yüksek FPS yayınlarda gamepad modunu kullanır. Yerel dokunmatiğe öncelik vermek için Her oyun’u seçin. + Simge durumuna küçült + Görüntüle + TV’de oyna + Yayın başlatılıyor + Sıra konumu %1$d + Oyun bilgisayarı bekleniyor + Yayına bağlanılıyor + Oturum sürdürülüyor + Oyun bilgisayarı hazırlanıyor + Oturum başlatılıyor + Sıra durumu + %1$s oynamaya hazır! + GFN sıranız tamamlandı. Uygulamaya dönmek için dokunun. + Ekran kapalıyken yayın devam eder + Sahip değilsiniz + Bilinmeyen yayıncı + Bulut oturumunu sürdür + Uygulama %1$s + Sıra %1$d + Başlatılıyor + Bu oyun için henüz bir açıklama yok. + Klavye düzeni + Oyun dili + Panodan yapıştırma + İleri + Tekrar dene + Başlat + Atla + Yenile + Gönder + Tamam + Geri al + Yükle + Yönet + İzin ver + Etkin + Hazır + Kontrol ediliyor + Kullanılabilir en iyi rota + Önünüzde + Bekleme + Oturum zamanlayıcısı + Önbelleği temizle + Eğitimi sıfırla + Ayarları sıfırla + Sıfırla ve yeniden başlat + Değiştir + Hesap ekle + Oturumu kapat + Tüm hesapların oturumunu kapat + Sağlayıcı seç + Oyun süresi istatistikleri + Bulut depolama + Depolama ekle + Depolama konumunu değiştir + Etkin yayın yok + Kitaplığa dön + Bulut oturumunu sonlandır + Adım %1$d / %2$d + Bitti’ye basın + Kumanda algılandı + Fiziksel bir kumanda bağlı olduğu için ekran kumandası gizlendi. + Bir daha gösterme + Telefon eşleştirmesi başlatılıyor… + OpenNOW telefon uygulamasıyla eşleştir + Önce Android telefonunuza OpenNOW’u yükleyip açın. Telefonu ve TV’yi aynı Wi‑Fi ağına bağlayın, ardından bu QR kodunu telefon kamerasıyla tarayın. Bağlantı beş dakika sonra sona erer. + TV eşleştirme + Bir TV ile eşleştir + Bu telefonu ve TV’yi aynı Wi‑Fi ağında tutun. TV’nin QR kodunu burada tarayın veya TV’yi bulup 4 haneli kodunu girin. + %1$s cihazına bağlandı. Oyunlarda artık TV’de Oyna eylemi gösterilir. + %1$s cihazına bağlı + TV’de oturum aç + TV’yi unut + QR kodu tara veya ağındaki bir TV’yi bul + TV QR kodunu tara + QR tarayıcı açılamadı + TV bul + Aranıyor… + TV kodu + Bu TV’de gösterilen 4 haneli kodu girin. + Eşleştir + Hesaplar ve hizmetler + Profiller, üyelik, depolama ve oyun mağazaları + Yeni oyunlar + Sonuçlar + Kitaplık öne çıkanlar başlığı + Dokunmatik kontrolcü görünümü + Dokunmatik kontrolcü rengi + Düğme harfleri + Jiroskopla nişan alma + Bu ağda OpenNOW TV bulunamadı. + Hesap profilini aç + Kullanıcı adı + Seviye + E-posta + Hesap seçenekleri + %1$s • %2$s + Kullanılamıyor + Geliştirici seçenekleri + Akışları sıfırla, çalışma ortamını incele ve yerel durumu yeniden oluştur + Geliştirme ve destek için + Bu işlemler yalnızca OpenNOW’un kendi yerel durumunu sıfırlar ve tanılama dışa aktarımında zaten bulunan bilgileri gösterir. Yıkıcı olanlar önce onay ister. Bu sayfayı listenin altından yeniden gizleyebilirsiniz. + Akışlar ve istemler + Katalog ve mağazalar + Yayın + Arayüz + Tanılama + Yıkıcı + Sıfırla + Temizle + Çalıştır + Uygula + Kopyala + Yeniden oynat + Gizle + İlk açılışı yeniden oynat + Kurulum, kılavuzlar, istemler, onay ve gezinme durumu tek seferde + Kurulum yeniden çalışacak, tüm tek seferlik istemler geri gelecek, analiz onayı yeniden yanıtlayana kadar geri çekilecek ve Mağaza ile Kitaplık sıralaması sıfırlanacak. Hesaplar, favoriler ve yayın ayarları etkilenmez. + Kurulumu yeniden çalıştır + Bir sonraki açılışta ilk kurulum ekranlarını gösterir + Yayın kılavuzunu yeniden göster + Bir sonraki yayın başladığında yeniden görünür + Oyun kumandası istemini yeniden göster + Bir sonraki kumanda bağlandığında yeniden görünür + Analiz onayını yeniden sor + Soru yeniden yanıtlanana kadar kapalı kalır + Yükseltme geçişlerini yeniden oynat + Tek seferlik sunum ve TV düzeni varsayılanlarını yeniden uygular + Oyun önbelleğini temizle + Önbelleğe alınmış Mağaza, Kitaplık ve arama sonuçlarını siler + Kataloğu yeniden yükle + Mağaza ve Kitaplığı sağlayıcıdan şimdi yeniden yükler + Gezinme durumunu sıfırla + Mağaza ve Kitaplık sıralaması ile filtreleri varsayılana döner + Başlatıcı seçimlerini unut + Oyun başına hatırlanan tüm mağaza seçimleri + Favorileri temizle + %1$d kayıtlı + Favorilere eklenen her oyun kaldırılacak. Bu işlem geri alınamaz. + Uygulama rafını temizle + %1$d yüklü uygulama sabitlendi + Ölçülen öneriyi uygula + Dokunmatik düzeni sıfırla + Yer paylaşımı boyutu, saydamlık ve tüm düğme kaydırmaları + Sunucu kuyruklarını yenile + PrintedWaste bölge listesini ve ping değerlerini yeniden sorgular + Arayüzü sıfırla + Vurgu, arka plan, kart düzeni ve animasyon varsayılanları + Tanılama günlüğünü kopyala + Temizlenmiş, hata raporunun eklediği metnin aynısı + Çalışma ortamı özetini kopyala + Yukarıdaki tablo, metin olarak + Güncelleme denetle + Güncelleme denetimini hemen çalıştırır + Tüm hesaplardan çıkış yap + %1$d kayıtlı + Kayıtlı her hesap bu cihazdan kaldırılacak ve OpenNOW oturum açma ekranına dönecek. + Uygulama verilerini sil ve yeniden başlat + OpenNOW’u yeni kurulmuş haline döndürür + Hesaplar, ayarlar, önbelleğe alınmış oyunlar ve yerel dosyalar silinecek ve OpenNOW yeni kurulmuş gibi yeniden başlayacak. Bu işlem geri alınamaz. + Geliştirici seçeneklerini gizle + Geri getirmek için Hakkında bölümünde derleme numarasına on kez dokunun + Derleme + Sürüm türü + Cihaz + Android + Düzen profili + Üyelik + Sağlayıcı + Yayın profili + Donanım kod çözücüler + Mağaza / Kitaplık oyunları + Yok + Oturum kapalı + Android TV + El konsolu + İlk açılış durumu geri yüklendi + Kurulum bir sonraki açılışta çalışacak + Yayın kılavuzu yeniden gösterilecek + Oyun kumandası istemi yeniden gösterilecek + Analiz onayı yeniden sorulacak + Yükseltme geçişleri yeniden oynatılacak + Gezinme durumu sıfırlandı + Başlatıcı seçimleri unutuldu + Favoriler temizlendi + Uygulama rafı temizlendi + Ölçülen öneri uygulandı + Dokunmatik düzen sıfırlandı + Arayüz sıfırlandı + Tanılama günlüğü kopyalandı + Çalışma ortamı özeti kopyalandı + Geliştirici seçenekleri gizlendi + Geliştirici seçeneklerini göstermek için %1$d dokunuş daha + Geliştirici seçenekleri artık Ayarlar’da + Geliştirici seçenekleri zaten gösteriliyor + İleri + Kullanılamıyor + Titreşim + Varsa kumanda titreşimi; yoksa cihaz titreşimi + Titreşim çıkışı + Bazı el konsolları, yerleşik kumandalarında hiçbir şeye bağlı olmayan bir titreşim motoru bildirir. Oyun içi titreşim sessiz kalıyorsa telefon motorunu zorlayın. + Otomatik + Kumanda + Telefon + Dokunmatik nişan alma + Joystick’i kilitle + Bölgeyi kilitle / dokunmatik nişan + Göreli fare bakışıyla nişan almak için sağ bölgenin herhangi bir yerinde sürükleyin. + NİŞAN BÖLGESİ + Discord topluluğu + OpenNOW topluluğuyla yardım alın ve hata raporlarını takip edin. + Topluluk desteği ve hata raporu takibi + Katıl + Discord daveti kopyalandı + Sırala ve filtrele + Sırala ve filtrele, %1$d etkin + Sıralama + Popüler + Son oynananlar + Filtreler + Kontroller + Mobil dokunmatik kontroller + Bulut bilgisayarın gönderdiği piksel sayısı. Yüksek çözünürlükler daha net görünür ama daha fazla kod çözücü, GPU ve ağ kapasitesi ister. + Yayının şeklini ekrana uydurur. Uyumsuz bir oran siyah çubuklar veya gerilme ekleyebilir; kod çözücüyü hızlandırmaz. + «Önerilen», bu cihazın ekranını, belleğini, işlemci sayısını, Android profilini ve doğrulanmış WebRTC donanım kod çözücülerini kullanır. «Özel», elle yaptığınız seçimleri korur. + Algılanan öneri: %1$s + Saniyedeki kare sayısı hareket akıcılığını belirler. Daha yüksek FPS, kod çözücüye kare başına daha az süre bırakır ve yavaş donanımda takılmaya yol açabilir. + En yüksek video veri hızı. Daha yüksek bit hızı ayrıntıyı iyileştirebilir, ancak yalnızca bağlantının yeterli kararlı kapasitesi varsa; FPS’i artırmaz. + H.264 en uyumlu olanıdır. H.265 bant genişliğini daha verimli kullanır ve doğrulanmış bir donanım kod çözücü varsa yüksek çözünürlükte tercih edilir. AV1 burada 8 bittir ve yalnızca uyumlu donanım yolu olan cihazlarda kullanılır. + 8 bit 4:2:0 en hafif ve en uyumlu seçenektir. 10 bit renk geçişlerini iyileştirir ama kod çözücü ve bant genişliği gereksinimlerini artırır. Android, desteklenmeyen bileşimleri otomatik olarak düzeltir. + HDR için uyumlu bir Android TV ekranı, H.265, 10 bit video ve desteklenen bir üyelik gerekir. İşlem yükünü artırır ve gecikme sorunlarını ararken önerilmez. + Kendi uygulamalarımı ekle + Kitaplıkta yüklü Android uygulamalarını ve oyunlarını ekleyip başlatabileceğiniz ve kaldırabileceğiniz bir raf gösterir. + Varsayılan başlatıcın yap + OpenNOW’un ana ekran olabilmesi için Android’in başlatıcı seçiciyi açar. Bunu Android ayarlarından yeniden değiştirebilirsiniz. + Başlatıcı seç + OpenNOW varsayılan + Yönet + Kendi uygulamalarım + Uygulama ekle + Bir uygulama seçin + Yüklü uygulamalar yükleniyor… + Uygulamalarımı göster, %1$d yüklü + Uygulamalarımı gizle, %1$d yüklü + Başlatılabilecek başka uygulama bulunamadı. + %1$s uygulamasını kaldır + Bu yalnızca kısayolu rafınızdan kaldırır. Uygulama yüklü kalır. + Kaldır + Öne çıkan + Her kaplama farklı bir kumandadır, yalnızca farklı bir renk değil: düğmelerin kesimi, yön tuşunun tek bir artı mı yoksa dört ayrı tuş mu olduğu ve çubuğun içinde hareket ettiği alan onunla birlikte değişir. + Bir vurgu rengi çevresinde kurulan kaplamaları yeniden renklendirir. Klasik, Dış Çizgi, Buz ve Yüksek Kontrast tasarımı gereği tek renktir. + «Kapalı», düzen kas hafızasına girdikten sonra tuşları boş bırakır. + Kaplama + Ön düğmelerin boyutu + Yön tuşu boyutu + Tetik ve omuz tuşu boyutu + Menü ve çubuk tıklama boyutu + Sol çubuk boyutu + Sağ çubuk boyutu + Çubuk başlığı boyutu + Göreli fare bakışıyla nişan almak için telefonu eğin. + Bu cihaz bir jiroskop bildirmiyor. + Jiroskop hassasiyeti + Jiroskop ölü bölgesi + Jiroskop yumuşatma + Jiroskopu yatayda ters çevir + Jiroskopu dikeyde ters çevir + Hareketle nişan alma + Dikey moddaki telefonlarda Kitaplık ızgarasının üstünde dönen afiş. + Bu uygulama artık yüklü değil veya açılamıyor. + Kitaplık sırası + Son oynananlar + Başlık A–Z + Hareketli seçim çerçeveleri + Seçili oyunları, menü öğelerini, sunucu seçimlerini ve başlatıcı seçeneklerini canlandırır. Daha sakin bir seçim için kapatın. + Absolute Cinema efektleri + Seçtiğiniz arayüz rengini korurken hareketli turuncu ve mavi odak halkaları kullanır. + Delirdim + Absolute Cinema’yı tüm arayüze salın. Üzerine gelinen ve odaklanılan görseller, açıklamalar, kontroller ve daha fazlası efekti alır. + Oyun kartlarında favori simgesini göster + Mobil, el konsolu ve TV oyun kartlarında bir favori düğmesi gösterir. + Varsayılan olarak açık. Siyah çubuklar bırakmak yerine ekranı doldurur; görüntüyü yalnızca uyuşmayan eksende gerer — asla kırpmaz. Tam geometri için bunu kapatın. + Kod çözmeden sonra ek bir GPU filtresi uygular. Algılanan ayrıntıyı artırabilir ama yavaş cihazlarda işleme yükü ekler. + Son işlem keskinleştirme filtresinin gücünü belirler. Kaynak yayının çözünürlüğünü değiştirmez. + Absolute Cinema + Switch + Başla + İleri + Geri + Atla + Bitir + Android için yerel GeForce NOW + Kendine göre ayarla + Buradaki her şey seçtikçe uygulanır. + Vurgu + Arayüz animasyonları + Parıltı, odak ışıması ve karusel hareketi. Kapatmak, sistemin animasyon ayarına da uyar. + Önizleme + Düzen + Her biri önizlemeyi yeniden çizer + Görsellerin altında oyun adları + «Kapalı», ızgarayı yalnızca kutu görselinden oluşan bir hale getirir. + Kare kartlar + Ekrana daha çok oyun sığsın diye kutu görselini kareye kırpar. + Görselin üzerinde favori düğmesi + Bir oyunu açmadan Kitaplığınıza kaydeder. + Yuvarlatılmış köşeler + Uygulama genelinde daha yumuşak kart ve panel kenarları. + Absolute Cinema + Odaklanılan öğenin çevresinde hareketli enerji çerçeveleri. Normalde kumanda ve TV için bir sunum. + Geri bildirim + İkisi de bir sonraki dokunuşunuzda çalışır + Titreşim + Bir şey seçtiğinizde kısa bir titreşim ve cihazın desteklediği yerlerde oyun içi kumanda titreşimi. + Arayüz sesleri + Düğmelere basınca ve menülerde gezinirken bir ton. Oyun sesini etkilemez. + Arka plan + Kapalı + Varsayılan + Yok + Uygulama arka planı + Duvar kâğıdı + Kendi görseliniz + Yayın kalitesi + Bu cihazın ekranı, yonga seti ve kod çözücüleri ölçülerek belirlendi. + Bu cihaz ölçülüyor + Önerilen + Veri tasarrufu + 720p, 30 FPS, 12 Mbps + En iyi kalite + Planınızda %2$d FPS ile %1$s değerine kadar + Kendim ayarlayayım + Aşağıdan çözünürlük, kare hızı ve bit hızı seçin + %1$s üyeliği + Planınız %2$d FPS ile %1$s değerine kadar yayın yapar. Daha yüksek seçenekler, onları açan seviyeyle birlikte listelenir. + GeForce NOW üyeliğinizi yükseltmek bu sınırı artırır — OpenNOW onu kısıtlamaz. + Oyun sırasında + Yayının nasıl hissettirdiğini seçin. + Önizleme + Dokunmatik fare + Doğrudan + Tıklamak istediğiniz yere dokunun + Dokunmatik yüzey + Hareket için kaydırın, sonra dokunun + Kapalı + Bir kumanda veya fiziksel fare kullanın + Dokunma = hareket + tıklama + Kaydırın, sonra dokunun + Kumanda / fare + Oyna + Durum satırı + FPS, ping, pil ve bağlantı tek bakışta. + Konum + 60 FPS • 24 ms • Wi-Fi + Bir şey bozulduğunda + Takılmalar, siyah ekranlar, çalışmayan kumandalar. + Yerleşik hata raporlayıcı + Yayın içi kontrollerden ya da oturum bittikten sonra sunulan rapordan açın. + Önce ayarlarınızı oturumla karşılaştırır ve bilinen sorunlu bileşimleri, genellikle bir çözümle birlikte işaretler. + Açıklamanızı ve temizlenmiş tanılama verilerini gönderir — ayarlar, kod çözücü ve ağ ölçümleri, cihaz modeli. Hesap bilgisi yok. + Her yayından sonra oturum raporu + Oturum bittiğinde gecikme, kare temposu ve paket kaybı; hata raporlayıcıya kısayolla birlikte. + Anonim tanılama verilerini paylaş + Çökmeler ve performans sorunları arasında örüntü bulmaya yardımcı olur. Hassas veriler kaldırılır ve hiçbir şey satılmaz. Bu kapalıyken bir çökme raporu incelemeye yetecek kadar bilgi taşımayabilir. + Tamamlandı + Bunların hepsini Ayarlar’dan değiştirebilirsiniz. + Seçimleriniz + Yayın kalitesi + Dokunmatik fare + Durum satırı + Açık + Kapalı + Kurulum + Kurulumu yeniden çalıştır + Görünümü, yayın kalitesini, oyun kontrollerini, durum satırını ve hata raporlamayı yeniden gözden geçirin + %1$s gerekiyor + GeForce NOW %1$s oyununu yalnızca %2$s olarak listeliyor ve bu hesap %3$s kullanıyor. Oturum büyük olasılıkla reddedilecek veya daha düşük bir profile indirilecek. + Erişim hakkına OpenNOW değil GeForce NOW karar verir ve katalog bazen güncel olmaz — yine de deneyebilirsiniz. + Yine de dene + Hatayı kopyala + Bağlantıyı kes + Geri dön + Oturum aç + Analizleri paylaş + Hatalarda, çökmelerde ve performans sorunlarında örüntü bulmamıza yardımcı olmak için anonim tanılama verilerini paylaşın. Hassas veriler kaldırılır ve verilerinizi satmayız. + Bir çökme sırasında paylaşım kapalıysa raporunuzu incelemek için yeterli bilgimiz olmayabilir. Varsayılan olarak kapalıdır ve gizlilik ayarlarından değiştirilebilir. + Kapalı kalsın + Tanılama verileri paylaşılsın mı? + En son derleme denetleniyor… + Google Play denetleniyor… + Bu oturum denetleniyor… + Denetimler + Ayarlar > Gelişmiş > Hata Ayıklama Günlükleri bölümünde bulunan aynı zaman damgalı günlük otomatik olarak eklenir. Başka dosya eklenmez. + Verileriniz satılmaz ve yalnızca hataları incelemek ve düzeltmek için kullanılır. + Otomatik günlük, yüklemeden önce hesap adlarını, kimlik bilgilerini, oturum kimliklerini ve ağ adreslerini kaldırır. Ham cihaz kimliği gönderilmez. + Neler toplanıyor? + Yazdığınız başlık ve açıklama tam olarak yazdığınız gibi gönderilir; bu yüzden kişisel veya hassas bilgi eklemeyin. + PrintedWaste ve OpenNOW geliştiricileri rapor metnini, uygulama sürümünü/derlemesini, cihaz modelini, Android sürümünü, sağlayıcı ve üyelik kategorisini, geçerli oyunu, yayın durumunu/ayarlarını, kötüye kullanımı önlemeye yönelik takma adlı bir kurulum tanımlayıcısını ve temizlenmiş bir tanılama günlüğünü görebilir. + Bu rapor ve ekli temizlenmiş tanılama verileri PrintedWaste API’sine gönderilsin mi? + Hata raporu gönderilsin mi? + Bu raporun gönderilmesine izin veriyorum. + Nelerin yükleneceğini anlıyorum ve bunun PrintedWaste API’sine gönderilmesine izin veriyorum. + Hatayı İngilizce açıklayın. Oturum tanılama verileri eklenir. + Ne oldu? + Ne yapıyordunuz, ne ters gitti ve bunu tekrarlayabiliyor musunuz? + İngilizce gerekli + Rapor göndermeden önce OpenNOW’u veya cihaz dilini İngilizce yapın. + Oyununuzdan çıkmadan sorunu açıklayın. + OpenNOW’un olası bir neden bulduğunu anlıyorum. Yine de gönder; ileride rapor gönderme erişimimi kaybedebilirim. + EŞLEŞEN ÖNERİLER + Bu denetim için alakasız çözümler önerilmiyor. + Bu cihazdan ve bu oturumdan canlı denetimler + Rapor göndermeden önce + Sürüm denetimini yeniden dene + Gözden geçir ve gönder + Bir tane daha gönder + Yine de gönder + Gönderiliyor… + Hata raporu gönderildi + Eşleşen bir öneriden sonra da oluyor mu? Devam edin, ölçülen kanıtlar otomatik olarak eklenecek. + Sorun başlığı + Yeniden bağlandıktan sonra yayın dondu + Google Play’de güncelle + Raporu yükle + Hata raporu yüklensin mi? + Rapor yükleniyor… + OpenNOW için İngilizce kullan + PrintedWaste kuyrukları ve gecikme denetleniyor + Açıklama + Filtreler + Ücretsiz seviye kuyruk yönlendirmesi + Ekran görüntüleri + Kumandanın Geri düğmesi + Ayrıntılar + Cihaz, hesap türü, yayın profili, geçerli durum ve geçici bağlantı adresi panoya kopyalandı. + Tanılama verileri kopyalandı + OpenNOW, yüklemeden önce belirteçleri, hesap tanımlayıcılarını, e-posta adreslerini, oturum kimliklerini ve ağ adreslerini kaldıracak. + Rastgele oluşturulan bağlantı listelenmez ama şifrelenmez ve servis yüklemeleri 24 saat içinde siler. + Geçici tanılama bağlantısı oluşturulsun mu? + Hassas değerler kaldırılıyor ve geçici bir bağlantı oluşturuluyor… + Tanılama hazırlanıyor + QR kodu oluşturulamadı. Bu pencereyi kapatıp yeniden deneyin. + Bu QR kodunu telefonunuzla tarayın. Temizlenmiş bağlantı 24 saat içinde sona erer. + Tanılama bağlantısını tara + Temizle ve yükle + Kullanılabilir tarayıcı yok + Mağaza sayfası açılamadı + Mağaza bağlantısı başlatılamadı + Mağaza bağlantısı kesilemedi + Erişim belirteci + Günlükler dışa aktarılamadı + Günlükler dışa aktarıldı + EŞLEŞTİRME KODU + Android için yerel GeForce NOW istemcisi + Bir NVIDIA erişim belirteci veya belirteç yanıtı JSON’u yapıştırın. OpenNOW, hesabı kaydetmeden önce erişim belirtecini doğrular. + Belirteçle oturum aç + Yalnızca size ait bir hesabın kimlik bilgilerini kullanın. + Tarayıcı olmadan oturum açmak için bir belirteç kullanın veya oturum açmadan önce tanılama verilerini dışa aktarın. + Oturum açma araçları + Kodla oturum açmayı kullan + Reklam + Canlı sıra + Kuyruk + GERİ + Bildir + Bir hatayla mı karşılaştınız? + Sunulan profil + Bu kısa bir oturumdu, bu yüzden puan normalden daha çok değişebilir. + Oturum raporu + Şimdi ne yapmalı + Profil neden değişti + Bu ayarlar algılanan önerinin üzerinde + Arka plan etkinliği + Optimize edildi (arka planda zaman aşımına uğrayabilir) + Sınırsız (arka planda izinli) + Android’in pil optimizasyonu uygulamanın arka plan etkinliğini kısıtlar; bu da uygulama küçültüldüğünde bağlantı zaman aşımlarına yol açabilir veya GFN kuyruk ilerlemesini durdurabilir. + Önbelleğe alınmış mağaza, kitaplık ve arama sonuçları kaldırılacak. Hesabınız ve ayarlarınız değişmez. + Oyun önbelleği temizlensin mi? + Bağlantı yardımı + Başlatma durumunu, kuyruk durumunu, yayın güncellemelerini, kurtarma olaylarını, ayarları, kodek yeteneklerini ve son temizlenmiş CloudMatch JSON yanıtlarını dışa aktarır. + Geliştirici + Bu hesap için etkin bir kalıcı depolama eklentisi yok. + Sürüm notları + Hesaplar, ayarlar, önbelleğe alınmış oyunlar, öğretici durumu ve yerel uygulama dosyaları kaldırılacak. OpenNOW yeni kurulmuş gibi yeniden başlayacak. + «Öğreticiyi sıfırla» yalnızca yayın kılavuzunun yeniden görünmesini sağlar. «Ayarları sıfırla» yıkıcıdır: yerel uygulama verilerini siler ve OpenNOW’u yeniden başlatır. + Ayarlar ve uygulama verileri sıfırlansın mı? + Yeni hesap için kullanılacak GeForce NOW sağlayıcısını seçin. + Depolama kullanımı + Mağaza bağlantıları + Listelenmeyen geçici bir bağlantı oluşturulmadan önce hassas değerler kaldırılır. Paylaşmak için QR kodunu telefonunuzla tarayın. + Günlükleri yükle ve QR göster + Bu, profil değişikliği özetini ve olası nedeni inceleyebilmeleri için PrintedWaste ve OpenNOW geliştiricilerine gönderir. + Yayın tanılama verileri gönderilsin mi? + Tanılama gönder + OpenNOW’a şu anda bağlı bir yerel yayın yok. + Yayın profili değişti + Neden oldu + Rapor ve temizlenmiş tanılama verileri gönderiliyor… + Kayıtlı yayın ayarlarınız değiştirilmedi. + Bulut oturumu zaten etkin + Sonlandır ve yenisini başlat + Yayın metnini yazın veya düzenleyin + Oyun önbelleği zaten boştu + Oyun önbelleği temizlendi + Uygulama verileri siliniyor ve OpenNOW yeniden başlatılıyor + Telefondan güvenli şekilde oturum açıldı + Mağaza bağlantısı kesildi + Öğretici bir sonraki yayında gösterilecek + Sürükle + + + %1$d sunucu + %1$d sunucu + + diff --git a/android/app/src/main/res/values-v26/styles.xml b/android/app/src/main/res/values-v26/styles.xml new file mode 100644 index 000000000..13a041f11 --- /dev/null +++ b/android/app/src/main/res/values-v26/styles.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 000000000..fb057fe66 --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,9 @@ + + + + #FF090B0D + #FF6AF0A0 + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 000000000..903cbcd42 --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,941 @@ + + + Open account profile + Username + Tier + Email + Account options + %1$s • %2$s + Not available + OpenNOW + Starting OpenNOW + Sign in with %1$s + Sign in on another device with %1$s + Use this code to sign in + %1$s + Waiting for sign-in + Code expires in %1$d:%2$02d + Starting phone pairing… + Pair with the OpenNOW phone app + Install and open OpenNOW on your Android phone first. Connect the phone and TV to the same Wi-Fi, then scan this QR with the phone camera. Android opens the pairing link in OpenNOW; it expires after five minutes. + TV pairing + Pair with a TV + Keep this phone and the TV on the same Wi-Fi. Scan the TV’s QR code here, or find the TV and enter its 4-digit code. + Connected to %1$s. Games now show a Play on TV action. + Connected to %1$s + Sign in TV + Forget TV + Scan a QR code or find a TV on your network + Scan TV QR + Could not open the QR scanner + Find TV + Finding… + No OpenNOW TV was found on this network. + TV code + Enter the 4-digit code shown on this TV. + Pair + Accounts and services + Profiles, membership, storage, and game stores + Store + Search + Library + Settings + Search games + Search settings + General + Updates, privacy, and app data + Language + App language + System default + English + العربية + Deutsch + Español + Français + 日本語 + 한국어 + Nederlands + Polski + Português + Română + Русский + Türkçe + 简体中文 + Stream + Resolution, FPS, codec, HDR, proxy + Input + Microphone, mouse, keyboard, touch controls, rumble + Interface + Appearance, library, status bar, and sounds + Account + Sign-in, TV pairing, storage, connected stores + Advanced + Advanced options, experiments, diagnostics, and logs + About + Developer options + Reset flows, inspect the runtime, and rebuild local state + + + For development and support + These actions only reset OpenNOW\'s own local state and show information already in the diagnostics export. Destructive ones ask first. You can hide this page again from the bottom of the list. + Flows and prompts + Catalogue and stores + Stream + Interface + Diagnostics + Destructive + Reset + Clear + Run + Apply + Copy + Replay + Hide + Replay first launch + Setup, guides, prompts, consent, and browsing state at once + Setup will run again, every one-time prompt comes back, analytics consent is withdrawn until you answer it again, and Store and Library ordering resets. Accounts, favourites, and stream settings are untouched. + Run setup again + Shows the first-run screens on the next launch + Show the stream guide again + Reappears the next time a stream starts + Show the controller prompt again + Reappears the next time a controller connects + Ask for analytics consent again + Opts out until the question is answered again + Replay upgrade migrations + Reapplies the one-time presentation and TV layout defaults + Clear the game cache + Drops cached Store, Library, and search results + Refetch the catalogue + Reloads Store and Library from the provider now + Reset browsing state + Store and Library ordering and filters back to defaults + Forget launcher choices + Every remembered per-game store selection + Clear favourites + %1$d saved + Every favourited game is removed. This cannot be undone. + Clear the app shelf + %1$d installed apps pinned + Apply the measured recommendation + Reset the touch layout + Overlay size, opacity, and every button offset + Refresh server queues + Repolls the PrintedWaste zone list and pings + Reset the interface + Accent, backdrop, card layout, and animation defaults + Copy the diagnostics log + Redacted, the same text the bug reporter attaches + Copy the runtime summary + The table above, as text + Check for an update + Runs the update check immediately + Sign out of every account + %1$d saved + Every saved account is removed from this device and OpenNOW returns to the sign-in screen. + Wipe app data and relaunch + Returns OpenNOW to a fresh install + Accounts, settings, cached games, and local files are deleted, and OpenNOW relaunches like a fresh install. This cannot be undone. + Hide developer options + Tap the build number in About ten times to bring it back + Build + Variant + Device + Android + Layout profile + Membership + Provider + Stream profile + Hardware decoders + Store / Library games + None + Signed out + Android TV + Handheld + First-launch state restored + Setup will run on the next launch + The stream guide will show again + The controller prompt will show again + Analytics consent will be asked again + Upgrade migrations will replay + Browsing state reset + Launcher choices forgotten + Favourites cleared + App shelf cleared + Applied the measured recommendation + Touch layout reset + Interface reset + Diagnostics log copied + Runtime summary copied + Developer options hidden + %1$d more taps to show developer options + Developer options are now in Settings + Developer options are already shown + Version, credits, and support + Show game titles + Clear search + Voice search + %1$d games + No games loaded + No library games match + Clear search to show all games in your library. + Clear filters to show all games in your library. + Clear search or filters to show all games in your library. + No store games match + Clear search to show more games. + Clear filters to show more games. + Clear search or filters to show more games. + Jump back in + Featured games + Latest additions to GeForce NOW + Continue playing + In queue + Favorites + Recommendations + Results + See all + Play + Continue + Resume + Save + Saved + Favorite + Unfavorite + Cancel + Minimize + View + Play on TV + Starting stream + Queue position %1$d + Waiting for a rig + Connecting stream + Resuming session + Setting up rig + Starting session + Queue status + %1$s is ready to play! + Your GFN queue is done. Tap to return to the app. + Streaming continues while the screen is off + Not owned + Unknown publisher + Resume cloud session + App %1$s + Queue %1$d + Starting + No description is available for this game yet. + Next + Retry + Launch + Skip + Refresh + Send + OK + Undo + Install + Manage + Allow + Active + Ready + Checking + Best available route + Ahead + Wait + Session timer + Clear cache + Reset tutorial + Reset settings + Reset and relaunch + Switch + Add account + Sign out + Sign out all accounts + Choose provider + Play time stats + Cloud storage + Add storage + Change storage location + No active stream + Back to library + End cloud session + Step %1$d of %2$d + Press Done + Controller detected + The on-screen controller was hidden because a physical controller is connected. + Keyboard or mouse detected + You’re using native touch. Switch to keyboard and mouse for this stream? + Keyboard and mouse disconnected + You’re using keyboard and mouse. Switch back to native touch for this stream? + Switch to keyboard & mouse + Stay on native touch + Switch to native touch + Keep keyboard & mouse + Don\'t show again + Hide New games added in landscape + Hide this landscape hero? + New games added will stay hidden when this device is horizontal. You can turn it back on in Interface settings. + + + On + Off + Visible + Hidden + Back + Open + Reset + Close + + + Stream Controls + Exit + Done + Open keyboard sender + Display + Input + Support + Controller + Touch Layout + Audio + Muted + Status bar + %1$s · %2$d items + Stream sharpening + Sharpness amount + Stretch to fit + Live + %1$d Mbps active in this session + Settings › Stream only applies to the next session + Microphone + Permission required + Steam Menu + Send Home to the streamed PC + Esc + Enter + + @string/dev_action_clear + Clear stream text? + This clears the focused text field in the streamed game. + Never, ever ask me again + Controller mouse + Right stick · A click · B right-click + Finger mouse + Direct click + Touch controller + Built-in game touch detected + This game supports built-in touch controls. You can still turn on OpenNOW’s touch controller below if you prefer. + This game supports built-in touch controls. OpenNOW’s touch controller is enabled for this session. + Built-in active + Joysticks + Fixed + Dynamic + Next + Unavailable + Vibration + Controller rumble when available; device haptics otherwise + Rumble output + Some handhelds report a rumble motor that is not wired to anything. Force Phone if in-game rumble stays silent. Force Controller to use standard rumble on PlayStation controllers; games will show Xbox prompts while forced. + Auto + Controller + Phone + + Phone rumble fallback + Controller mouse mode + L stick moves · R stick scrolls · A clicks · B right-clicks + Mouse mode + Configure mouse emulation + Touch controls + Controller layout, joysticks, and rumble + Report a problem + Run checks and send redacted diagnostics + Drag edit mode + Reset touch layout + Reset positions to default + Layout scale + Button size + Opacity + Edge padding + Bottom padding + Left position + Right position + + + Joysticks + Tune the touch analog controls + Dynamic placement + Starts centered beneath your thumb + Uses the saved fixed center + Touch aiming + Lock Joystick + Lock Zone / Touch Aim + Drag anywhere inside the right-side zone for fast right-stick controller aiming. + AIM ZONE + Aim zone scale + Aim zone sensitivity + Stick size + Dead zone + Dynamic mode keeps the saved stick area, but treats wherever your thumb first lands as neutral. This avoids sudden movement when you miss the exact center. + + + Status bar + Choose its layout and information + Appearance + Position + Items + FPS + Ping + Bitrate + Battery + Connection + Resolution + Codec + Server + Dec / Jit + Loss + Keyboard + + + %1$d/100 + %1$s + Don\'t show session reports again + Connection + Not measured + Latency + Stream speed + Packet loss + Jitter + Frame rate + Decode + %1$d ms avg + %1$d ms peak + %1$s peak + Stable + May affect clarity + Timing variation + Average / target FPS + Per video frame + + + Session Control + Exit Stream? + Do you really want to exit %1$s? + Your current cloud gaming session will be closed. + Keep Playing + Exit Stream + + + Bug reporter + Report a bug + Report a stream bug + Send an issue and redacted diagnostics + Discord community + Get help and follow up on bug reports with the OpenNOW community. + Community support and bug-report follow-up + Join + Discord invite copied + + + Show description + Hide description + + + Ping %1$s + Dec %1$sms + Jit %1$sms + Loss %1$s%% + %1$d frames per second + Ping %1$d milliseconds + Decode time %1$s milliseconds per frame + Jitter %1$s milliseconds + Packet loss %1$s percent + good + fair + poor + + Dismiss + Play on %1$s + Clear filters + Sort and filter + Sort and filter, %1$d active + Sort + Popular + New games added + Last played + Filters + Controls + Mobile touch controls + Back to top + Auto + Coming soon + Choose launcher + Launchers + Default + Selected + Available launcher + Don\'t ask me again - make this the default store + Proceeding with default store: %1$s + Tip: long press Play to choose a different store later. + Long press Play to choose a store + Stream + Interface + Quality + Video + Connection + Audio and keyboard + Keyboard layout + Game language + Clipboard paste + Pointer input + Mouse lock + Keep an external mouse captured inside the game while streaming. Opening Stream Controls releases it. + Controller and touch + Appearance + Library and navigation + Status bar + Sounds and sessions + Show session report + Show a quality summary after each stream. + Advanced tools + Thanks + Resolution + The number of pixels sent by the cloud PC. Higher resolutions look sharper but require more decoder, GPU, and network capacity. + Aspect ratio + Matches the stream shape to the display. A mismatched ratio can add black bars or stretching; it does not make the decoder faster. + Stream preset + Recommended uses this device\'s display, memory, processor count, Android profile, and verified WebRTC hardware decoders. Custom keeps your manual choices. + Detected Recommended: %1$s + Recommended + Custom + Low (data saver) + Medium + High + FPS + Frames per second controls motion smoothness. Higher FPS gives the decoder less time per frame and can cause stutter on slower hardware. + Bitrate Mbps + The maximum video data rate. Higher bitrate can improve detail, but only when the connection has enough stable capacity; it does not increase FPS. + Codec + H.264 is the most compatible. H.265 uses bandwidth more efficiently and is preferred for high resolution when a verified hardware decoder exists. AV1 is 8-bit here and is used only on devices with a compatible hardware path. + Color + 8-bit 4:2:0 is the lightest and most compatible. 10-bit improves gradients but adds decoder and bandwidth requirements. Android normalizes unsupported combinations automatically. + H.264/H.265 only + AV1 uses 8-bit color. Choose H.265 for 10-bit; HDR is limited to compatible Android TV modes. + AV1 uses 8-bit color in OpenNOW. Switched to 8-bit and disabled HDR. Choose H.265 or H.264 to use 10-bit. + HDR (Performance & Ultimate) + HDR needs a compatible Android TV display, H.265, 10-bit video, and a supported membership. It adds processing load and is not recommended for lag troubleshooting. + HDR streaming is not available on Android handhelds. 10-bit SDR remains available with H.265. + HDR on Android TV requires H.265 at 60 FPS or lower and a resolution up to 3840 x 2160. + Region + Session proxy + Routes GFN session creation and queue polling through this proxy. Leave off for direct requests. + Proxy URL + Copy codec diagnostic + Copied codec diagnostic + Codec probe has not run yet. + Enable session proxy? + GFN session creation, queue polling, resume, stop, and queue ad update requests will be routed through the proxy you enter. + A bad or blocked proxy can break launch, queue progress, active session resume, or session cleanup. + Only use a proxy you trust. The proxy operator may be able to observe request timing, destination hosts, and sensitive session traffic metadata. + Enable proxy + Experimental streaming + May cause session launch failures. + L4S + Requests NVIDIA\'s low-latency, low-loss transport path when the server and network support it. Leave off if your network gets unstable. + Microphone + Sends your default Android microphone to the streamed game. You can mute it from Stream Controls. + Microphone permission was not granted. OpenNOW will keep microphone streaming off. + Use system colors + Accent + Add my own apps + Show a Library shelf where you can add, launch, and remove installed Android apps and games. + Make this your default launcher + Open Android\'s launcher chooser so OpenNOW can become the Home screen. You can change this again in Android settings. + Choose launcher + OpenNOW is the default + Manage + My own apps + Add app + Choose an app + Loading installed apps… + Show my own apps, %1$d installed + Hide my own apps, %1$d installed + No other launchable apps were found. + Remove %1$s + This only removes the shortcut from your shelf. The app stays installed. + Remove + Featured + Touch controller skin + Each skin is a different controller, not a different colour: the cut of the buttons, whether the d-pad is one cross or four separate keys, and what the stick travels inside all change with it. + Touch controller colour + Recolours the skins built around an accent. Classic, Outline, Frost and High contrast are monochrome by design. + Button letters + Off leaves the caps blank once the layout is muscle memory. + Skin + Face button size + D-pad size + Trigger and bumper size + Menu and stick-click size + Left stick size + Right stick size + Stick cap size + Visible controls + Hide any built-in cluster you do not need. Hidden controls stop consuming touches. + Face buttons + D-pad + Left stick + Right stick or aim zone + Triggers and bumpers + L3 and R3 + Start and Select + Programmable buttons + Add up to four movable buttons. Each can duplicate any gamepad action for a layout that fits your hands. + Extra button %1$d + Programmable button size + Gyroscope aiming + Tilt the phone for relative mouse-look aiming. + This device does not report a gyroscope. + Gyroscope sensitivity + Gyroscope dead zone + Gyroscope smoothing + Invert gyroscope horizontal + Invert gyroscope vertical + Motion aiming + Library featured banner + Rotating banner above the Library grid on portrait phones. + New games hero in landscape + Show the New games added hero when this device is horizontal. + This app is no longer installed or cannot be opened. + Library order + Recently played + Title A–Z + Launch page + Store + Library + Disable update checking + Advanced options + Shows experimental catalog and tuning options. The Advanced diagnostics tab stays available. + Expressive card styling + Uses brighter card surfaces and softer corners. Turn it off for a flatter, quieter Material style. + Add games border + Show a bold static outline around game artwork. This can be turned off without disabling border effects. + Enable effects on borders + Animate the outer border effect independently from the static game outline. + Effects Bonanza everywhere + Extend the animated border effect to focused and hovered artwork, descriptions, controls, menus, and more. + Catalog background + Shows a background image behind Store and Library on handheld screens. + Background image + Custom image + Built-in background + Colorful abstract (default) + Original OpenNOW + Absolute Cinema + Choose image + Use default + Screen edge padding + Compact game cards + Show favorite icon on game cards + Shows a favorite button on mobile, handheld, and TV game cards. + Game card size + Changes card size across Store, Continue playing, and Library. + Hide stream buttons + On-screen keyboard button + Shows a compact keyboard icon in the stream status bar. + Show status bar by default + Stats overlay position + Hide server selector + Button press tones + Plays a short UI tone for controller navigation and on-screen control presses. + Play intro music + Intro music starts + Muted + Playing + Play music when queue finishes + Mute music + Stretch stream to fill + Off by default to preserve exact geometry. Turn this on to fill the display by stretching the picture on the mismatched axis only — never by cropping it. + Applies an extra GPU filter after decoding. It can improve perceived detail but may add rendering load on slower devices. + Controls the strength of the post-processing sharpening filter. It does not change the source stream resolution. + Smart session timer + Thanks to the people helping improve OpenNOW for everyone. + DarkevilPT + Community support + Donate + Donate link copied + OpenNOW + Pixel blue + Hot pink + Lime + Coral + Violet + Absolute Cinema + Switch + Native streamer (Experimental) + Intercepts the hardware decoder to inject low-latency vendor properties. May be unstable. + Request Cloud G-Sync / VRR + Requests variable refresh timing when the device, display, plan, and GFN session support it. + Show store labels + Native touch Auto uses gamepad mode for high-resolution or high-FPS streams so the selected stream mode is preserved. Choose Every game to prioritize native touch instead. + + Get started + Next + Back + Skip + Finish + Native GeForce NOW for Android + + Make it yours + Everything here applies as you pick it. + Accent + Interface animations + Shimmer, focus glows, and carousel motion. Turning this off also honours the system animation setting. + Preview + Layout + Each one redraws the preview + Game titles under artwork + Off leaves the grid as pure box art. + Square cards + Crops box art to a square so more games fit on screen. + Favourite button on artwork + Saves a game to your Library without opening it. + Rounded corners + Softer card and panel edges throughout the app. + Absolute Cinema + Animated energy frames around whatever is focused. Normally a controller and TV treatment. + Feedback + Both fire on your next tap + Haptics + A short buzz when you select something, and controller rumble in game where the device supports it. + Interface sounds + A tone on button presses and menu navigation. Does not affect game audio. + Background + Off + Default + Nothing + App background + Wallpaper + Your image + + Stream quality + Measured from this device\'s display, chipset, and decoders. + Measuring this device + Recommended + Data saver + 720p, 30 FPS, 12 Mbps + Best quality + Up to %1$s at %2$d FPS on your plan + Set it myself + Choose resolution, codec, frame rate, and bitrate below + %1$s membership + Your plan streams up to %1$s at %2$d FPS. Higher options are listed with the tier that unlocks them. + Upgrading your GeForce NOW membership raises this ceiling — OpenNOW does not cap it. + + During play + Choose the controls and status you want during a stream. + Preview + Drag, tap, and try the fake stream controls. + Try fullscreen + Leave practice + Practice only — drag to move the pointer, then tap or press A to click. + Touch mouse + Direct + Tap where you want to click + Trackpad + Swipe to move, then tap + Off + Use a controller or physical mouse + Tap = move + click + Swipe, then tap + Controller / mouse + Click me + Hits %1$d + Move onto the target first + Menu + Keys + Status line + Input + Stream quality + Status line + Choose exactly what stays visible over the game. + Position + 60 FPS • 24 ms • Wi-Fi + %1$d selected + Choose an item below + FPS 60 + Ping 24ms + 35 Mbps + Battery 82%% + Wi-Fi + 1920×1080 + HEVC + SEA + Dec 5.2 · Jit 1.4 + Loss 0.00%% + + When something breaks + Stutter, black screens, dead controllers. + The built-in bug reporter + Open it from the in-stream controls, or from the report offered after a session ends. + It checks your settings against the session first and flags known-bad combinations, usually with a fix. + It sends your description plus redacted diagnostics — settings, decoder and network measurements, device model. No account details. + Session report after each stream + Latency, frame pacing, and packet loss when a session ends, with a shortcut into the bug reporter. + Share anonymous diagnostics + Helps find patterns across crashes and performance problems. Sensitive data is removed and nothing is sold. With this off, a crash report may not carry enough to investigate. + + Done + Change any of it in Settings. + Your choices + Stream quality + Touch mouse + Status line + On + Off + + Setup + Run setup again + Revisit appearance, stream quality, play controls, status, and bug reporting + + + %1$d server + %1$d servers + + + Needs %1$s + GeForce NOW lists %1$s as %2$s only, and this account is on %3$s. The session will most likely be refused or dropped to a lower profile. + Entitlement is decided by GeForce NOW, not by OpenNOW, and the catalogue is sometimes out of date — so you can still try. + Try anyway + + + Copy error + Disconnect + Go back + Sign in + Share analytics + Share anonymous diagnostics to help us find patterns in bugs, crashes, and performance problems. Sensitive data is removed, and we do not sell your data. + If sharing is off during a crash, we may not have enough information to investigate your report. It is off by default and can be changed in Privacy settings. + Keep off + Share diagnostics? + Checking latest build… + Checking Google Play… + Checking this session… + Checks + The same timestamped log available from Settings > Advanced > Debug Logs is attached automatically. No other files are added. + Your data is not sold and is used only to investigate and fix bugs. + The automatic log removes account names, credentials, session IDs, and network addresses before upload. The raw device ID is not sent. + What is collected? + Your typed title and description are sent exactly as written, so do not include personal or sensitive information. + PrintedWaste and OpenNOW maintainers may view the report text, app version/build, device model, Android version, provider and membership category, current game, stream status/settings, a pseudonymous installation identifier for abuse prevention, and a redacted diagnostic log. + Send this report and the attached redacted diagnostics to PrintedWaste API? + Send bug report? + I consent to send this report. + I understand what will be uploaded and consent to send it to the PrintedWaste API. + Describe the bug in English. Session diagnostics are attached. + What happened? + What were you doing, what went wrong, and can you reproduce it? + English language required + Set either OpenNOW or the device language to English before reporting. + Describe the problem without leaving your game. + I understand OpenNOW found a likely cause. Send anyway; I may lose future reporting access. + MATCHED SUGGESTIONS + No irrelevant fixes are being suggested for this check. + Live checks from this device and session + Before you report + Retry version check + Review & send + Send another + Send anyway + Sending… + Bug report sent + Report ID + Copy report ID + Report ID copied + Still happening after any matched suggestion? Continue and the measured evidence will be attached automatically. + Issue title + Stream froze after reconnecting + Update in Google Play + Upload report + Upload bug report? + Uploading report… + Use English for OpenNOW + Checking PrintedWaste queues and latency + Description + Filters + Free tier queue routing + Screenshots + Remote Back button + Details + Device, account type, stream profile, current status, and the temporary paste URL were copied to the clipboard. + Diagnostics copied + OpenNOW will remove tokens, account identifiers, email addresses, session IDs, and network addresses before uploading. + The randomized link is unlisted but not encrypted, and the paste service deletes uploads within 24 hours. + Create temporary diagnostics paste? + Removing sensitive values and creating a temporary paste… + Preparing diagnostics + Could not create the QR code. Close this dialog and try again. + Scan this QR code on your phone. The sanitized paste expires within 24 hours. + Scan diagnostics link + Sanitize and upload + No browser available + Couldn\'t open store page + Failed to start store connection + Failed to disconnect store + Access token + Could not export logs + Logs exported + PAIRING CODE + Native Android GeForce NOW client + Paste an NVIDIA access token or token-response JSON. OpenNOW verifies the access token before saving the account. + Sign in with token + Only use credentials for an account you control. + Use a token to sign in without the browser, or export diagnostics before signing in. + Sign-in tools + Use code sign-in + Advertisement + Live position + Queue + BACK + Report it + Experienced a bug? + Delivered profile + This was a short session, so the score may vary more than usual. + Session report + What to do next + Why the profile changed + These settings are above the detected recommendation + Background activity + Optimized (May timeout in background) + Unlimited (Allowed in background) + Android battery optimization restricts the app\'s background activity, which can cause connection timeouts or pause GFN queue progress when the app is minimized. + Cached store, library, and search results will be removed. Your account and settings stay unchanged. + Clear game cache? + Connection help + Exports launch state, queue state, stream updates, recovery events, settings, codec capabilities, and recent sanitized CloudMatch JSON responses. + Developer + No persistent storage add-on is active for this account. + Release notes + Accounts, settings, cached games, tutorial state, and local app files will be removed. OpenNOW will relaunch like a fresh install. + Reset tutorial only makes the stream guide appear again. Reset settings is destructive: it clears local app data and relaunches OpenNOW. + Reset settings and app data? + Select the GeForce NOW provider to use for the new account. + Storage usage + Game store connections + Sensitive values are removed before an unlisted, temporary paste is created. Scan the QR code with your phone to share it. + Upload logs and show QR + This sends the profile-change summary and likely cause to PrintedWaste and OpenNOW maintainers so they can investigate it. + Send stream diagnostics? + Send diagnostics + OpenNOW does not have a local stream attached right now. + Stream profile changed + Why it happened + Sending the report and redacted diagnostics… + Your saved stream settings were not changed. + Cloud session already active + Terminate and start new + Type or edit stream text + Game cache was already clear + Cleared game cache + Clearing app data and relaunching OpenNOW + Signed in securely from phone + Store disconnected + Tutorial will show on the next stream + Drag + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 000000000..660fa422b --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,26 @@ + + + + + + + + diff --git a/android/app/src/main/res/xml/locale_config.xml b/android/app/src/main/res/xml/locale_config.xml new file mode 100644 index 000000000..d74993266 --- /dev/null +++ b/android/app/src/main/res/xml/locale_config.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/xml/network_security_config.xml b/android/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 000000000..3c611eb3f --- /dev/null +++ b/android/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,13 @@ + + + + + + + + + + 127.0.0.1 + localhost + + diff --git a/android/app/src/main/res/xml/update_file_paths.xml b/android/app/src/main/res/xml/update_file_paths.xml new file mode 100644 index 000000000..6655b88f6 --- /dev/null +++ b/android/app/src/main/res/xml/update_file_paths.xml @@ -0,0 +1,6 @@ + + + + diff --git a/android/app/src/playBundle/AndroidManifest.xml b/android/app/src/playBundle/AndroidManifest.xml new file mode 100644 index 000000000..6d64f732c --- /dev/null +++ b/android/app/src/playBundle/AndroidManifest.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/android/app/src/sideload/AndroidManifest.xml b/android/app/src/sideload/AndroidManifest.xml new file mode 100644 index 000000000..fd0cbb365 --- /dev/null +++ b/android/app/src/sideload/AndroidManifest.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/AndroidAuthRefreshTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/AndroidAuthRefreshTest.kt new file mode 100644 index 000000000..149548559 --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/AndroidAuthRefreshTest.kt @@ -0,0 +1,134 @@ +package com.opencloudgaming.opennow + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +class AndroidAuthRefreshTest { + @Test + fun legacySessionTriesBothKnownAuthClients() { + assertEquals( + listOf("browser-client", "device-client"), + authenticationRefreshClientIds( + savedClientId = null, + browserClientId = "browser-client", + deviceClientId = "device-client", + ), + ) + } + + @Test + fun savedAuthClientIsTriedFirstWithoutDuplicates() { + assertEquals( + listOf("device-client", "browser-client"), + authenticationRefreshClientIds( + savedClientId = "device-client", + browserClientId = "browser-client", + deviceClientId = "device-client", + ), + ) + } + + @Test + fun freshAccessAndClientTokensDoNotNeedBackgroundRefresh() { + val now = 1_000_000L + val tokens = tokens( + expiresAt = now + TOKEN_REFRESH_WINDOW_MS + 1L, + clientTokenExpiresAt = now + CLIENT_TOKEN_REFRESH_WINDOW_MS + 1L, + ) + + assertFalse(tokens.needsBackgroundRefresh(now)) + } + + @Test + fun accessTokenInsideRefreshWindowNeedsBackgroundRefresh() { + val now = 1_000_000L + val tokens = tokens( + expiresAt = now + TOKEN_REFRESH_WINDOW_MS - 1L, + clientTokenExpiresAt = now + CLIENT_TOKEN_REFRESH_WINDOW_MS + 1L, + ) + + assertTrue(tokens.needsBackgroundRefresh(now)) + } + + @Test + fun missingOrExpiringClientTokenNeedsBackgroundRefresh() { + val now = 1_000_000L + val missingClientToken = tokens( + expiresAt = now + TOKEN_REFRESH_WINDOW_MS + 1L, + clientToken = null, + clientTokenExpiresAt = null, + ) + val expiringClientToken = tokens( + expiresAt = now + TOKEN_REFRESH_WINDOW_MS + 1L, + clientTokenExpiresAt = now + CLIENT_TOKEN_REFRESH_WINDOW_MS - 1L, + ) + + assertTrue(missingClientToken.needsBackgroundRefresh(now)) + assertTrue(expiringClientToken.needsBackgroundRefresh(now)) + } + + @Test + fun bestEffortRefreshFallsBackWhenRefreshFails() = runBlocking { + val fallback = session("fallback-token") + var reported: Throwable? = null + + val result = refreshedSessionOrFallback( + fallback = fallback, + refresh = { error("Token refresh failed") }, + onFailure = { reported = it }, + ) + + assertSame(fallback, result) + assertEquals("Token refresh failed", reported?.message) + } + + @Test + fun bestEffortRefreshStillPropagatesCancellation() = runBlocking { + val cancellation = CancellationException("cancelled") + + val thrown = runCatching { + refreshedSessionOrFallback( + fallback = session("fallback-token"), + refresh = { throw cancellation }, + ) + }.exceptionOrNull() + + assertSame(cancellation, thrown) + } + + private fun session(accessToken: String): AuthSession = AuthSession( + provider = LoginProvider( + idpId = "test-idp", + code = "TEST", + displayName = "Test", + streamingServiceUrl = "https://example.invalid", + ), + tokens = AuthTokens( + accessToken = accessToken, + expiresAt = 0L, + ), + user = AuthUser( + userId = "test-user", + displayName = "Test user", + membershipTier = "FREE", + ), + ) + + private fun tokens( + expiresAt: Long, + clientToken: String? = "client-token", + clientTokenExpiresAt: Long?, + ): AuthTokens = AuthTokens( + accessToken = "access-token", + refreshToken = "refresh-token", + idToken = "id-token", + expiresAt = expiresAt, + clientToken = clientToken, + clientTokenExpiresAt = clientTokenExpiresAt, + ) +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/AndroidDeveloperOptionsTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/AndroidDeveloperOptionsTest.kt new file mode 100644 index 000000000..f41713c60 --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/AndroidDeveloperOptionsTest.kt @@ -0,0 +1,137 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class AndroidDeveloperOptionsTest { + + @Test + fun buildNumberTapsStaySilentUntilTheCountdownBegins() { + (1 until DEVELOPER_OPTIONS_TAP_COUNTDOWN_FROM).forEach { tap -> + assertEquals( + "tap $tap", + DeveloperOptionsTapResult.Silent, + developerOptionsTapResult(tapCount = tap, alreadyUnlocked = false), + ) + } + } + + @Test + fun buildNumberTapsCountDownThenUnlock() { + assertEquals( + DeveloperOptionsTapResult.Countdown(remaining = 3), + developerOptionsTapResult(tapCount = 7, alreadyUnlocked = false), + ) + assertEquals( + DeveloperOptionsTapResult.Countdown(remaining = 1), + developerOptionsTapResult(tapCount = 9, alreadyUnlocked = false), + ) + assertEquals( + DeveloperOptionsTapResult.Unlocked, + developerOptionsTapResult(tapCount = DEVELOPER_OPTIONS_TAP_COUNT, alreadyUnlocked = false), + ) + } + + @Test + fun tappingAnAlreadyUnlockedBuildNumberNeverCountsDown() { + assertEquals( + DeveloperOptionsTapResult.AlreadyUnlocked, + developerOptionsTapResult(tapCount = 1, alreadyUnlocked = true), + ) + assertEquals( + DeveloperOptionsTapResult.AlreadyUnlocked, + developerOptionsTapResult(tapCount = 99, alreadyUnlocked = true), + ) + } + + @Test + fun unlockingAndLockingAreInverses() { + val locked = AppSettings() + assertFalse(locked.developerOptionsUnlocked) + val unlocked = locked.unlockingDeveloperOptions() + assertTrue(unlocked.developerOptionsUnlocked) + assertEquals(locked, unlocked.lockingDeveloperOptions()) + } + + @Test + fun replayingFirstLaunchRestoresEveryOneTimePromptWithoutTouchingUserContent() { + val used = AppSettings( + setupFlowCompletedVersion = SETUP_FLOW_VERSION, + androidStreamGuideDismissed = true, + androidPhysicalControllerPromptDismissed = true, + analyticsConsentAsked = true, + analyticsOptOut = false, + streamPresentationProfileVersion = STREAM_PRESENTATION_PROFILE_VERSION, + catalogFilterIds = listOf(CATALOG_FILTER_TOUCHSCREEN), + librarySortId = LIBRARY_SORT_TITLE, + favoriteGameIds = listOf("game-1"), + defaultGameVariantIds = mapOf("game-1" to "variant-1"), + ) + + val replayed = used.replayingFirstLaunch() + + assertEquals(0, replayed.setupFlowCompletedVersion) + assertFalse(replayed.androidStreamGuideDismissed) + assertFalse(replayed.androidPhysicalControllerPromptDismissed) + assertFalse(replayed.analyticsConsentAsked) + assertTrue(replayed.analyticsOptOut) + assertEquals(0, replayed.streamPresentationProfileVersion) + assertEquals(emptyList(), replayed.catalogFilterIds) + assertEquals(AppSettings().librarySortId, replayed.librarySortId) + // Content the user created is not a first-launch prompt and must survive. + assertEquals(listOf("game-1"), replayed.favoriteGameIds) + assertEquals(mapOf("game-1" to "variant-1"), replayed.defaultGameVariantIds) + } + + @Test + fun resettingAnalyticsConsentOptsOutUntilItIsAnsweredAgain() { + val consented = AppSettings(analyticsConsentAsked = true, analyticsOptOut = false) + assertTrue(consented.analyticsSharingEnabled) + + val reset = consented.resettingAnalyticsConsent() + + assertFalse(reset.analyticsConsentAsked) + assertFalse(reset.analyticsSharingEnabled) + } + + @Test + fun resettingInterfaceLeavesAccountAndStreamAlone() { + val customized = AppSettings( + uiAccent = UiAccent.HotPink, + absoluteCinemaEffects = true, + nerdCatalogBackground = true, + nerdCatalogBackgroundUri = "file:///data/wallpaper", + posterSizeScale = MAX_GAME_CARD_SCALE, + stream = StreamSettings(resolution = "3840x2160", fps = 120), + favoriteGameIds = listOf("game-1"), + ) + + val reset = customized.resettingInterface() + + assertEquals(AppSettings().uiAccent, reset.uiAccent) + assertFalse(reset.absoluteCinemaEffects) + assertFalse(reset.nerdCatalogBackground) + assertEquals(null, reset.nerdCatalogBackgroundUri) + assertEquals(AppSettings().posterSizeScale, reset.posterSizeScale) + assertEquals(customized.stream, reset.stream) + assertEquals(listOf("game-1"), reset.favoriteGameIds) + } + + @Test + fun catalogueResetsAreIndependentOfEachOther() { + val settings = AppSettings( + catalogFilterIds = listOf(CATALOG_FILTER_TOUCHSCREEN), + favoriteGameIds = listOf("game-1"), + defaultGameVariantIds = mapOf("game-1" to "variant-1"), + localAppPackageNames = listOf("com.example.app"), + ) + + assertEquals(listOf("game-1"), settings.resettingCatalogBrowsing().favoriteGameIds) + assertEquals(listOf(CATALOG_FILTER_TOUCHSCREEN), settings.clearingFavorites().catalogFilterIds) + assertEquals(emptyMap(), settings.clearingStorePreferences().defaultGameVariantIds) + assertEquals(listOf("game-1"), settings.clearingStorePreferences().favoriteGameIds) + assertEquals(emptyList(), settings.clearingLocalAppShelf().localAppPackageNames) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/AndroidDeviceDiagnosticsTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/AndroidDeviceDiagnosticsTest.kt new file mode 100644 index 000000000..49ff31d80 --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/AndroidDeviceDiagnosticsTest.kt @@ -0,0 +1,61 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class AndroidDeviceDiagnosticsTest { + @Test + fun debugSummaryContainsUsefulNonUniqueSupportContext() { + val summary = snapshot().debugSummary() + + assertTrue(summary.contains("manufacturer=NVIDIA")) + assertTrue(summary.contains("model=SHIELD_Android_TV")) + assertTrue(summary.contains("formFactor=tv")) + assertTrue(summary.contains("release=11")) + assertTrue(summary.contains("sdk=30")) + assertTrue(summary.contains("targetSdk=36")) + assertTrue(summary.contains("securityPatch=2025-04-05")) + assertTrue(summary.contains("abis=arm64-v8a|armeabi-v7a")) + assertTrue(summary.contains("runtimeBits=64")) + assertTrue(summary.contains("memoryMiB=3072")) + assertTrue(summary.contains("pixels=3840x2160")) + assertFalse(summary.contains("serial")) + assertFalse(summary.contains("fingerprint")) + assertFalse(summary.contains("androidId")) + } + + @Test + fun classifiesTvTabletAndPhoneFormFactors() { + assertEquals("tv", androidDeviceFormFactor(androidTv = true, smallestScreenWidthDp = 320)) + assertEquals("tablet", androidDeviceFormFactor(androidTv = false, smallestScreenWidthDp = 600)) + assertEquals("phone", androidDeviceFormFactor(androidTv = false, smallestScreenWidthDp = 599)) + } + + private fun snapshot() = AndroidDeviceDiagnosticsSnapshot( + manufacturer = "NVIDIA", + brand = "NVIDIA", + model = "SHIELD_Android_TV", + deviceCodename = "mdarcy", + product = "mdarcy", + hardware = "darcy", + board = "darcy", + androidRelease = "11", + androidCodename = "REL", + androidSdk = 30, + targetSdk = 36, + securityPatch = "2025-04-05", + supportedAbis = listOf("arm64-v8a", "armeabi-v7a"), + is64BitRuntime = true, + processorCount = 8, + totalMemoryMiB = 3_072, + lowRamDevice = false, + displayWidthPixels = 3840, + displayHeightPixels = 2160, + densityDpi = 320, + smallestScreenWidthDp = 960, + formFactor = "tv", + emulator = false, + ) +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/AndroidLocalNetworkAccessTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/AndroidLocalNetworkAccessTest.kt new file mode 100644 index 000000000..55527185d --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/AndroidLocalNetworkAccessTest.kt @@ -0,0 +1,13 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class AndroidLocalNetworkAccessTest { + @Test + fun localNetworkPermissionStartsAtAndroid17() { + assertFalse(androidLocalNetworkPermissionRequired(36)) + assertTrue(androidLocalNetworkPermissionRequired(37)) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/AndroidQueueAdsTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/AndroidQueueAdsTest.kt new file mode 100644 index 000000000..3176e0ecb --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/AndroidQueueAdsTest.kt @@ -0,0 +1,368 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class AndroidQueueAdsTest { + @Test + fun mergeQueueSessionStatePreservesAdsWhenServerTemporarilyOmitsThem() { + val previous = session( + adState = SessionAdState( + isAdsRequired = true, + sessionAdsRequired = true, + sessionAds = listOf(ad("ad-1")), + ads = listOf(ad("ad-1")), + ), + ) + val next = session( + queuePosition = 4, + adState = SessionAdState( + isAdsRequired = true, + sessionAdsRequired = true, + serverSentEmptyAds = true, + ), + ) + + val merged = mergeQueueSessionState(previous, next) + + assertEquals(4, merged.queuePosition) + assertEquals(listOf("ad-1"), sessionAdItems(merged.adState).map { it.adId }) + } + + @Test + fun mergeQueueSessionStateDoesNotRestoreAdsForReadySession() { + val previous = session( + adState = SessionAdState( + isAdsRequired = true, + sessionAdsRequired = true, + sessionAds = listOf(ad("ad-1")), + ads = listOf(ad("ad-1")), + ), + ) + val ready = session( + status = 2, + adState = SessionAdState( + isAdsRequired = false, + sessionAdsRequired = false, + serverSentEmptyAds = true, + ), + ) + + val merged = mergeQueueSessionState(previous, ready) + + assertEquals(2, merged.status) + assertEquals(emptyList(), sessionAdItems(merged.adState)) + } + + @Test + fun mergeQueueSessionStatePreservesAdsForReadyStatusWithoutStreamEndpoint() { + val previous = session( + adState = SessionAdState( + isAdsRequired = true, + sessionAdsRequired = true, + sessionAds = listOf(ad("ad-1")), + ads = listOf(ad("ad-1")), + ), + ) + val partialReady = session( + status = 2, + serverIp = "", + signalingServer = "", + signalingUrl = "", + adState = SessionAdState( + isAdsRequired = true, + sessionAdsRequired = true, + serverSentEmptyAds = true, + ), + ) + + val merged = mergeQueueSessionState(previous, partialReady) + + assertEquals(2, merged.status) + assertEquals(listOf("ad-1"), sessionAdItems(merged.adState).map { it.adId }) + } + + @Test + fun mergeQueueAdStateDoesNotRestoreAfterExplicitLocalClear() { + val previous = SessionAdState( + isAdsRequired = true, + sessionAdsRequired = true, + sessionAds = listOf(ad("ad-1")), + ads = listOf(ad("ad-1")), + ) + val next = SessionAdState( + isAdsRequired = true, + sessionAdsRequired = true, + serverSentEmptyAds = false, + ) + + val merged = mergeQueueAdState(previous, next) + + assertEquals(emptyList(), sessionAdItems(merged)) + } + + @Test + fun mergeQueueAdStateKeepsMissingAdStateStableDuringQueue() { + val previous = SessionAdState( + isAdsRequired = true, + sessionAdsRequired = true, + sessionAds = listOf(ad("ad-1")), + ads = listOf(ad("ad-1")), + ) + + val merged = mergeQueueAdState(previous, null) + + assertEquals(listOf("ad-1"), sessionAdItems(merged).map { it.adId }) + } + + @Test + fun mergeQueueAdStateCanClearMissingAdStateAfterFinishReport() { + val previous = SessionAdState( + isAdsRequired = true, + sessionAdsRequired = true, + sessionAds = listOf(ad("ad-1")), + ads = listOf(ad("ad-1")), + ) + + val merged = mergeQueueAdState(previous, null, preserveMissingAdState = false) + + assertNull(merged) + } + + @Test + fun mergeQueueAdStateDoesNotRestoreServerEmptyAdsAfterFinishReport() { + val previous = SessionAdState( + isAdsRequired = true, + sessionAdsRequired = true, + sessionAds = listOf(ad("ad-1")), + ads = listOf(ad("ad-1")), + ) + val next = SessionAdState( + isAdsRequired = true, + sessionAdsRequired = true, + serverSentEmptyAds = true, + ) + + val merged = mergeQueueAdState(previous, next, preserveMissingAdState = false) + + assertEquals(emptyList(), sessionAdItems(merged)) + } + + @Test + fun mergeQueueSessionStateDoesNotRestoreServerEmptyAdsAfterFinishReport() { + val previous = session( + adState = SessionAdState( + isAdsRequired = true, + sessionAdsRequired = true, + sessionAds = listOf(ad("ad-1")), + ads = listOf(ad("ad-1")), + ), + ) + val next = session( + queuePosition = 5, + adState = SessionAdState( + isAdsRequired = true, + sessionAdsRequired = true, + serverSentEmptyAds = true, + ), + ) + + val merged = mergeQueueSessionState(previous, next, preserveMissingAdState = false) + + assertEquals(5, merged.queuePosition) + assertEquals(emptyList(), sessionAdItems(merged.adState)) + } + + @Test + fun removeSessionAdItemDropsOnlyCompletedAd() { + val state = SessionAdState( + isAdsRequired = true, + sessionAdsRequired = true, + sessionAds = listOf(ad("ad-1"), ad("ad-2")), + ads = listOf(ad("ad-1"), ad("ad-2")), + ) + + val updated = removeSessionAdItem(state, "ad-1") + + assertEquals(listOf("ad-2"), sessionAdItems(updated).map { it.adId }) + } + + @Test + fun mergeQueueSessionStatePreservesRemainingAdsAfterCompletedAdIsRemoved() { + val previous = session( + adState = SessionAdState( + isAdsRequired = true, + sessionAdsRequired = true, + sessionAds = listOf(ad("ad-1"), ad("ad-2")), + ads = listOf(ad("ad-1"), ad("ad-2")), + ), + ) + val locallyAdvanced = removeSessionAdItem(previous, "ad-1") + val serverOmittedAds = session( + queuePosition = 5, + adState = SessionAdState( + isAdsRequired = true, + sessionAdsRequired = true, + serverSentEmptyAds = true, + ), + ) + + val merged = mergeQueueSessionState(locallyAdvanced, serverOmittedAds) + + assertEquals(5, merged.queuePosition) + assertEquals(listOf("ad-2"), sessionAdItems(merged.adState).map { it.adId }) + } + + @Test + fun mergeQueueSessionStateDoesNotRestoreLastCompletedAdAfterLocalRemoval() { + val previous = session( + adState = SessionAdState( + isAdsRequired = true, + sessionAdsRequired = true, + sessionAds = listOf(ad("ad-1")), + ads = listOf(ad("ad-1")), + ), + ) + val locallyCompleted = removeSessionAdItem(previous, "ad-1") + val serverOmittedAds = session( + queuePosition = 5, + adState = SessionAdState( + isAdsRequired = true, + sessionAdsRequired = true, + serverSentEmptyAds = true, + ), + ) + + val merged = mergeQueueSessionState(locallyCompleted, serverOmittedAds) + + assertEquals(5, merged.queuePosition) + assertEquals(emptyList(), sessionAdItems(merged.adState)) + } + + @Test + fun mergeQueueAdReportResultDropsCompletedAdFromEchoedServerList() { + val previous = session( + adState = SessionAdState( + isAdsRequired = true, + sessionAdsRequired = true, + sessionAds = listOf(ad("ad-2")), + ads = listOf(ad("ad-2")), + serverSentEmptyAds = false, + ), + ) + val echoed = session( + queuePosition = 5, + adState = SessionAdState( + isAdsRequired = true, + sessionAdsRequired = true, + sessionAds = listOf(ad("ad-1"), ad("ad-2")), + ads = listOf(ad("ad-1"), ad("ad-2")), + ), + ) + + val merged = mergeQueueAdReportResult( + previous = previous, + updated = echoed, + adId = "ad-1", + terminalAction = true, + ) + + assertEquals(5, merged.queuePosition) + assertEquals(listOf("ad-2"), sessionAdItems(merged.adState).map { it.adId }) + } + + @Test + fun mergeQueueAdReportResultKeepsLocallyAdvancedAdWhenServerOmitsList() { + val locallyAdvanced = session( + adState = SessionAdState( + isAdsRequired = true, + sessionAdsRequired = true, + sessionAds = listOf(ad("ad-2")), + ads = listOf(ad("ad-2")), + serverSentEmptyAds = false, + ), + ) + val omitted = session( + queuePosition = 4, + adState = SessionAdState( + isAdsRequired = true, + sessionAdsRequired = true, + serverSentEmptyAds = true, + ), + ) + + val merged = mergeQueueAdReportResult( + previous = locallyAdvanced, + updated = omitted, + adId = "ad-1", + terminalAction = true, + ) + + assertEquals(4, merged.queuePosition) + assertEquals(listOf("ad-2"), sessionAdItems(merged.adState).map { it.adId }) + } + + @Test + fun nextSessionAdIdOnlyAdvancesToADifferentAd() { + val ads = SessionAdState( + isAdsRequired = true, + sessionAdsRequired = true, + sessionAds = listOf(ad("ad-1"), ad("ad-2")), + ads = listOf(ad("ad-1"), ad("ad-2")), + ) + + assertEquals("ad-2", nextSessionAdId(ads, "ad-1")) + assertNull(nextSessionAdId(ads, "ad-2")) + assertNull(nextSessionAdId(ads.copy(sessionAds = listOf(ad("ad-1")), ads = emptyList()), "ad-1")) + assertEquals("ad-2", nextSessionAdId(ads.copy(sessionAds = listOf(ad("ad-2")), ads = emptyList()), "ad-1")) + } + + @Test + fun mergeQueueAdStateReturnsNullWhenNoPriorAdStateExists() { + assertNull(mergeQueueAdState(null, null)) + } + + @Test + fun shouldWaitForQueueAdPlaybackRequiresPlayableAdItem() { + val waiting = SessionAdState( + isAdsRequired = true, + sessionAdsRequired = true, + sessionAds = listOf(ad("ad-1")), + ads = listOf(ad("ad-1")), + ) + val emptyRequired = SessionAdState( + isAdsRequired = true, + sessionAdsRequired = true, + serverSentEmptyAds = true, + ) + + assertEquals(true, shouldWaitForQueueAdPlayback(waiting)) + assertEquals(false, shouldWaitForQueueAdPlayback(emptyRequired)) + } + + private fun ad(id: String): SessionAdInfo = + SessionAdInfo(adId = id, mediaUrl = "https://example.invalid/$id.mp4") + + private fun session( + status: Int = 1, + queuePosition: Int? = 7, + adState: SessionAdState? = null, + serverIp: String = "np.example.invalid", + signalingServer: String = "np.example.invalid:443", + signalingUrl: String = "wss://np.example.invalid/nvst/", + ): SessionInfo = + SessionInfo( + sessionId = "session-1", + status = status, + queuePosition = queuePosition, + adState = adState, + zone = "prod", + streamingBaseUrl = "https://np.example.invalid", + serverIp = serverIp, + signalingServer = signalingServer, + signalingUrl = signalingUrl, + ) +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/AndroidRecommendedProfileTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/AndroidRecommendedProfileTest.kt new file mode 100644 index 000000000..4940c17ae --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/AndroidRecommendedProfileTest.kt @@ -0,0 +1,177 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class AndroidRecommendedProfileTest { + @Test + fun constrainedDeviceUsesSafe720pThirtyProfile() { + val recommendation = recommendedAndroidStreamProfile( + displayWidth = 2560, + displayHeight = 1440, + processorCount = 4, + totalMemoryMiB = 2_048, + androidTvProfile = false, + report = codecReport( + lowPower = true, + constrained = true, + h264Max = 3840 to 2160, + h265Max = 3840 to 2160, + ), + ) + + assertEquals("1280x720", recommendation.stream.resolution) + assertEquals(30, recommendation.stream.fps) + assertEquals(12, recommendation.stream.maxBitrateMbps) + assertEquals(VideoCodec.H264, recommendation.stream.codec) + } + + @Test + fun highEndDeviceUsesVerifiedH265For1440p() { + val recommendation = recommendedAndroidStreamProfile( + displayWidth = 2560, + displayHeight = 1440, + processorCount = 8, + totalMemoryMiB = 8_192, + androidTvProfile = false, + report = codecReport( + h264Max = 1920 to 1080, + h265Max = 2560 to 1440, + ), + ) + + assertEquals("2560x1440", recommendation.stream.resolution) + assertEquals(60, recommendation.stream.fps) + assertEquals(45, recommendation.stream.maxBitrateMbps) + assertEquals(VideoCodec.H265, recommendation.stream.codec) + } + + @Test + fun recommendationDropsResolutionWhenNoVerifiedDecoderSupportsDisplayMaximum() { + val recommendation = recommendedAndroidStreamProfile( + displayWidth = 2560, + displayHeight = 1440, + processorCount = 8, + totalMemoryMiB = 8_192, + androidTvProfile = false, + report = codecReport( + h264Max = 1920 to 1080, + h265Max = 1920 to 1080, + ), + ) + + assertEquals("1920x1080", recommendation.stream.resolution) + assertEquals(VideoCodec.H264, recommendation.stream.codec) + } + + @Test + fun verifiedHardwareH265BeatsSoftwareH264At1080p() { + val report = RuntimeCodecReport( + capabilities = listOf( + hardwareCapability(VideoCodec.H264, 1920 to 1080, hardware = false), + hardwareCapability(VideoCodec.H265, 1920 to 1080), + ), + nativeRuntimeSummary = "test", + androidTvProfile = false, + lowPowerGpuProfile = false, + ) + + val recommendation = recommendedAndroidStreamProfile( + displayWidth = 1920, + displayHeight = 1080, + processorCount = 8, + totalMemoryMiB = 6_144, + androidTvProfile = false, + report = report, + ) + + assertEquals("1920x1080", recommendation.stream.resolution) + assertEquals(VideoCodec.H265, recommendation.stream.codec) + } + + @Test + fun shieldCanUseVerifiedFourKDecoderPath() { + val recommendation = recommendedAndroidStreamProfile( + displayWidth = 3840, + displayHeight = 2160, + processorCount = 8, + totalMemoryMiB = 3_072, + androidTvProfile = true, + nvidiaShieldTv = true, + report = codecReport( + androidTv = true, + h264Max = 3840 to 2160, + h265Max = 3840 to 2160, + ), + ) + + assertEquals("3840x2160", recommendation.stream.resolution) + assertEquals(VideoCodec.H265, recommendation.stream.codec) + assertEquals(75, recommendation.stream.maxBitrateMbps) + } + + @Test + fun customProfileListsOnlyPerformanceChoicesAboveRecommendation() { + val recommended = StreamSettings( + resolution = "1920x1080", + aspectRatio = "16:9", + fps = 60, + maxBitrateMbps = 35, + codec = VideoCodec.H264, + colorQuality = ColorQuality.EightBit420, + ) + val selected = recommended.copy( + resolution = "2560x1440", + fps = 120, + maxBitrateMbps = 75, + hdrEnabled = true, + colorQuality = ColorQuality.TenBit420, + streamSharpeningEnabled = true, + ) + + val overrides = selected.performanceOverridesComparedTo(recommended, report = null) + + assertTrue(overrides.any { it.startsWith("2560x1440 resolution") }) + assertTrue(overrides.any { it.startsWith("120 FPS") }) + assertTrue(overrides.any { it.startsWith("75 Mbps bitrate") }) + assertTrue(overrides.contains("HDR")) + assertTrue(overrides.contains("10-bit color")) + assertTrue(overrides.contains("stream sharpening")) + assertTrue(recommended.performanceOverridesComparedTo(recommended, report = null).isEmpty()) + } + + private fun codecReport( + androidTv: Boolean = false, + lowPower: Boolean = false, + constrained: Boolean = false, + h264Max: Pair, + h265Max: Pair, + ): RuntimeCodecReport = RuntimeCodecReport( + capabilities = listOf( + hardwareCapability(VideoCodec.H264, h264Max), + hardwareCapability(VideoCodec.H265, h265Max), + ), + nativeRuntimeSummary = "test", + androidTvProfile = androidTv, + lowPowerGpuProfile = lowPower, + constrainedRuntimeProfile = constrained, + ) + + private fun hardwareCapability( + codec: VideoCodec, + maximum: Pair, + hardware: Boolean = true, + ): CodecCapability = CodecCapability( + codec = codec, + decoderAvailable = true, + encoderAvailable = false, + hardwareDecoder = hardware, + hardwareEncoder = false, + nativeDecoderAvailable = hardware, + webRtcDecoderAvailable = true, + webRtcHardwareDecoderAvailable = hardware, + maxSupportedWidth = maximum.first, + maxSupportedHeight = maximum.second, + ) +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/AndroidSetupFlowTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/AndroidSetupFlowTest.kt new file mode 100644 index 000000000..d39bb6df7 --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/AndroidSetupFlowTest.kt @@ -0,0 +1,263 @@ +package com.opencloudgaming.opennow + +import kotlinx.serialization.decodeFromString +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class AndroidSetupFlowTest { + @Test + fun `the subtle adaptive background is default and nothing stays distinct`() { + val defaults = AppSettings() + val nothing = defaults.withAppBackgroundChoice(AppBackgroundChoice.Nothing) + val wallpaper = defaults.withAppBackgroundChoice(AppBackgroundChoice.Wallpaper) + + assertEquals(AppBackgroundChoice.Default, appBackgroundChoiceFor(defaults)) + assertTrue(defaults.ambientBackgroundEnabled) + assertEquals(AppBackgroundChoice.Nothing, appBackgroundChoiceFor(nothing)) + assertFalse(nothing.ambientBackgroundEnabled) + assertFalse(nothing.nerdCatalogBackground) + assertEquals(AppBackgroundChoice.Wallpaper, appBackgroundChoiceFor(wallpaper)) + assertTrue(wallpaper.nerdCatalogBackground) + } + + @Test + fun `a fresh install runs setup and a finished one does not`() { + assertTrue(shouldShowSetupFlow(AppSettings())) + assertTrue(shouldShowSetupFlow(OpenNowJson.decodeFromString("{}"))) + assertFalse(shouldShowSetupFlow(AppSettings().completingSetupFlow(SetupStep.Ready))) + } + + @Test + fun `raising the flow version brings existing installs back through setup`() { + val completedOnAnOlderRelease = AppSettings(setupFlowCompletedVersion = SETUP_FLOW_VERSION - 1) + + assertTrue(shouldShowSetupFlow(completedOnAnOlderRelease)) + } + + @Test + fun `running setup again from settings does not disturb the choices it made`() { + val configured = AppSettings( + uiAccent = UiAccent.Violet, + nerdCatalogBackground = true, + catalogBackgroundPreset = CatalogBackgroundPreset.AbsoluteCinema, + analyticsConsentAsked = true, + analyticsOptOut = false, + ).completingSetupFlow(SetupStep.Ready) + + val restarted = configured.restartingSetupFlow() + + assertTrue(shouldShowSetupFlow(restarted)) + assertEquals(configured.copy(setupFlowCompletedVersion = 0), restarted) + } + + @Test + fun `steps run welcome to ready with no gaps at either end`() { + assertEquals( + listOf( + SetupStep.Welcome, + SetupStep.Appearance, + SetupStep.Streaming, + SetupStep.Play, + SetupStep.Feedback, + SetupStep.Ready, + ), + setupSteps(), + ) + assertNull(setupStepBefore(SetupStep.Welcome)) + assertNull(setupStepAfter(SetupStep.Ready)) + assertTrue(isFinalSetupStep(SetupStep.Ready)) + assertFalse(isFinalSetupStep(SetupStep.Feedback)) + + var step = SetupStep.Welcome + val walked = mutableListOf(step) + while (!isFinalSetupStep(step)) { + step = setupStepAfter(step)!! + walked += step + } + assertEquals(setupSteps(), walked) + assertEquals(SetupStep.Feedback, setupStepBefore(SetupStep.Ready)) + } + + @Test + fun `leaving before the diagnostics step still lets the consent dialog ask`() { + listOf( + SetupStep.Welcome, + SetupStep.Appearance, + SetupStep.Streaming, + SetupStep.Play, + SetupStep.Feedback, + ) + .forEach { furthest -> + val settings = AppSettings().completingSetupFlow(furthest) + + assertFalse( + "furthest=$furthest should leave analytics consent unasked", + settings.analyticsConsentAsked, + ) + assertFalse(shouldShowSetupFlow(settings)) + } + } + + @Test + fun `walking past the diagnostics step counts as answering it`() { + val settings = AppSettings().completingSetupFlow(SetupStep.Ready) + + assertTrue(settings.analyticsConsentAsked) + // Reaching the step is consent to have been asked, not consent to share. + assertTrue(settings.analyticsOptOut) + assertFalse(settings.analyticsSharingEnabled) + } + + @Test + fun `an answer given during setup survives skipping out afterwards`() { + val optedIn = AppSettings(analyticsConsentAsked = true, analyticsOptOut = false) + + val settings = optedIn.completingSetupFlow(SetupStep.Feedback) + + assertTrue(settings.analyticsConsentAsked) + assertTrue(settings.analyticsSharingEnabled) + } + + @Test + fun `the streaming step reflects the preset already in settings`() { + assertEquals( + SetupStreamingChoice.Recommended, + setupStreamingChoiceFor(AppSettings(streamPreset = StreamPreset.Recommended)), + ) + assertEquals( + SetupStreamingChoice.Best, + setupStreamingChoiceFor(AppSettings(streamPreset = StreamPreset.High)), + ) + assertEquals( + SetupStreamingChoice.DataSaver, + setupStreamingChoiceFor(AppSettings(streamPreset = StreamPreset.LowDataSaver)), + ) + listOf(StreamPreset.Custom, StreamPreset.Medium).forEach { preset -> + assertEquals( + SetupStreamingChoice.Custom, + setupStreamingChoiceFor(AppSettings(streamPreset = preset)), + ) + } + } + + @Test + fun `every streaming choice round-trips through its preset`() { + SetupStreamingChoice.entries.forEach { choice -> + val preset = setupStreamingPresetFor(choice) + assertEquals( + choice.name, + choice, + setupStreamingChoiceFor(AppSettings(streamPreset = preset)), + ) + } + } + + @Test + fun `only the custom choice exposes the inline stream controls`() { + SetupStreamingChoice.entries.forEach { choice -> + assertEquals( + choice.name, + choice == SetupStreamingChoice.Custom, + setupStreamingCustomControlsVisible(choice), + ) + } + } + + @Test + fun `custom setup codec choices share settings availability and selection`() { + val presentation = androidCodecChoicePresentation( + stream = StreamSettings(codec = VideoCodec.H265), + codecReport = null, + comingSoonLabel = "Coming soon", + unavailableLabel = "Unavailable", + ) + + assertEquals(VideoCodec.entries.map { it.name }, presentation.options.map { it.value }) + assertTrue(presentation.options.all { it.enabled }) + assertEquals(VideoCodec.H265.name, presentation.selectedLabel) + } + + @Test + fun `touch mouse choices write one authoritative mode`() { + SetupTouchMouseChoice.entries.forEach { choice -> + val settings = AppSettings().withSetupTouchMouseChoice(choice) + + assertEquals(choice, setupTouchMouseChoiceFor(settings)) + assertEquals(choice != SetupTouchMouseChoice.Off, settings.androidTouch.mousePad) + assertEquals(choice == SetupTouchMouseChoice.Direct, settings.androidTouch.mouseDirectClick) + } + } + + @Test + fun `a disabled finger mouse does not retain direct click`() { + val settings = AppSettings( + androidTouch = AndroidTouchSettings(mousePad = true, mouseDirectClick = true), + ).withSetupTouchMouseChoice(SetupTouchMouseChoice.Off) + + assertFalse(settings.androidTouch.mousePad) + assertFalse(settings.androidTouch.mouseDirectClick) + assertEquals(SetupTouchMouseChoice.Off, setupTouchMouseChoiceFor(settings)) + } + + @Test + fun `setup and stream controls share every status line item`() { + assertEquals( + listOf( + StreamStatusItem.Keyboard, + StreamStatusItem.Fps, + StreamStatusItem.Ping, + StreamStatusItem.Bitrate, + StreamStatusItem.Battery, + StreamStatusItem.Connection, + StreamStatusItem.Resolution, + StreamStatusItem.Codec, + StreamStatusItem.Server, + StreamStatusItem.Latency, + StreamStatusItem.PacketLoss, + ), + StreamStatusItem.entries, + ) + } + + @Test + fun `every setup status item toggles only its own persisted value`() { + val defaults = AppSettings() + + StreamStatusItem.entries.forEach { item -> + val before = item.enabledIn(defaults) + val changed = item.setEnabled(defaults, !before) + + assertEquals(item.name, !before, item.enabledIn(changed)) + StreamStatusItem.entries.filterNot { it == item }.forEach { untouched -> + assertEquals( + "changing ${item.name} also changed ${untouched.name}", + untouched.enabledIn(defaults), + untouched.enabledIn(changed), + ) + } + assertEquals(item.name, defaults, item.setEnabled(changed, before)) + } + } + + @Test + fun `finishing setup changes nothing else about the settings`() { + val before = AppSettings( + uiAccent = UiAccent.Lime, + streamPreset = StreamPreset.LowDataSaver, + showStatsOnLaunch = false, + showSessionReportAfterStream = false, + analyticsConsentAsked = true, + analyticsOptOut = false, + ) + + val after = before.completingSetupFlow(SetupStep.Ready) + + assertEquals( + before, + after.copy(setupFlowCompletedVersion = before.setupFlowCompletedVersion), + ) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/AndroidStreamKeepAliveNotifierTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/AndroidStreamKeepAliveNotifierTest.kt new file mode 100644 index 000000000..aa73c785f --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/AndroidStreamKeepAliveNotifierTest.kt @@ -0,0 +1,96 @@ +package com.opencloudgaming.opennow + +import android.content.pm.ServiceInfo +import android.os.Build +import org.junit.Assert.assertFalse +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class AndroidStreamKeepAliveNotifierTest { + @Test + fun addsMicrophoneForegroundTypeOnlyWhenCaptureIsActiveAndSupported() { + assertEquals( + ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK, + androidStreamForegroundServiceType( + microphoneCaptureActive = false, + sdkInt = Build.VERSION_CODES.VANILLA_ICE_CREAM, + ), + ) + assertEquals( + ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK or + ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE, + androidStreamForegroundServiceType( + microphoneCaptureActive = true, + sdkInt = Build.VERSION_CODES.VANILLA_ICE_CREAM, + ), + ) + assertEquals( + ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK, + androidStreamForegroundServiceType( + microphoneCaptureActive = true, + sdkInt = Build.VERSION_CODES.Q, + ), + ) + } + + @Test + fun preparesMicrophoneServiceOnlyForReadyPermittedMicrophoneStream() { + val readyMicrophoneState = OpenNowUiState( + page = AppPage.Stream, + streamStatus = "streaming", + streamSession = readySession(), + activeStreamSettings = StreamSettings(microphoneMode = MicrophoneMode.VoiceActivity), + ) + + assertTrue(shouldPrepareAndroidStreamMicrophone(readyMicrophoneState, permissionGranted = true)) + assertFalse(shouldPrepareAndroidStreamMicrophone(readyMicrophoneState, permissionGranted = false)) + assertFalse( + shouldPrepareAndroidStreamMicrophone( + readyMicrophoneState.copy( + activeStreamSettings = StreamSettings(microphoneMode = MicrophoneMode.Disabled), + ), + permissionGranted = true, + ), + ) + assertFalse( + shouldPrepareAndroidStreamMicrophone( + readyMicrophoneState.copy(page = AppPage.Home), + permissionGranted = true, + ), + ) + } + + @Test + fun keepsReadyStreamAlive() { + val state = OpenNowUiState( + page = AppPage.Stream, + streamStatus = "streaming", + streamSession = readySession(), + ) + + assertTrue(shouldKeepAndroidStreamAlive(state)) + } + + @Test + fun doesNotKeepQueueOrExitedStreamAlive() { + assertFalse( + shouldKeepAndroidStreamAlive( + OpenNowUiState(page = AppPage.Stream, streamStatus = "queueing"), + ), + ) + assertFalse( + shouldKeepAndroidStreamAlive( + OpenNowUiState(page = AppPage.Home, streamStatus = "streaming", streamSession = readySession()), + ), + ) + } + + private fun readySession(): SessionInfo = SessionInfo( + sessionId = "session-id", + status = 2, + serverIp = "example.invalid", + signalingServer = "example.invalid", + signalingUrl = "wss://example.invalid", + ) +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/AndroidStreamOrientationTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/AndroidStreamOrientationTest.kt new file mode 100644 index 000000000..ac99bea91 --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/AndroidStreamOrientationTest.kt @@ -0,0 +1,81 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class AndroidStreamOrientationTest { + @Test + fun locksPhoneLandscapeOnlyAfterQueueCompletes() { + val readySession = readySession() + + assertFalse( + shouldLockPhoneStreamLandscape( + state = OpenNowUiState( + page = AppPage.Stream, + streamStatus = "queue", + streamSession = readySession, + ), + smallestScreenWidthDp = 390, + ), + ) + assertTrue( + shouldLockPhoneStreamLandscape( + state = OpenNowUiState( + page = AppPage.Stream, + streamStatus = "connecting", + streamSession = readySession, + ), + smallestScreenWidthDp = 390, + ), + ) + } + + @Test + fun keepsPhonesUnlockedUntilSessionIsStreamReady() { + assertFalse( + shouldLockPhoneStreamLandscape( + state = OpenNowUiState( + page = AppPage.Stream, + streamStatus = "connecting", + streamSession = readySession(status = 1), + ), + smallestScreenWidthDp = 390, + ), + ) + } + + @Test + fun doesNotLockTabletOrTvLandscape() { + val state = OpenNowUiState( + page = AppPage.Stream, + streamStatus = "streaming", + streamSession = readySession(), + ) + + assertFalse(shouldLockPhoneStreamLandscape(state, smallestScreenWidthDp = 600)) + assertFalse( + shouldLockPhoneStreamLandscape( + state = state.copy(codecReport = runtimeCodecReport(androidTvProfile = true)), + smallestScreenWidthDp = 390, + ), + ) + } + + private fun readySession(status: Int = 2): SessionInfo = + SessionInfo( + sessionId = "session", + status = status, + serverIp = "203.0.113.10", + signalingServer = "signal.example.com", + signalingUrl = "wss://signal.example.com", + ) + + private fun runtimeCodecReport(androidTvProfile: Boolean): RuntimeCodecReport = + RuntimeCodecReport( + capabilities = emptyList(), + nativeRuntimeSummary = "", + androidTvProfile = androidTvProfile, + lowPowerGpuProfile = false, + ) +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/AndroidTvUiBehaviorTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/AndroidTvUiBehaviorTest.kt new file mode 100644 index 000000000..e4842f4e1 --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/AndroidTvUiBehaviorTest.kt @@ -0,0 +1,749 @@ +package com.opencloudgaming.opennow + +import com.opencloudgaming.opennow.ui.theme.OpenNowPalette +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.Key +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class AndroidTvUiBehaviorTest { + @Test + fun profileMenuRemainsOpaqueOverCustomWallpaperAndBonanzaEffects() { + assertEquals(1f, ProfileMenuContainerColor.alpha, 0f) + assertEquals(Panel, ProfileMenuContainerColor) + } + + @Test + fun chosenWallpaperAlsoBacksSettingsButNeverTheStream() { + val wallpaper = AppSettings(nerdCatalogBackground = true) + + assertTrue(shouldShowAppWallpaper(AppPage.Home, inStream = false, wallpaper)) + assertTrue(shouldShowAppWallpaper(AppPage.Library, inStream = false, wallpaper)) + assertTrue(shouldShowAppWallpaper(AppPage.Settings, inStream = false, wallpaper)) + assertFalse(shouldShowAppWallpaper(AppPage.Stream, inStream = true, wallpaper)) + assertFalse(shouldShowAppWallpaper(AppPage.Settings, inStream = false, AppSettings())) + } + + @Test + fun settingsFocusScrollLeavesRoomBelowTheWholeFocusedCard() { + assertEquals( + 30f, + settingsFocusScrollDistance( + itemOffsetPx = 650f, + itemSizePx = 60f, + containerSizePx = 720f, + topClearancePx = 16f, + bottomClearancePx = 40f, + ), + 0f, + ) + assertEquals( + 0f, + settingsFocusScrollDistance( + itemOffsetPx = 100f, + itemSizePx = 60f, + containerSizePx = 720f, + topClearancePx = 16f, + bottomClearancePx = 40f, + ), + 0f, + ) + } + + @Test + fun defaultThemeUsesNvidiaStyleGreenAndWhiteSelectionEnergy() { + val style = AppSettings().activeSelectionEffectStyle() + + assertEquals(OpenNowPalette.AccentDefault, UiAccent.OpenNow.color) + assertEquals(OpenNowPalette.AccentDefaultSecondary, UiAccent.OpenNow.secondaryColor) + assertEquals(Color.White, style.color) + assertEquals(Color.White, style.secondaryColor) + assertFalse(style.enabled) + assertFalse(style.gameCardBordersEnabled) + assertFalse(style.absoluteCinemaActive) + assertFalse(style.absoluteCinemaEverywhere) + } + + @Test + fun absoluteCinemaRequiresItsToggleAndKeepsTheSelectedAccent() { + val hotPink = AppSettings(uiAccent = UiAccent.HotPink) + val cinema = hotPink.copy(absoluteCinemaEffects = true) + val switch = AppSettings(uiAccent = UiAccent.Switch) + val hotPinkStyle = hotPink.activeSelectionEffectStyle() + val cinemaStyle = cinema.activeSelectionEffectStyle() + val switchStyle = switch.activeSelectionEffectStyle() + + assertEquals(OpenNowPalette.AccentHotPink, hotPink.uiAccent.color) + assertEquals(OpenNowPalette.AccentHotPink, hotPinkStyle.color) + assertEquals(OpenNowPalette.AccentHotPink, cinemaStyle.color) + assertEquals(OpenNowPalette.AccentHotPink, cinemaStyle.secondaryColor) + assertTrue(cinemaStyle.absoluteCinemaActive) + assertEquals(OpenNowPalette.AccentHotPink, cinema.uiAccent.color) + assertEquals(OpenNowPalette.AccentSwitchRed, switchStyle.color) + assertEquals(OpenNowPalette.AccentSwitchBlue, switchStyle.secondaryColor) + val absoluteCinemaStyle = AppSettings(uiAccent = UiAccent.AbsoluteCinema) + .activeSelectionEffectStyle() + assertEquals(Color.White, absoluteCinemaStyle.color) + assertEquals(Color.White, absoluteCinemaStyle.secondaryColor) + assertEquals(Color.White, absoluteCinemaStyle.tintColor) + assertEquals(OpenNowPalette.AccentDefault, UiAccent.AbsoluteCinema.themeColor) + assertEquals(OpenNowPalette.AccentDefaultSecondary, UiAccent.AbsoluteCinema.themeSecondaryColor) + } + + @Test + fun absoluteCinemaAnimatedEnergyUsesClassicOrangeAndBlueWithoutTintingStaticBorders() { + val staticStyle = AppSettings(uiAccent = UiAccent.AbsoluteCinema).activeSelectionEffectStyle() + val (primaryEnergy, secondaryEnergy) = controllerFocusEnergyColors( + absoluteCinemaPalette = true, + tint = staticStyle.color, + secondaryTint = staticStyle.secondaryColor, + ) + + assertEquals(Color.White, staticStyle.color) + assertEquals(Color.White, staticStyle.secondaryColor) + assertEquals(OpenNowPalette.AccentCinemaOrange, primaryEnergy) + assertEquals(OpenNowPalette.AccentCinemaBlue, secondaryEnergy) + } + + @Test + fun accentPickerRestoresAbsoluteCinemaWithoutEnablingEffects() { + val accents = selectableUiAccents() + + assertFalse(UiAccent.LegacyOrange in accents) + assertTrue(UiAccent.AbsoluteCinema in accents) + assertEquals(accents.size, accents.distinct().size) + assertFalse(AppSettings(uiAccent = UiAccent.AbsoluteCinema).absoluteCinemaEffects) + } + + @Test + fun selectionBordersOnlyEnableWithAbsoluteCinema() { + assertFalse(AppSettings().activeSelectionEffectStyle().enabled) + assertFalse(AppSettings(uiAccent = UiAccent.Pixel).activeSelectionEffectStyle().enabled) + assertFalse( + AppSettings(uiAccent = UiAccent.Pixel, liveSelectedOutlines = false) + .activeSelectionEffectStyle() + .enabled, + ) + val absoluteCinema = AppSettings(liveSelectedOutlines = false, absoluteCinemaEffects = true) + assertTrue(absoluteCinema.activeSelectionEffectStyle().enabled) + } + + @Test + fun staticGameBordersAndAnimatedEffectsAreIndependent() { + val borderOnly = AppSettings( + liveSelectedOutlines = true, + absoluteCinemaEffects = false, + ).activeSelectionEffectStyle() + val effectsOnly = AppSettings( + liveSelectedOutlines = false, + absoluteCinemaEffects = true, + ).activeSelectionEffectStyle() + + assertTrue(borderOnly.gameCardBordersEnabled) + assertFalse(borderOnly.absoluteCinemaActive) + assertFalse(effectsOnly.gameCardBordersEnabled) + assertTrue(effectsOnly.absoluteCinemaActive) + } + + @Test + fun catalogBordersFollowTheIndependentGameBorderToggle() { + val accent = OpenNowPalette.AccentHotPink + + assertEquals( + Color.Transparent, + catalogCardBorderColor(selectionColor = accent, gameBorderEnabled = false), + ) + assertEquals( + Color.Transparent, + cinemaBorderColor(absoluteCinemaEnabled = false, cinemaColor = Color.White), + ) + assertEquals( + accent, + catalogCardBorderColor(selectionColor = accent, gameBorderEnabled = true), + ) + assertEquals(Color.White, storeHeroBorderColor(gameBorderEnabled = true)) + assertEquals(Color.Transparent, storeHeroBorderColor(gameBorderEnabled = false)) + } + + @Test + fun controllerFocusKeepsAStaticWhiteGameBorderWhenEffectsAreOff() { + val accent = OpenNowPalette.AccentHotPink + + assertEquals( + Color.White, + catalogCardBorderColor( + selectionColor = accent, + gameBorderEnabled = false, + controllerFocused = true, + borderEffectsEnabled = false, + ), + ) + assertEquals( + Color.Transparent, + catalogCardBorderColor( + selectionColor = accent, + gameBorderEnabled = false, + controllerFocused = true, + borderEffectsEnabled = true, + ), + ) + assertEquals( + Color.White, + storeHeroBorderColor( + gameBorderEnabled = false, + controllerFocused = true, + borderEffectsEnabled = false, + ), + ) + } + + @Test + fun crazyCinemaBroadeningStillRequiresAbsoluteCinema() { + val crazy = AppSettings( + liveSelectedOutlines = false, + absoluteCinemaEffects = true, + absoluteCinemaEverywhere = true, + ).activeSelectionEffectStyle() + val orphanedToggle = AppSettings( + absoluteCinemaEffects = false, + absoluteCinemaEverywhere = true, + ).activeSelectionEffectStyle() + + assertTrue(crazy.enabled) + assertTrue(crazy.absoluteCinemaActive) + assertTrue(crazy.absoluteCinemaEverywhere) + assertFalse(orphanedToggle.absoluteCinemaActive) + assertFalse(orphanedToggle.absoluteCinemaEverywhere) + } + + @Test + fun catalogueWallpaperMakesTheHorizontalRailSubstantiallyDarker() { + assertEquals(OpenNowPalette.ChromeScrim, navigationRailScrim(darkenForCatalogBackground = false)) + assertEquals(Color.Black.copy(alpha = 0.76f), navigationRailScrim(darkenForCatalogBackground = true)) + } + + @Test + fun gameDetailsPreferCleanShortDescription() { + val game = GameInfo( + id = "fortnite", + title = "Fortnite", + description = "Clean short description", + longDescription = "Provider long description", + ) + + assertEquals("Clean short description", gameDescriptionForDetails(game)) + } + + @Test + fun restoresTvNavigationFocusOnlyWhenLeavingStream() { + assertTrue( + shouldRestoreTvNavigationFocus( + previouslyInStream = true, + currentlyInStream = false, + tvProfile = true, + ), + ) + assertFalse( + shouldRestoreTvNavigationFocus( + previouslyInStream = true, + currentlyInStream = false, + tvProfile = false, + ), + ) + assertFalse( + shouldRestoreTvNavigationFocus( + previouslyInStream = false, + currentlyInStream = false, + tvProfile = true, + ), + ) + } + + @Test + fun catalogWallpaperIsOptInOnBothTvAndMobile() { + val defaults = AppSettings() + + assertFalse(shouldShowCatalogWallpaper(defaults)) + assertTrue(shouldShowCatalogWallpaper(defaults.copy(nerdCatalogBackground = true))) + } + + @Test + fun localAppsProfileActionStaysAvailableAsAnEnablementShortcut() { + assertTrue( + shouldShowLocalAppsProfileAction( + localAppLauncherSupported = true, + localAppsEnabled = false, + ), + ) + assertFalse( + shouldShowLocalAppsProfileAction( + localAppLauncherSupported = false, + localAppsEnabled = true, + ), + ) + assertTrue( + shouldShowLocalAppsProfileAction( + localAppLauncherSupported = true, + localAppsEnabled = true, + ), + ) + } + + @Test + fun settingsControllerNavigationIncludesGamingHandheldControls() { + assertTrue(shouldEnableSettingsControllerNavigation(false, null, gamingHandheld = true)) + assertTrue( + shouldEnableSettingsControllerNavigation( + tvProfile = false, + controllerFamily = AndroidControllerFamily.Generic, + gamingHandheld = false, + ), + ) + assertFalse(shouldEnableSettingsControllerNavigation(false, null, gamingHandheld = false)) + } + + @Test + fun tvActivationKeysCanBeConsumedAcrossBothKeyPhases() { + assertTrue(isTvActivationKey(Key.DirectionCenter)) + assertTrue(isTvActivationKey(Key.Enter)) + assertTrue(isTvActivationKey(Key.NumPadEnter)) + assertFalse(isTvActivationKey(Key.ButtonY)) + } + + @Test + fun mobileCatalogCardsUseDisplaySizedGameBoxArtWithoutTitleOverlay() { + val gameBoxArt = "https://img.nvidiagrid.net/apps/123/ZZ/GAME_BOX_ART_01_example.jpg" + val game = GameInfo( + id = "game", + title = "Game", + imageUrl = gameBoxArt, + tvCardImageUrl = gameBoxArt, + ) + + assertEquals("$gameBoxArt;f=webp;w=512", catalogCardImageUrl(game, tvProfile = false)) + assertFalse(shouldOverlayCatalogCardTitle(tvProfile = false)) + } + + @Test + fun mobileCatalogCardsRejectStaleTvBannerCacheEntries() { + val game = GameInfo( + id = "game", + title = "Game", + imageUrl = "https://img.nvidiagrid.net/apps/123/ZZ/TV_BANNER_01_example.jpg", + ) + + assertNull(catalogCardImageUrl(game, tvProfile = false)) + } + + @Test + fun tvCatalogCardsUseTheSameArtworkAsMobileAtTvRequestSize() { + val game = GameInfo( + id = "game", + title = "Game", + imageUrl = "https://img.nvidiagrid.net/apps/123/ZZ/GAME_BOX_ART_01_example.jpg", + tvCardImageUrl = "https://img.nvidiagrid.net/apps/123/ZZ/TV_BANNER_01_example.jpg", + ) + + assertEquals( + "https://img.nvidiagrid.net/apps/123/ZZ/GAME_BOX_ART_01_example.jpg;f=webp;w=272", + catalogCardImageUrl(game, tvProfile = true), + ) + assertFalse(shouldOverlayCatalogCardTitle(tvProfile = true)) + } + + @Test + fun tvCatalogCardsRetainDedicatedArtworkAsAMissingPosterFallback() { + val game = GameInfo( + id = "game", + title = "Game", + tvCardImageUrl = "https://img.nvidiagrid.net/apps/123/ZZ/TV_BANNER_01_example.jpg", + ) + + assertEquals( + "https://img.nvidiagrid.net/apps/123/ZZ/TV_BANNER_01_example.jpg;f=webp;w=272", + catalogCardImageUrl(game, tvProfile = true), + ) + } + + @Test + fun catalogFavoriteIconIsOptInForEveryDeviceLayout() { + assertFalse(shouldShowCatalogFavoriteIcon(AppSettings())) + assertTrue(shouldShowCatalogFavoriteIcon(AppSettings(showFavoriteIconOnGameCards = true))) + } + + @Test + fun liveSelectionOutlineRequiresBothSelectionAndUserOptIn() { + assertTrue(shouldShowActiveSelectionOutline(selected = true, enabled = true)) + assertFalse(shouldShowActiveSelectionOutline(selected = false, enabled = true)) + assertFalse(shouldShowActiveSelectionOutline(selected = true, enabled = false)) + } + + @Test + fun controllerBackMinimizesOnlyPendingStreamLaunches() { + assertTrue(canMinimizeStreamLaunch(streamStatus = "queue", sessionReady = false)) + assertTrue(canMinimizeStreamLaunch(streamStatus = "connecting", sessionReady = false)) + assertFalse(canMinimizeStreamLaunch(streamStatus = "idle", sessionReady = false)) + assertFalse(canMinimizeStreamLaunch(streamStatus = "connecting", sessionReady = true)) + } + + @Test + fun activeLogoFloatsBeforeItsQuickFlip() { + assertEquals(0f, activeLogoSpinProgress(0f), 0f) + assertEquals(0f, activeLogoSpinProgress(0.30f), 0.0001f) + assertEquals(0.5f, activeLogoSpinProgress(0.39f), 0.0001f) + assertEquals(1f, activeLogoSpinProgress(0.48f), 0.0001f) + assertEquals(1f, activeLogoSpinProgress(0.9f), 0f) + assertEquals(0f, activeLogoFloatOffsetDp(0f), 0.0001f) + assertEquals(2.5f, activeLogoFloatOffsetDp(0.25f), 0.0001f) + assertEquals(-2.5f, activeLogoFloatOffsetDp(0.75f), 0.0001f) + } + + @Test + fun tvGameDetailsInitiallyFocusPlayWhileTouchLayoutsKeepArtworkFocus() { + assertTrue(shouldInitiallyFocusGameDetailsPlay(tvProfile = true)) + assertFalse(shouldInitiallyFocusGameDetailsPlay(tvProfile = false)) + } + + @Test + fun focusedGameDetailsPlayButtonGetsAnUnmistakableLiftAndEdge() { + assertEquals(1.06f, gameDetailsPlayFocusScale(focused = true), 0f) + assertEquals(1f, gameDetailsPlayFocusScale(focused = false), 0f) + assertEquals(4f, gameDetailsPlayFocusBorderWidthDp(focused = true), 0f) + assertEquals(0f, gameDetailsPlayFocusBorderWidthDp(focused = false), 0f) + } + + @Test + fun whiteButtonFocusTreatmentRequiresTvOrPhysicalController() { + assertTrue( + shouldShowControllerFocus( + focused = true, + tvProfile = true, + physicalControllerConnected = false, + ), + ) + assertTrue( + shouldShowControllerFocus( + focused = true, + tvProfile = false, + physicalControllerConnected = true, + ), + ) + assertFalse( + shouldShowControllerFocus( + focused = true, + tvProfile = false, + physicalControllerConnected = false, + ), + ) + assertFalse( + shouldShowControllerFocus( + focused = false, + tvProfile = true, + physicalControllerConnected = true, + ), + ) + } + + @Test + fun controllerCatalogCardsAreArtworkOnly() { + assertTrue(shouldUseArtworkOnlyCatalogCards(tvProfile = true, controllerActionMode = false)) + assertTrue(shouldUseArtworkOnlyCatalogCards(tvProfile = false, controllerActionMode = true)) + assertFalse(shouldUseArtworkOnlyCatalogCards(tvProfile = false, controllerActionMode = false)) + } + + @Test + fun tvNeverShowsTouchControlsWhileMobileBehaviorIsPreserved() { + assertFalse( + shouldShowAndroidTouchControls( + tvProfile = true, + touchInputEnabled = true, + touchControlsEnabled = true, + suppressedByPhysicalController = false, + ), + ) + assertTrue( + shouldShowAndroidTouchControls( + tvProfile = false, + touchInputEnabled = true, + touchControlsEnabled = true, + suppressedByPhysicalController = false, + ), + ) + assertFalse( + shouldShowAndroidTouchControls( + tvProfile = false, + touchInputEnabled = true, + touchControlsEnabled = true, + suppressedByPhysicalController = true, + ), + ) + assertFalse( + shouldShowAndroidTouchControls( + tvProfile = false, + touchInputEnabled = true, + touchControlsEnabled = true, + suppressedByPhysicalController = false, + physicalMouseConnected = true, + ), + ) + assertTrue( + shouldShowAndroidTouchControls( + tvProfile = false, + touchInputEnabled = true, + touchControlsEnabled = true, + suppressedByPhysicalController = false, + physicalMouseConnected = true, + allowWithPhysicalMouse = true, + ), + ) + } + + @Test + fun allAndroidDevicesUseStableAudioBuffering() { + assertFalse(shouldUseLowLatencyStreamAudio(androidTvProfile = true)) + assertFalse(shouldUseLowLatencyStreamAudio(androidTvProfile = false)) + } + + @Test + fun tvSafeAreaStartsInset() { + assertEquals(16f, AppSettings().tvSafeAreaPaddingDp, 0f) + } + + @Test + fun screenEdgePaddingOnlyAppliesToTvOutsideStream() { + val settings = AppSettings(tvSafeAreaPaddingDp = 20f) + + assertEquals(20f, appContentEdgePaddingDp(settings, inStream = false, tvProfile = true), 0f) + assertEquals(0f, appContentEdgePaddingDp(settings, inStream = true, tvProfile = true), 0f) + assertEquals(0f, appContentEdgePaddingDp(settings, inStream = false, tvProfile = false), 0f) + } + + @Test + fun gameCardScaleChangesStoreRailWidthContinuously() { + assertEquals(72f, scaledCatalogCardWidthDp(96f, 0.75f), 0f) + assertEquals(96f, scaledCatalogCardWidthDp(96f, 1f), 0f) + assertEquals(134.4f, scaledCatalogCardWidthDp(96f, 1.4f), 0.001f) + } + + @Test + fun storeRailSkeletonOnlyIncludesWholeCards() { + assertEquals(3, storeRailVisibleCardCount(360f, 96f, 10f)) + assertEquals(2, storeRailVisibleCardCount(360f, 140f, 10f)) + assertEquals(3, storeRailVisibleCardCount(308f, 96f, 10f)) + } + + @Test + fun storeSearchAndFiltersHideDiscoverySections() { + assertTrue(shouldShowStoreDiscoverySections(searchActive = false, filterActive = false)) + assertFalse(shouldShowStoreDiscoverySections(searchActive = true, filterActive = false)) + assertFalse(shouldShowStoreDiscoverySections(searchActive = false, filterActive = true)) + assertFalse(shouldShowStoreDiscoverySections(searchActive = true, filterActive = true)) + } + + @Test + fun unresolvedCatalogQueryReplacesOldCardsWithShimmerButCachedResultsStayVisible() { + assertTrue( + shouldShowCatalogLoadingPlaceholder( + queryLoading = true, + loadingGames = true, + hasVisibleGames = true, + ), + ) + assertFalse( + shouldShowCatalogLoadingPlaceholder( + queryLoading = false, + loadingGames = false, + hasVisibleGames = true, + ), + ) + assertFalse( + shouldShowCatalogLoadingPlaceholder( + queryLoading = false, + loadingGames = true, + hasVisibleGames = true, + ), + ) + assertTrue( + shouldShowCatalogLoadingPlaceholder( + queryLoading = false, + loadingGames = true, + hasVisibleGames = false, + ), + ) + } + + @Test + fun storeKeepsTopControlsMountedWhileControllerIsConnected() { + assertTrue( + shouldHideStoreChromeOnScroll( + hideChromeWhenScrolled = true, + scrolledAwayFromTop = true, + physicalControllerConnected = false, + ), + ) + assertFalse( + shouldHideStoreChromeOnScroll( + hideChromeWhenScrolled = true, + scrolledAwayFromTop = true, + physicalControllerConnected = true, + ), + ) + } + + @Test + fun catalogCardTitlesAreCaptionedOnTouchHandheldsOnly() { + assertTrue(shouldShowCatalogCardTitles(tvProfile = false, enabled = true)) + assertFalse(shouldShowCatalogCardTitles(tvProfile = true, enabled = true)) + assertFalse(shouldShowCatalogCardTitles(tvProfile = false, enabled = false)) + assertFalse(shouldOverlayCatalogCardTitle(tvProfile = true)) + } + + @Test + fun localTvRemoteRequiresExplicitOptIn() { + assertFalse(AppSettings().localTvRemoteEnabled) + } + + @Test + fun localTvConnectionDotNeverLeaksIntoMobileChrome() { + assertTrue(shouldShowLocalTvConnectionDot(tvProfile = true, pairedDeviceName = "Pixel")) + assertFalse(shouldShowLocalTvConnectionDot(tvProfile = false, pairedDeviceName = "Pixel")) + assertFalse(shouldShowLocalTvConnectionDot(tvProfile = true, pairedDeviceName = null)) + } + + @Test + fun appIconGlideHonorsMotionPermissionAndKeepsTvChromeAlive() { + val capableReport = RuntimeCodecReport( + capabilities = emptyList(), + nativeRuntimeSummary = "", + androidTvProfile = false, + lowPowerGpuProfile = false, + constrainedRuntimeProfile = false, + ) + assertTrue(shouldAnimateOpenNowAppIcon(capableReport, reduceMotion = false, absoluteCinemaEnabled = true)) + assertFalse(shouldAnimateOpenNowAppIcon(null, reduceMotion = false, absoluteCinemaEnabled = true)) + assertFalse(shouldAnimateOpenNowAppIcon(capableReport, reduceMotion = true, absoluteCinemaEnabled = true)) + assertFalse(shouldAnimateOpenNowAppIcon(capableReport, reduceMotion = false, absoluteCinemaEnabled = false)) + assertTrue( + shouldAnimateOpenNowAppIcon( + codecReport = null, + reduceMotion = false, + absoluteCinemaEnabled = false, + androidTvProfile = true, + ), + ) + assertFalse( + shouldAnimateOpenNowAppIcon( + codecReport = null, + reduceMotion = true, + absoluteCinemaEnabled = true, + androidTvProfile = true, + ), + ) + assertFalse( + shouldAnimateOpenNowAppIcon( + capableReport.copy(lowPowerGpuProfile = true), + reduceMotion = false, + absoluteCinemaEnabled = true, + ), + ) + assertFalse( + shouldAnimateOpenNowAppIcon( + capableReport.copy(constrainedRuntimeProfile = true), + reduceMotion = false, + absoluteCinemaEnabled = true, + ), + ) + assertTrue(shouldAnimateControllerFocusFrame(absoluteCinemaEnabled = true, reduceMotion = false)) + assertFalse(shouldAnimateControllerFocusFrame(absoluteCinemaEnabled = false, reduceMotion = false)) + assertFalse(shouldAnimateControllerFocusFrame(absoluteCinemaEnabled = true, reduceMotion = true)) + } + + @Test + fun tvKeepsTopChromeAndRefreshAvailableOutsideTheStream() { + assertTrue( + shouldShowTopStatusBar( + inStream = false, + portraitChrome = false, + phoneLandscapeChrome = false, + phoneLandscapeScrollChromeHidden = false, + tvProfile = true, + ), + ) + assertFalse( + shouldShowTopStatusBar( + inStream = true, + portraitChrome = false, + phoneLandscapeChrome = false, + phoneLandscapeScrollChromeHidden = false, + tvProfile = true, + ), + ) + assertTrue(shouldShowTvRefreshAction(tvProfile = true, inStream = false)) + assertFalse(shouldShowTvRefreshAction(tvProfile = true, inStream = true)) + assertFalse(shouldShowTvRefreshAction(tvProfile = false, inStream = false)) + } + + @Test + fun tvControllerShortcutsRequireAPhysicalController() { + assertTrue( + catalogControllerActionMode( + tvProfile = true, + landscapeLayout = true, + physicalControllerConnected = true, + ), + ) + assertFalse( + catalogControllerActionMode( + tvProfile = true, + landscapeLayout = true, + physicalControllerConnected = false, + ), + ) + assertFalse( + catalogControllerActionMode( + tvProfile = false, + landscapeLayout = false, + physicalControllerConnected = true, + ), + ) + } + + @Test + fun tvSettingsNeverAddsASecondBackItemToTheRail() { + assertFalse( + shouldShowSettingsBackRail( + tvProfile = true, + settingsPageOpen = true, + horizontalChrome = true, + detailRouteOpen = true, + ), + ) + assertTrue( + shouldShowSettingsBackRail( + tvProfile = false, + settingsPageOpen = true, + horizontalChrome = true, + detailRouteOpen = true, + ), + ) + } + + @Test + fun batteryOptimizationIsHiddenOnlyWhenNoBatteryIsConfirmed() { + assertFalse(shouldShowBatteryOptimization(explicitBatteryPresent = false)) + assertTrue(shouldShowBatteryOptimization(explicitBatteryPresent = true)) + assertTrue(shouldShowBatteryOptimization(explicitBatteryPresent = null)) + } + + @Test + fun compactTvPairingHidesMainLoginContentOnlyWhenNeeded() { + assertTrue(shouldUseDedicatedTvPairingLayout(true, true, 640f, 360f)) + assertTrue(shouldUseDedicatedTvPairingLayout(true, true, 960f, 480f)) + assertFalse(shouldUseDedicatedTvPairingLayout(true, true, 960f, 540f)) + assertFalse(shouldUseDedicatedTvPairingLayout(false, true, 400f, 720f)) + assertFalse(shouldUseDedicatedTvPairingLayout(true, false, 640f, 360f)) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/AppLaunchModeTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/AppLaunchModeTest.kt new file mode 100644 index 000000000..428adfd71 --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/AppLaunchModeTest.kt @@ -0,0 +1,62 @@ +package com.opencloudgaming.opennow + +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * `appLaunchMode` decides which virtual input devices the host builds for a session, and it is read + * once at creation and never revisited. Sending the wrong value is invisible at every layer we can + * see — the packets still encode, still send, still look right on the wire — and shows up only as a + * game that ignores your fingers. That is why it is pinned here rather than left to a device test. + */ +class AppLaunchModeTest { + + private fun launchModeOf(body: kotlinx.serialization.json.JsonObject): Int = + body.getValue("sessionRequestData").jsonObject + .getValue("appLaunchMode").jsonPrimitive.int + + private fun controllerBitmapOf(body: kotlinx.serialization.json.JsonObject): Int = + body.getValue("sessionRequestData").jsonObject + .getValue("remoteControllersBitmap").jsonPrimitive.int + + private fun supportedControllerTypesOf(body: kotlinx.serialization.json.JsonObject): List = + body.getValue("sessionRequestData").jsonObject + .getValue("availableSupportedControllers").jsonArray + .map { it.jsonPrimitive.int } + + @Test + fun sessionsDefaultToGamepadFriendly() { + val body = buildMinimalClaimRequestBody(appId = "123", deviceId = "device") + assertEquals(GfnAppLaunchMode.GAMEPAD_FRIENDLY, launchModeOf(body)) + assertEquals(1, controllerBitmapOf(body)) + assertEquals(listOf(2), supportedControllerTypesOf(body)) + } + + /** The one value that makes the host present a digitizer. Without it native touch is inert. */ + @Test + fun aTouchSessionAsksForTouchFriendly() { + val body = buildMinimalClaimRequestBody( + appId = "123", + deviceId = "device", + appLaunchMode = GfnAppLaunchMode.TOUCH_FRIENDLY, + ) + assertEquals(GfnAppLaunchMode.TOUCH_FRIENDLY, launchModeOf(body)) + assertEquals(0, controllerBitmapOf(body)) + assertEquals(emptyList(), supportedControllerTypesOf(body)) + } + + /** + * These are protocol constants, not ours to renumber — the host and the official client both + * read them by value. + */ + @Test + fun theProtocolValuesAreWhatTheServerExpects() { + assertEquals(1, GfnAppLaunchMode.DEFAULT) + assertEquals(2, GfnAppLaunchMode.GAMEPAD_FRIENDLY) + assertEquals(3, GfnAppLaunchMode.TOUCH_FRIENDLY) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/AppSettingsDefaultsTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/AppSettingsDefaultsTest.kt new file mode 100644 index 000000000..10310060c --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/AppSettingsDefaultsTest.kt @@ -0,0 +1,423 @@ +package com.opencloudgaming.opennow + +import kotlinx.serialization.decodeFromString +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class AppSettingsDefaultsTest { + @Test + fun streamStatusBarDefaultsToConnectionEssentials() { + val settings = AppSettings() + val metrics = settings.streamStatsMetrics + + assertTrue(settings.showStatsOnLaunch) + assertFalse(settings.hideStreamButtons) + assertFalse(settings.streamKeyboardClearConfirmationDisabled) + assertTrue(settings.externalMousePointerLock) + assertFalse(settings.showFavoriteIconOnGameCards) + assertFalse(settings.liveSelectedOutlines) + assertFalse(settings.absoluteCinemaEffects) + assertFalse(settings.absoluteCinemaEverywhere) + assertFalse(settings.localAppsEnabled) + assertFalse(settings.stretchStreamToFit) + assertTrue(settings.ambientBackgroundEnabled) + assertTrue(settings.localAppPackageNames.isEmpty()) + // The shelf opens on first sight; folding it is a choice the reader makes and keeps. + assertFalse(settings.localAppsCollapsed) + assertTrue(settings.landscapeNewGamesHero) + // Rumble routing stays automatic until someone's hardware proves it needs forcing. + assertEquals(HapticsOutputPreference.Auto, settings.hapticsOutput) + assertEquals(TouchControllerStyle.V1, settings.androidTouch.touchControllerStyle) + assertNull(settings.androidTouch.touchSkinTint) + assertTrue(settings.androidTouch.touchButtonLabels) + assertFalse(settings.androidTouch.gyroscopeEnabled) + assertEquals(1f, settings.androidTouch.aimZoneScale, 0.0001f) + assertEquals(1f, settings.androidTouch.aimZoneSensitivity, 0.0001f) + assertEquals(1f, settings.androidTouch.faceButtonScale, 0.0001f) + assertEquals(1f, settings.androidTouch.leftStickScale, 0.0001f) + assertEquals(1f, settings.androidTouch.rightStickScale, 0.0001f) + assertEquals(TouchControlGroup.entries.toSet(), settings.androidTouch.visibleControlGroups) + assertEquals(TouchExtraButtonAction.Guide, settings.androidTouch.extraButtonAction(0)) + assertEquals(TouchExtraButtonAction.None, settings.androidTouch.extraButtonAction(3)) + // Developer options are a hidden gesture, never a shipped or migrated-in default. + assertFalse(settings.developerOptionsUnlocked) + assertEquals(StreamKeyboardButtonPosition(), settings.streamKeyboardButtonPosition) + assertTrue(metrics.fps) + assertTrue(metrics.ping) + assertFalse(metrics.bitrate) + assertTrue(metrics.battery) + assertTrue(metrics.connection) + assertFalse(metrics.resolution) + assertFalse(metrics.codec) + assertFalse(metrics.location) + assertFalse(metrics.latency) + assertFalse(metrics.packetLoss) + assertEquals(4, metrics.enabledCount()) + } + + @Test + fun olderSavedSettingsReceiveStatusBarDefaults() { + val settings = OpenNowJson.decodeFromString("{}") + + assertEquals(StreamStatsMetrics(), settings.streamStatsMetrics) + assertTrue(settings.showStatsOnLaunch) + assertFalse(settings.hideStreamButtons) + assertFalse(settings.streamKeyboardClearConfirmationDisabled) + assertTrue(settings.externalMousePointerLock) + assertEquals(StreamKeyboardButtonPosition(), settings.streamKeyboardButtonPosition) + assertEquals(CatalogBackgroundPreset.ColorfulAbstract, settings.catalogBackgroundPreset) + assertFalse(settings.analyticsConsentAsked) + assertTrue(settings.analyticsOptOut) + assertFalse(settings.analyticsSharingEnabled) + assertFalse(settings.showFavoriteIconOnGameCards) + assertFalse(settings.liveSelectedOutlines) + assertFalse(settings.absoluteCinemaEffects) + assertFalse(settings.absoluteCinemaEverywhere) + assertFalse(settings.localAppsEnabled) + assertFalse(settings.stretchStreamToFit) + assertTrue(settings.localAppPackageNames.isEmpty()) + assertTrue(settings.landscapeNewGamesHero) + // Developer options are a hidden gesture, never a shipped or migrated-in default. + assertFalse(settings.developerOptionsUnlocked) + assertFalse(settings.showSessionReportAfterStream) + assertEquals(TouchJoystickMode.Fixed, settings.androidTouch.joystickMode) + assertEquals(TouchAimMode.LockJoystick, settings.androidTouch.aimMode) + assertEquals(0f, settings.androidTouch.joystickDeadZone, 0.0001f) + assertEquals(TouchControlGroup.entries.toSet(), settings.androidTouch.visibleControlGroups) + assertEquals(TouchExtraButtonAction.Guide, settings.androidTouch.extraButtonAction(0)) + } + + @Test + fun streamKeyboardClearConfirmationDefaultsOnAndPreservesOptOut() { + val defaulted = OpenNowJson.decodeFromString("{}") + val optedOut = OpenNowJson.decodeFromString( + """{"streamKeyboardClearConfirmationDisabled":true}""", + ) + + assertFalse(defaulted.streamKeyboardClearConfirmationDisabled) + assertTrue(optedOut.streamKeyboardClearConfirmationDisabled) + } + + @Test + fun favoriteIconDefaultsOffAndPreservesExplicitOptIn() { + val defaulted = OpenNowJson.decodeFromString("{}") + val optedIn = OpenNowJson.decodeFromString( + """{"showFavoriteIconOnGameCards":true}""", + ) + + assertFalse(defaulted.showFavoriteIconOnGameCards) + assertTrue(optedIn.showFavoriteIconOnGameCards) + } + + @Test + fun liveSelectedOutlinesDefaultOffAndPreserveOptIn() { + val defaulted = OpenNowJson.decodeFromString("{}") + val optedIn = OpenNowJson.decodeFromString( + """{"liveSelectedOutlines":true}""", + ) + + assertFalse(defaulted.liveSelectedOutlines) + assertTrue(optedIn.liveSelectedOutlines) + } + + @Test + fun localAppsAreOptInAndSavedPackagesRemainCompatible() { + val defaulted = OpenNowJson.decodeFromString("{}") + val optedIn = OpenNowJson.decodeFromString( + """{"localAppsEnabled":true,"localAppPackageNames":["com.epicgames.fortnite"]}""", + ) + + assertFalse(defaulted.localAppsEnabled) + assertTrue(defaulted.localAppPackageNames.isEmpty()) + assertTrue(optedIn.localAppsEnabled) + assertEquals(listOf("com.epicgames.fortnite"), optedIn.localAppPackageNames) + } + + @Test + fun absoluteCinemaAccentRemainsIndependentFromEffectToggle() { + val cinema = OpenNowJson.decodeFromString("""{"uiAccent":"AbsoluteCinema"}""").normalizedForAndroid() + val switch = OpenNowJson.decodeFromString("""{"uiAccent":"Switch"}""") + + assertEquals(UiAccent.AbsoluteCinema, cinema.uiAccent) + assertFalse(cinema.absoluteCinemaEffects) + assertEquals(UiAccent.Switch, switch.uiAccent) + } + + @Test + fun removedOrangeAccentMigratesToViolet() { + val settings = OpenNowJson.decodeFromString( + """{"uiAccent":"Orange"}""", + ).normalizedForAndroid() + + assertEquals(UiAccent.Violet, settings.uiAccent) + } + + @Test + fun crazyCinemaPersistsOnlyAsAnAbsoluteCinemaSuboption() { + val enabled = OpenNowJson.decodeFromString( + """{"absoluteCinemaEffects":true,"absoluteCinemaEverywhere":true}""", + ).normalizedForAndroid() + val orphaned = OpenNowJson.decodeFromString( + """{"absoluteCinemaEverywhere":true}""", + ).normalizedForAndroid() + + assertTrue(enabled.absoluteCinemaEverywhere) + assertFalse(orphaned.absoluteCinemaEverywhere) + } + + @Test + fun catalogueSortAndFiltersSurviveSettingsDecode() { + val settings = OpenNowJson.decodeFromString( + """{"catalogSortId":"latest","catalogFilterIds":["genre-action","opennow:supported-controls:touchscreen"],"librarySortId":"recent","libraryFilterIds":["library_store:steam"]}""", + ).normalizedForAndroid() + + assertEquals("latest", settings.catalogSortId) + assertEquals(listOf("genre-action", CATALOG_FILTER_TOUCHSCREEN), settings.catalogFilterIds) + assertEquals(LIBRARY_SORT_RECENT, settings.librarySortId) + assertEquals(listOf("library_store:steam"), settings.libraryFilterIds) + } + + @Test + fun legacyRelevanceDefaultMigratesOnceToMostPopular() { + val migrated = OpenNowJson.decodeFromString( + """{"catalogSortId":"relevance"}""", + ).normalizedForAndroid() + val explicitRelevance = migrated.copy(catalogSortId = "relevance").normalizedForAndroid() + + assertEquals(DEFAULT_CATALOG_SORT_ID, migrated.catalogSortId) + assertEquals(CATALOG_SORT_DEFAULT_VERSION, migrated.catalogSortDefaultVersion) + assertEquals("relevance", explicitRelevance.catalogSortId) + } + + @Test + fun touchAimZoneIsOptInAndPersistsWhenSelected() { + val defaulted = OpenNowJson.decodeFromString("{}") + val optedIn = OpenNowJson.decodeFromString( + """{"androidTouch":{"aimMode":"LockZone"}}""", + ) + + assertEquals(TouchAimMode.LockJoystick, defaulted.androidTouch.aimMode) + assertEquals(TouchAimMode.LockZone, optedIn.androidTouch.aimMode) + } + + @Test + fun touchAimZoneCustomizationPersistsAndNormalizes() { + val persisted = OpenNowJson.decodeFromString( + """{"androidTouch":{"aimZoneScale":1.25,"aimZoneSensitivity":1.75}}""", + ).normalizedForAndroid() + val invalid = AppSettings( + androidTouch = AndroidTouchSettings( + aimZoneScale = Float.NaN, + aimZoneSensitivity = Float.POSITIVE_INFINITY, + ), + ).normalizedForAndroid() + val bounded = AppSettings( + androidTouch = AndroidTouchSettings( + aimZoneScale = 9f, + aimZoneSensitivity = 0.1f, + ), + ).normalizedForAndroid() + + assertEquals(1.25f, persisted.androidTouch.aimZoneScale, 0.0001f) + assertEquals(1.75f, persisted.androidTouch.aimZoneSensitivity, 0.0001f) + assertEquals(1f, invalid.androidTouch.aimZoneScale, 0.0001f) + assertEquals(1f, invalid.androidTouch.aimZoneSensitivity, 0.0001f) + assertEquals(1.5f, bounded.androidTouch.aimZoneScale, 0.0001f) + assertEquals(0.25f, bounded.androidTouch.aimZoneSensitivity, 0.0001f) + } + + @Test + fun retiredLibraryHeroPreferenceDoesNotInvalidateSavedSettings() { + val settings = OpenNowJson.decodeFromString( + """{"libraryHeroCarousel":false,"landscapeNewGamesHero":false}""", + ) + + assertFalse(settings.landscapeNewGamesHero) + } + + @Test + fun defaultsUseRecommendedProfileAndKeepOptionalMusicOff() { + val settings = AppSettings() + + assertFalse(settings.nerdMode) + assertEquals(CatalogBackgroundPreset.ColorfulAbstract, settings.catalogBackgroundPreset) + assertTrue(settings.controllerUiSounds) + assertTrue(settings.vibrationEnabled) + assertEquals(AppLaunchPage.Store, settings.launchPage) + assertEquals(StreamPreset.Recommended, settings.streamPreset) + assertFalse(settings.streamIntroMusic) + assertEquals(IntroMusicStartMode.Muted, settings.streamIntroStartMode) + assertFalse(settings.queueReadyMusic) + assertFalse(settings.showSessionReportAfterStream) + assertFalse(settings.analyticsConsentAsked) + assertTrue(settings.analyticsOptOut) + assertFalse(settings.analyticsSharingEnabled) + assertFalse(settings.stream.streamSharpeningEnabled) + } + + @Test + fun legacyPhoneRumbleSettingControlsTheUnifiedVibrationToggle() { + val disabled = OpenNowJson.decodeFromString( + """{"phoneRumbleFallback":false}""", + ) + + assertFalse(disabled.vibrationEnabled) + } + + @Test + fun olderSavedSettingsKeepStreamSharpeningDisabledUnlessExplicitlyEnabled() { + val defaulted = OpenNowJson.decodeFromString("{}") + val optedIn = OpenNowJson.decodeFromString( + """{"stream":{"streamSharpeningEnabled":true}}""", + ) + + assertFalse(defaulted.stream.streamSharpeningEnabled) + assertTrue(optedIn.stream.streamSharpeningEnabled) + } + + @Test + fun mouseLockDefaultsOnAndPreservesExplicitOptOut() { + val defaulted = OpenNowJson.decodeFromString("{}") + val optedOut = OpenNowJson.decodeFromString( + """{"externalMousePointerLock":false}""", + ) + + assertTrue(defaulted.externalMousePointerLock) + assertFalse(optedOut.externalMousePointerLock) + } + + @Test + fun phonePresentationKeepsExactGeometryByDefaultAndRunsOnce() { + val migrated = AppSettings().withCurrentStreamPresentationDefaults() + + assertFalse(migrated.stretchStreamToFit) + assertFalse(migrated.legacyCropStreamToFill) + assertEquals(STREAM_PRESENTATION_PROFILE_VERSION, migrated.streamPresentationProfileVersion) + + // Already migrated: a later opt-in is the user's, and must survive every launch after it. + val optedIn = migrated.copy(stretchStreamToFit = true) + assertEquals(optedIn, optedIn.withCurrentStreamPresentationDefaults()) + } + + @Test + fun existingInstallKeepsItsSavedStretchPreference() { + val optedIn = AppSettings(stretchStreamToFit = true, streamPresentationProfileVersion = 2) + val optedOut = AppSettings(stretchStreamToFit = false, streamPresentationProfileVersion = 2) + + assertTrue(optedIn.withCurrentStreamPresentationDefaults().stretchStreamToFit) + assertFalse(optedOut.withCurrentStreamPresentationDefaults().stretchStreamToFit) + } + + @Test + fun tvPresentationKeepsExactGeometry() { + // A TV panel and a 16:9 stream already agree; filling would be a no-op that misreports. + val migrated = AppSettings().withCurrentStreamPresentationDefaults() + + assertFalse(migrated.legacyCropStreamToFill) + assertFalse(migrated.stretchStreamToFit) + assertEquals(STREAM_PRESENTATION_PROFILE_VERSION, migrated.streamPresentationProfileVersion) + } + + @Test + fun legacyAnalyticsPreferenceDoesNotOptInWithoutConsent() { + val settings = OpenNowJson.decodeFromString("""{"analyticsOptOut":false}""") + + assertFalse(settings.analyticsConsentAsked) + assertFalse(settings.analyticsSharingEnabled) + } + + @Test + fun sessionReportOptOutSurvivesSettingsSerialization() { + val settings = OpenNowJson.decodeFromString( + """{"showSessionReportAfterStream":false}""", + ) + + assertFalse(settings.showSessionReportAfterStream) + } + + @Test + fun legacySessionReportOptInIsMigratedOffOnce() { + val migrated = OpenNowJson.decodeFromString( + """{"showSessionReportAfterStream":true}""", + ).normalizedForAndroid() + + assertFalse(migrated.showSessionReportAfterStream) + assertEquals(SESSION_REPORT_DEFAULT_VERSION, migrated.sessionReportDefaultVersion) + } + + @Test + fun currentSessionReportOptInRemainsAvailable() { + val optedIn = OpenNowJson.decodeFromString( + """{"showSessionReportAfterStream":true,"sessionReportDefaultVersion":1}""", + ).normalizedForAndroid() + + assertTrue(optedIn.showSessionReportAfterStream) + } + + @Test + fun legacyPortalStreamModeMigratesToProviderTwentyOneByNineGeometry() { + val normalized = AppSettings( + stream = StreamSettings( + resolution = "1376x640", + aspectRatio = "19.5:9", + fps = 120, + ), + ).normalizedForAndroid() + + assertEquals("1376x590", normalized.stream.resolution) + assertEquals("21:9", normalized.stream.aspectRatio) + assertEquals(120, normalized.stream.fps) + } + + @Test + fun persistedNonFiniteInputSettingsFallBackBeforeTheyReachMotionRounding() { + val normalized = AppSettings( + stream = StreamSettings( + mouseSensitivity = Float.NaN, + streamSharpeningAmount = Float.POSITIVE_INFINITY, + ), + posterSizeScale = Float.NaN, + tvSafeAreaPaddingDp = Float.NEGATIVE_INFINITY, + androidTouch = AndroidTouchSettings( + opacity = Float.NaN, + nativeTouchScrollScale = Float.POSITIVE_INFINITY, + nativeTouchJitterThresholdDp = Float.NaN, + offsets = mapOf("bad" to TouchOffset(Float.NaN, Float.POSITIVE_INFINITY)), + ), + ).normalizedForAndroid() + + assertEquals(1f, normalized.stream.mouseSensitivity, 0f) + assertEquals(0.25f, normalized.stream.streamSharpeningAmount, 0f) + assertEquals(1f, normalized.posterSizeScale, 0f) + assertEquals(16f, normalized.tvSafeAreaPaddingDp, 0f) + assertEquals(AndroidTouchSettings().opacity, normalized.androidTouch.opacity, 0f) + assertEquals(1f, normalized.androidTouch.nativeTouchScrollScale, 0f) + assertEquals(8f, normalized.androidTouch.nativeTouchJitterThresholdDp, 0f) + assertEquals(TouchOffset(), normalized.androidTouch.offsets["bad"]) + } + + @Test + fun fullyTransparentTouchControlsRemainInteractivePreference() { + val normalized = AppSettings( + androidTouch = AndroidTouchSettings(opacity = 0f), + ).normalizedForAndroid() + + assertEquals(0f, normalized.androidTouch.opacity, 0f) + } + + @Test + fun keyboardButtonPositionIsKeptInsideTheStreamViewport() { + val normalized = AppSettings( + streamKeyboardButtonPosition = StreamKeyboardButtonPosition( + horizontalFraction = Float.POSITIVE_INFINITY, + verticalFraction = -0.25f, + ), + ).normalizedForAndroid() + + assertEquals(1f, normalized.streamKeyboardButtonPosition.horizontalFraction, 0f) + assertEquals(0f, normalized.streamKeyboardButtonPosition.verticalFraction, 0f) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/AppUpdateTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/AppUpdateTest.kt new file mode 100644 index 000000000..044c15ca2 --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/AppUpdateTest.kt @@ -0,0 +1,293 @@ +package com.opencloudgaming.opennow + +import com.google.android.play.core.install.model.UpdateAvailability +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class AppUpdateTest { + @Test + fun parsesManifestWithRelativeApkUrl() { + val candidate = parseAndroidUpdateCandidate( + "https://updates.example.com/android/opennow.json", + """ + { + "versionCode": 7, + "versionName": "0.5.2", + "apkUrl": "OpenNOW-0.5.2.apk", + "sha256": "SHA256: AA BB CC", + "releaseNotes": "Native Android update" + } + """.trimIndent(), + ) + + assertEquals("https://updates.example.com/android/opennow.json", candidate?.sourceUrl) + assertEquals("https://updates.example.com/android/OpenNOW-0.5.2.apk", candidate?.apkUrl) + assertEquals("0.5.2", candidate?.versionName) + assertEquals(7L, candidate?.versionCode) + assertEquals("aabbcc", candidate?.sha256) + assertEquals("Native Android update", candidate?.releaseNotes) + } + + @Test + fun parsesNestedAndroidManifest() { + val candidate = parseAndroidUpdateCandidate( + "https://updates.example.com/releases/latest.json", + """ + { + "android": { + "version_code": 8, + "version_name": "0.5.3", + "download_url": "https://cdn.example.com/OpenNOW-0.5.3.apk", + "release_notes": "Fix haptics\\nImprove updater\\r\\nClean up stream UI" + } + } + """.trimIndent(), + ) + + assertEquals("https://cdn.example.com/OpenNOW-0.5.3.apk", candidate?.apkUrl) + assertEquals("0.5.3", candidate?.versionName) + assertEquals(8L, candidate?.versionCode) + assertEquals("Fix haptics\nImprove updater\nClean up stream UI", candidate?.releaseNotes) + } + + @Test + fun parsesGithubReleaseApkAsset() { + val candidate = parseAndroidUpdateCandidate( + "https://api.github.com/repos/OpenCloudGaming/OpenNOW/releases/latest", + """ + { + "tag_name": "v0.5.4", + "body": "Release notes", + "assets": [ + { + "name": "OpenNOW-desktop.zip", + "browser_download_url": "https://github.com/example/desktop.zip" + }, + { + "name": "OpenNOW-android.apk", + "browser_download_url": "https://github.com/example/OpenNOW-android.apk", + "digest": "sha256:012345" + } + ] + } + """.trimIndent(), + ) + + assertEquals("https://github.com/example/OpenNOW-android.apk", candidate?.apkUrl) + assertEquals("0.5.4", candidate?.versionName) + assertEquals("012345", candidate?.sha256) + assertEquals("Release notes", candidate?.releaseNotes) + } + + @Test + fun parsesPrintedWasteManifestShape() { + val candidate = parseAndroidUpdateCandidate( + ANDROID_UPDATE_SOURCE_URL, + """ + { + "id": "5b000fc7-4f4c-464d-862a-ce9409c61081", + "appSlug": "opennow", + "appName": "OpenNOW", + "platform": "android", + "channel": "stable", + "versionCode": 6, + "versionName": "0.5.1", + "artifactUrl": "https://api.printedwaste.com/release-files/opennow/app-release.apk", + "url": "https://api.printedwaste.com/release-files/opennow/app-release.apk", + "sha256": "8bfd318dad6b2590e23d39237d02fb7bfec9fde1d09b6f08aff368ba5e9b073c", + "releaseNotes": "- Autoupdating\n- Filter fixies\n- Optimizations", + "mandatory": false, + "draft": false, + "apkUrl": "https://api.printedwaste.com/release-files/opennow/app-release.apk" + } + """.trimIndent(), + ) + + assertEquals(ANDROID_UPDATE_SOURCE_URL, candidate?.sourceUrl) + assertEquals("https://api.printedwaste.com/release-files/opennow/app-release.apk", candidate?.apkUrl) + assertEquals("0.5.1", candidate?.versionName) + assertEquals(6L, candidate?.versionCode) + assertEquals("8bfd318dad6b2590e23d39237d02fb7bfec9fde1d09b6f08aff368ba5e9b073c", candidate?.sha256) + assertEquals("- Autoupdating\n- Filter fixies\n- Optimizations", candidate?.releaseNotes) + } + + @Test + fun rejectsManifestWithoutApkUrl() { + val candidate = parseAndroidUpdateCandidate( + "https://updates.example.com/opennow.json", + """{"versionCode": 7, "versionName": "0.5.2"}""", + ) + + assertNull(candidate) + } + + @Test + fun normalizesHttpsSourceWithoutScheme() { + assertEquals("https://updates.example.com/opennow.json", normalizeAndroidUpdateSourceUrl("updates.example.com/opennow.json")) + } + + @Test(expected = IllegalStateException::class) + fun rejectsNonLoopbackHttpSource() { + normalizeAndroidUpdateSourceUrl("http://updates.example.com/opennow.json") + } + + @Test + fun updateChecksAreBlockedAcrossLocalStreamLifecycle() { + assertFalse(OpenNowUiState().isAndroidUpdateCheckBlockedByStream()) + assertTrue(OpenNowUiState(streamStatus = "queue").isAndroidUpdateCheckBlockedByStream()) + assertTrue(OpenNowUiState(streamStatus = "connecting").isAndroidUpdateCheckBlockedByStream()) + assertTrue(OpenNowUiState(streamStatus = "streaming").isAndroidUpdateCheckBlockedByStream()) + assertTrue( + OpenNowUiState( + streamStatus = "idle", + activeStreamSettings = StreamSettings(), + ).isAndroidUpdateCheckBlockedByStream(), + ) + } + + @Test + fun updateNoticeKeyIsStableAcrossAvailableAndDownloadedStates() { + val available = AndroidUpdateState( + status = AndroidUpdateStatus.Available, + availableVersionName = "0.5.4", + availableVersionCode = 9, + ) + val downloaded = available.copy(status = AndroidUpdateStatus.Downloaded) + + assertEquals(androidUpdateNoticeKey(available), androidUpdateNoticeKey(downloaded)) + assertEquals(null, available.visibleNoticeKey(androidUpdateNoticeKey(downloaded))) + assertEquals(androidUpdateNoticeKey(available), available.visibleNoticeKey(null)) + } + + @Test + fun googlePlayInstallSourceUsesPlayChecksWithoutEnablingApkUpdater() { + val update = AndroidUpdateState( + status = AndroidUpdateStatus.Available, + installSource = AndroidAppInstallSource(setOf(GOOGLE_PLAY_STORE_PACKAGE)), + availableVersionName = "0.6.7", + availableVersionCode = 22, + ) + + assertTrue(update.installSource.isGooglePlay) + assertTrue(update.installSource.usesGooglePlayUpdates) + assertFalse(update.apkUpdatesAllowed) + assertTrue(update.updateChecksSupported) + assertTrue(update.canCheck) + assertFalse(update.canDownload) + assertFalse(update.canInstall) + assertTrue(update.canOpenPlayStore) + assertFalse(update.shouldRunAutomaticCheck()) + assertEquals("code:22|name:0.6.7", androidUpdateNoticeKey(update)) + assertTrue(update.copy(status = AndroidUpdateStatus.Idle).shouldRunAutomaticCheck()) + } + + @Test + fun playStoreBuildComparisonRequiresAnAvailableNewerBuild() { + assertEquals( + 58L, + playStoreAvailableVersionCode( + currentVersionCode = 51, + updateAvailability = UpdateAvailability.UPDATE_AVAILABLE, + availableVersionCode = 58, + ), + ) + assertNull( + playStoreAvailableVersionCode( + currentVersionCode = 58, + updateAvailability = UpdateAvailability.UPDATE_AVAILABLE, + availableVersionCode = 58, + ), + ) + assertNull( + playStoreAvailableVersionCode( + currentVersionCode = 51, + updateAvailability = UpdateAvailability.UPDATE_NOT_AVAILABLE, + availableVersionCode = 58, + ), + ) + } + + @Test + fun sideloadInstallSourceAllowsApkUpdaterWhenBuildSupportsIt() { + val update = AndroidUpdateState( + status = AndroidUpdateStatus.Available, + installSource = AndroidAppInstallSource(installerPackageNames = emptySet(), apkUpdatesSupportedByBuild = true), + availableVersionName = "0.6.7", + availableVersionCode = 22, + ) + + assertFalse(update.installSource.isGooglePlay) + assertTrue(update.apkUpdatesAllowed) + assertTrue(update.canCheck) + assertTrue(update.canDownload) + assertEquals("Sideloaded", update.installSource.displayName) + assertEquals("code:22|name:0.6.7", androidUpdateNoticeKey(update)) + } + + @Test + fun playReleaseBuildUsesPlayChecksEvenWithoutInstallerMetadata() { + val update = AndroidUpdateState( + status = AndroidUpdateStatus.Available, + installSource = AndroidAppInstallSource( + installerPackageNames = emptySet(), + apkUpdatesSupportedByBuild = false, + playStoreReleaseBuild = true, + ), + availableVersionName = "0.6.7", + availableVersionCode = 22, + ) + + assertFalse(update.installSource.isGooglePlay) + assertTrue(update.installSource.usesGooglePlayUpdates) + assertFalse(update.apkUpdatesAllowed) + assertTrue(update.updateChecksSupported) + assertTrue(update.canCheck) + assertFalse(update.canDownload) + assertTrue(update.canOpenPlayStore) + assertFalse(update.shouldRunAutomaticCheck()) + assertTrue(update.copy(status = AndroidUpdateStatus.Idle).shouldRunAutomaticCheck()) + assertEquals("Ready to check Google Play for updates.", androidUpdateUnavailableMessage(update.installSource)) + } + + @Test + fun googlePlayInstallerPackageMatchingIsStable() { + assertTrue(isGooglePlayInstallerPackage(" com.android.vending ")) + assertTrue(isGooglePlayInstallerPackage("COM.ANDROID.VENDING")) + assertFalse(isGooglePlayInstallerPackage("com.android.packageinstaller")) + assertFalse(isGooglePlayInstallerPackage(null)) + } + + @Test + fun installSourceLabelsPlayStoreAndApkDistribution() { + val play = AndroidAppInstallSource(setOf(GOOGLE_PLAY_STORE_PACKAGE)) + val sideload = AndroidAppInstallSource(installerPackageNames = emptySet()) + val packageInstaller = AndroidAppInstallSource(setOf("com.google.android.packageinstaller")) + + assertEquals("play-store", play.distributionKind) + assertEquals("apk", sideload.distributionKind) + assertEquals("apk", packageInstaller.distributionKind) + assertEquals("Google Play", play.displayName) + assertEquals("Sideloaded", sideload.displayName) + } + + @Test + fun debugHeaderIncludesBuildAndDistribution() { + val update = AndroidUpdateState( + currentVersionName = "0.7.5", + currentVersionCode = 30, + installSource = AndroidAppInstallSource( + installerPackageNames = setOf(GOOGLE_PLAY_STORE_PACKAGE), + apkUpdatesSupportedByBuild = false, + playStoreReleaseBuild = true, + ), + ) + + assertEquals( + "app.version=0.7.5 build=30 variant=release distribution=play-store installSource=Google Play buildDistribution=play-release apkUpdatesAllowed=false", + update.debugHeaderLine(debugBuild = false), + ) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/BugReportPreflightTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/BugReportPreflightTest.kt new file mode 100644 index 000000000..ac1b3c5cc --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/BugReportPreflightTest.kt @@ -0,0 +1,443 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class BugReportPreflightTest { + private val settings = StreamSettings( + resolution = "1920x1080", + aspectRatio = "16:9", + fps = 60, + maxBitrateMbps = 35, + codec = VideoCodec.H264, + ) + + @Test + fun detectsManualServerFromSelectorOrConfiguredRegion() { + assertFalse(manuallySelectedServerForReport(null, "")) + assertFalse(manuallySelectedServerForReport("", "")) + assertTrue(manuallySelectedServerForReport("https://np-lon-06.example", "")) + assertTrue(manuallySelectedServerForReport(null, "https://np-ams-06.example")) + } + + @Test + fun experimentalNativeStreamerWarningIsFirstAndExplicitlyUnsupported() { + val deck = buildBugReportPreflightDeck( + BugReportPreflightEvidence( + requestedSettings = settings, + nativeLowLatencyDecoderEnabled = true, + ), + ) + + val warning = deck.cards.first() + assertEquals(4, deck.cards.size) + assertEquals(BugReportPreflightTone.Warning, warning.tone) + assertEquals("EXPERIMENTAL FEATURE DETECTED", warning.label) + assertTrue(warning.summary.contains("explicitly acknowledge sending anyway")) + assertTrue(warning.facts.contains("Native streamer (Experimental): On")) + assertEquals("Turn it off and reproduce the issue again", warning.recommendations.single().title) + } + + @Test + fun standardPreflightDoesNotShowExperimentalNativeStreamerWarning() { + val deck = buildBugReportPreflightDeck( + BugReportPreflightEvidence(requestedSettings = settings), + ) + + assertEquals(3, deck.cards.size) + assertFalse(deck.cards.any { it.label == "EXPERIMENTAL FEATURE DETECTED" }) + } + + @Test + fun healthySixGhzDoesNotSuggestChangingWifiBand() { + val deck = buildBugReportPreflightDeck( + BugReportPreflightEvidence( + requestedSettings = settings, + runtimeStats = StreamRuntimeStats( + bitrateKbps = 28_000, + availableIncomingBitrateKbps = 74_000, + pingMs = 24, + fps = 60, + resolution = "1920x1080", + codec = "H264", + jitterMs = 3.0, + packetLossPct = 0.05, + ), + runtimeDiagnostics = AndroidRuntimeDiagnosticsSnapshot( + thermalStatus = AndroidThermalStatus.None, + networkKind = AndroidNetworkKind.Wifi, + networkSignalBars = 4, + networkDownstreamKbps = 200_000, + wifiFrequencyMhz = 6_115, + wifiBand = AndroidWifiBand.SixGhz, + ), + ), + ) + + val connection = deck.cards.first() + assertEquals(BugReportPreflightTone.Healthy, connection.tone) + assertTrue(connection.facts.contains("6 GHz")) + assertTrue(connection.recommendations.isEmpty()) + assertFalse(connection.toString().contains("Use 5 GHz")) + assertFalse(connection.toString().contains("2.4 GHz Wi-Fi")) + assertEquals( + null, + bugReportKnownIssueBlock( + title = "High ping and lag", + description = "The game has high latency even though the connection looks normal during this session.", + deck = deck, + ), + ) + val video = deck.cards[1] + assertTrue(video.facts.contains("Requested max 35 Mbps")) + assertTrue(video.facts.contains("28 Mbps video")) + assertTrue(video.facts.contains("74 Mbps WebRTC receive estimate")) + } + + @Test + fun runtimeBitrateStatusShowsActualAndRequestedMaximum() { + assertEquals("28.4 Mbps / 35 Mbps max", formatRuntimeBitrateStatus(28_400, 35)) + assertEquals("-- / 35 Mbps max", formatRuntimeBitrateStatus(null, 35)) + } + + @Test + fun degradedTwoPointFourGhzShowsOnlyMatchedNetworkActions() { + val deck = buildBugReportPreflightDeck( + BugReportPreflightEvidence( + requestedSettings = settings, + runtimeStats = StreamRuntimeStats( + bitrateKbps = 2_000, + pingMs = 155, + fps = 42, + resolution = "1280x720", + codec = "H264", + jitterMs = 28.0, + packetLossPct = 2.2, + ), + runtimeDiagnostics = AndroidRuntimeDiagnosticsSnapshot( + thermalStatus = AndroidThermalStatus.None, + networkKind = AndroidNetworkKind.Wifi, + networkSignalBars = 2, + networkDownstreamKbps = 12_000, + wifiFrequencyMhz = 2_412, + wifiBand = AndroidWifiBand.TwoPointFourGhz, + ), + ), + ) + + val connection = deck.cards.first() + val titles = connection.recommendations.map { it.title } + assertEquals(BugReportPreflightTone.Warning, connection.tone) + assertTrue(titles.contains("Use 5 GHz or 6 GHz Wi-Fi")) + assertTrue(titles.contains("Reduce packet loss")) + assertTrue(titles.contains("Stabilize latency")) + assertTrue(titles.contains("Lower the maximum bitrate")) + val block = requireNotNull( + bugReportKnownIssueBlock( + title = "High ping and lag", + description = "The stream feels delayed and stutters while I am playing over this connection.", + deck = deck, + ), + ) + assertEquals("network-2.4ghz", block.key) + assertTrue(block.action.contains("5/6 GHz")) + assertTrue(block.action.contains("cellular")) + assertTrue(block.action.contains("may result in a bug-reporting ban")) + assertFalse(bugReportKnownIssueAllowsSubmission(block, null)) + assertTrue(bugReportKnownIssueAllowsSubmission(block, block.key)) + assertEquals( + null, + bugReportKnownIssueBlock( + title = "Flag icon is incorrect", + description = "The country flag icon has the wrong colors after opening the settings page.", + deck = deck, + ), + ) + } + + @Test + fun possibleBanWarningRequiresExactLagWordAndMeasuredCause() { + val degradedDeck = buildBugReportPreflightDeck( + BugReportPreflightEvidence( + requestedSettings = settings, + runtimeStats = StreamRuntimeStats(pingMs = 150, packetLossPct = 3.0), + runtimeDiagnostics = AndroidRuntimeDiagnosticsSnapshot( + networkKind = AndroidNetworkKind.Wifi, + wifiBand = AndroidWifiBand.TwoPointFourGhz, + ), + ), + ) + val lagBlock = requireNotNull( + bugReportKnownIssueBlock("Lag", "The stream has lag.", degradedDeck), + ) + assertTrue(lagBlock.action.contains("bug-reporting ban")) + + val laggyBlock = requireNotNull( + bugReportKnownIssueBlock("Laggy stream", "The stream stutters.", degradedDeck), + ) + assertFalse(laggyBlock.action.contains("bug-reporting ban")) + + val healthyDeck = buildBugReportPreflightDeck(BugReportPreflightEvidence(requestedSettings = settings)) + assertEquals(null, bugReportKnownIssueBlock("Lag", "The stream has lag.", healthyDeck)) + } + + @Test + fun ethernetSessionNeverGetsWifiAdvice() { + val deck = buildBugReportPreflightDeck( + BugReportPreflightEvidence( + requestedSettings = settings, + runtimeStats = StreamRuntimeStats( + bitrateKbps = 30_000, + pingMs = 18, + fps = 60, + packetLossPct = 0.0, + ), + runtimeDiagnostics = AndroidRuntimeDiagnosticsSnapshot( + networkKind = AndroidNetworkKind.Ethernet, + networkDownstreamKbps = 500_000, + ), + ), + ) + + val connection = deck.cards.first() + assertEquals(BugReportPreflightTone.Healthy, connection.tone) + assertTrue(connection.facts.contains("LAN")) + assertTrue(connection.recommendations.isEmpty()) + assertFalse(connection.toString().contains("Wi-Fi")) + } + + @Test + fun videoAdviceMatchesActualDecoderAndThermalState() { + val deck = buildBugReportPreflightDeck( + BugReportPreflightEvidence( + requestedSettings = settings.copy(codec = VideoCodec.H265), + runtimeStats = StreamRuntimeStats( + bitrateKbps = 18_000, + pingMs = 28, + fps = 30, + resolution = "1920x1080", + codec = "H265", + decodeMs = 15.5, + packetLossPct = 0.0, + ), + runtimeDiagnostics = AndroidRuntimeDiagnosticsSnapshot( + thermalStatus = AndroidThermalStatus.Severe, + networkKind = AndroidNetworkKind.Ethernet, + networkDownstreamKbps = 500_000, + ), + codecReport = RuntimeCodecReport( + capabilities = listOf( + CodecCapability( + codec = VideoCodec.H265, + decoderAvailable = true, + encoderAvailable = false, + hardwareDecoder = false, + hardwareEncoder = false, + webRtcDecoderAvailable = true, + webRtcHardwareDecoderAvailable = false, + ), + CodecCapability( + codec = VideoCodec.H264, + decoderAvailable = true, + encoderAvailable = false, + hardwareDecoder = true, + hardwareEncoder = false, + webRtcDecoderAvailable = true, + webRtcHardwareDecoderAvailable = true, + ), + ), + nativeRuntimeSummary = "test", + androidTvProfile = false, + lowPowerGpuProfile = false, + ), + ), + ) + + val video = deck.cards[1] + val titles = video.recommendations.map { it.title } + assertEquals(BugReportPreflightTone.Warning, video.tone) + assertTrue(video.facts.contains("Software decoder")) + assertTrue(video.facts.contains("Thermal severe")) + assertTrue(titles.contains("Decoder could not keep up")) + assertTrue(titles.contains("Let the device cool down")) + assertTrue(titles.contains("Use a hardware-decoded codec")) + assertTrue(video.recommendations.any { it.detail.contains("Try H264") }) + assertEquals( + "video-device-measured", + bugReportKnownIssueBlock( + title = "Low FPS and video stutter", + description = "The video becomes choppy and slow after the phone gets hot during a stream.", + deck = deck, + )?.key, + ) + } + + @Test + fun lagReportAboveDetectedRecommendationRequiresExplicitOverride() { + val recommended = settings + val selected = settings.copy( + resolution = "2560x1440", + fps = 120, + maxBitrateMbps = 75, + ) + val deck = buildBugReportPreflightDeck( + BugReportPreflightEvidence( + requestedSettings = selected, + recommendedSettings = recommended, + runtimeStats = StreamRuntimeStats( + bitrateKbps = 32_000, + pingMs = 20, + fps = 72, + resolution = "2560x1440", + codec = "H264", + jitterMs = 2.0, + packetLossPct = 0.0, + ), + runtimeDiagnostics = AndroidRuntimeDiagnosticsSnapshot( + thermalStatus = AndroidThermalStatus.None, + networkKind = AndroidNetworkKind.Ethernet, + networkDownstreamKbps = 500_000, + ), + ), + ) + + val video = deck.cards[1] + assertEquals(BugReportPreflightTone.Warning, video.tone) + assertEquals("Selected settings exceed the device recommendation", video.title) + assertTrue(video.facts.any { it.startsWith("Detected Recommended 1920x1080@60") }) + assertTrue(video.recommendations.any { it.title == "Use the detected Recommended profile" }) + + val block = requireNotNull( + bugReportKnownIssueBlock( + title = "Lag and low FPS", + description = "The stream stutters and feels slow while I play.", + deck = deck, + ), + ) + assertEquals("device-profile-override", block.key) + assertTrue(block.title.contains("exceeds this device's recommendation")) + assertFalse(bugReportKnownIssueAllowsSubmission(block, null)) + assertTrue(bugReportKnownIssueAllowsSubmission(block, block.key)) + assertEquals( + null, + bugReportKnownIssueBlock( + title = "Wrong game artwork", + description = "The store card uses the wrong image after refresh.", + deck = deck, + ), + ) + } + + @Test + fun inputCardDistinguishesCapturedMouseFromMissingEvidence() { + val missing = buildBugReportPreflightDeck( + BugReportPreflightEvidence(requestedSettings = settings), + ).cards.last() + assertEquals(BugReportPreflightTone.Notice, missing.tone) + assertEquals("For an input problem, reproduce it once", missing.recommendations.single().title) + + val captured = buildBugReportPreflightDeck( + BugReportPreflightEvidence( + requestedSettings = settings, + inputDiagnostics = """ + input channel open label=input_channel_v1 + input channel open label=input_channel_partially_reliable + external mouse move sent source=131076 device=7 mode=relative + """.trimIndent(), + ), + ).cards.last() + assertEquals(BugReportPreflightTone.Healthy, captured.tone) + assertTrue(captured.facts.any { it.contains("External mouse") }) + assertTrue(captured.facts.contains("Input channels opened")) + assertTrue(captured.facts.contains("Mouse movement sent")) + assertTrue(captured.recommendations.isEmpty()) + } + + @Test + fun disconnectedInputOnlyBlocksMatchingInputReports() { + val deck = buildBugReportPreflightDeck( + BugReportPreflightEvidence( + requestedSettings = settings, + inputDiagnostics = "external mouse input dropped noOpenChannel", + ), + ) + + assertEquals( + "input-measured", + bugReportKnownIssueBlock( + title = "Mouse input does not work", + description = "The cursor stops moving after the stream reconnects and clicks no longer reach the game.", + deck = deck, + )?.key, + ) + assertEquals( + null, + bugReportKnownIssueBlock( + title = "Store artwork is missing", + description = "Several game cards show a blank image after I return from the library page.", + deck = deck, + ), + ) + } + + @Test + fun manualServerSelectionWarnsAndGatesMatchingStreamReports() { + val deck = buildBugReportPreflightDeck( + BugReportPreflightEvidence( + requestedSettings = settings, + runtimeStats = StreamRuntimeStats( + pingMs = 24, + fps = 60, + jitterMs = 1.0, + packetLossPct = 0.0, + ), + runtimeDiagnostics = AndroidRuntimeDiagnosticsSnapshot( + networkKind = AndroidNetworkKind.Ethernet, + networkDownstreamKbps = 500_000, + ), + serverZone = "np-lon-06", + manuallySelectedServer = true, + ), + ) + + val connection = deck.cards.first { it.area == BugReportPreflightArea.Connection } + assertEquals(BugReportPreflightTone.Warning, connection.tone) + assertEquals("A manually selected server may explain the issue", connection.title) + assertTrue(connection.facts.contains("Server np-lon-06")) + assertTrue(connection.facts.contains("Manual server selection")) + assertTrue(connection.summary.contains("may not be investigated")) + + val block = requireNotNull( + bugReportKnownIssueBlock( + title = "FPS drops to 30", + description = "The video becomes choppy even though my bandwidth is fast.", + deck = deck, + ), + ) + assertEquals("network-manual-server", block.key) + assertTrue(block.action.contains("may not be investigated")) + assertFalse(bugReportKnownIssueAllowsSubmission(block, null)) + assertTrue(bugReportKnownIssueAllowsSubmission(block, block.key)) + } + + @Test + fun manualServerSelectionDoesNotGateUnrelatedReports() { + val deck = buildBugReportPreflightDeck( + BugReportPreflightEvidence( + requestedSettings = settings, + manuallySelectedServer = true, + ), + ) + + assertEquals( + null, + bugReportKnownIssueBlock( + title = "Wrong store artwork", + description = "The library card displays an image from a different game.", + deck = deck, + ), + ) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/BugReportsTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/BugReportsTest.kt new file mode 100644 index 000000000..c5bb84778 --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/BugReportsTest.kt @@ -0,0 +1,423 @@ +package com.opencloudgaming.opennow + +import okio.Buffer +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class BugReportsTest { + private val reporterId = androidBugReportReporterId("test-gfn-device-id") + + @Test + fun buildsPrintedWasteMultipartReportWithRedactedLogAttachment() { + val request = buildAndroidBugReportRequest( + AndroidBugReport( + title = " Stream froze ", + description = " Video stopped after reconnecting and remained frozen until I restarted the session. ", + versionName = "0.9.0", + versionCode = "45", + reporterId = reporterId, + appLanguageSelectionTag = "en-US", + languageCheck = englishLanguageCheck, + metadata = """{"device":"Pixel 9","sessionId":"[redacted]"}""", + files = listOf( + AndroidBugReportAttachment( + fileName = "opennow.log", + contentType = "text/plain; charset=utf-8", + bytes = "sessionId=[redacted]".toByteArray(), + ), + ), + ), + ) + + val buffer = Buffer() + requireNotNull(request.body).writeTo(buffer) + val multipart = buffer.readUtf8() + + assertEquals(ANDROID_BUG_REPORT_ENDPOINT, request.url.toString()) + assertEquals("POST", request.method) + assertTrue(multipart.contains("name=\"title\"\r\n\r\nStream froze")) + assertTrue( + multipart.contains( + "name=\"description\"\r\n\r\nVideo stopped after reconnecting and remained frozen until I restarted the session.", + ), + ) + assertTrue(multipart.contains("name=\"versionName\"\r\n\r\n0.9.0")) + assertTrue(multipart.contains("name=\"versionCode\"\r\n\r\n45")) + assertTrue(multipart.contains("name=\"platform\"\r\n\r\nandroid")) + assertTrue(multipart.contains("name=\"reporterId\"\r\n\r\n$reporterId")) + assertTrue(multipart.contains("name=\"files\"; filename=\"opennow.log\"")) + assertTrue(multipart.contains("sessionId=[redacted]")) + } + + @Test + fun metadataIncludesDeviceAndAndroidContextForTriage() { + val fileName = "opennow-android-logs-20260718-123456.txt" + val metadata = buildAndroidBugReportMetadata(fileName, device = testDeviceDiagnostics) + + assertTrue(metadata.contains("\"source\":\"settings-advanced-debug-logs\"")) + assertTrue(metadata.contains("\"attachment\":\"$fileName\"")) + assertTrue(metadata.contains("\"manufacturer\":\"Google\"")) + assertTrue(metadata.contains("\"model\":\"Pixel_9_Pro\"")) + assertTrue(metadata.contains("\"sdk\":35")) + assertTrue(metadata.contains("\"targetSdk\":36")) + assertTrue(metadata.contains("\"supportedAbis\":[\"arm64-v8a\"]")) + assertTrue(metadata.contains("\"widthPixels\":1440")) + assertFalse(metadata.contains("sessionId")) + } + + @Test + fun metadataRecordsAUserAcknowledgedKnownIssueOverride() { + val metadata = buildAndroidBugReportMetadata( + logFileName = "opennow-android-logs.txt", + knownIssueOverrideKey = "network-2.4ghz", + ) + + assertTrue(metadata.contains("\"knownIssueOverride\":true")) + assertTrue(metadata.contains("\"knownIssueKey\":\"network-2.4ghz\"")) + } + + private val testDeviceDiagnostics = AndroidDeviceDiagnosticsSnapshot( + manufacturer = "Google", + brand = "google", + model = "Pixel_9_Pro", + deviceCodename = "komodo", + product = "komodo", + hardware = "komodo", + board = "komodo", + androidRelease = "15", + androidCodename = "REL", + androidSdk = 35, + targetSdk = 36, + securityPatch = "2026-07-05", + supportedAbis = listOf("arm64-v8a"), + is64BitRuntime = true, + processorCount = 8, + totalMemoryMiB = 12_288, + lowRamDevice = false, + displayWidthPixels = 1440, + displayHeightPixels = 3120, + densityDpi = 512, + smallestScreenWidthDp = 411, + formFactor = "phone", + emulator = false, + ) + + @Test(expected = IllegalArgumentException::class) + fun rejectsMoreThanFiveFiles() { + val files = (1..6).map { index -> + AndroidBugReportAttachment("$index.log", "text/plain", byteArrayOf()) + } + buildAndroidBugReportRequest( + AndroidBugReport( + "Title", + "The stream stopped decoding video after a reconnect and did not recover.", + "0.9.0", + "45", + reporterId, + "en", + englishLanguageCheck, + "{}", + files, + ), + ) + } + + @Test(expected = IllegalArgumentException::class) + fun rejectsFilesLargerThanTenMib() { + buildAndroidBugReportRequest( + AndroidBugReport( + "Title", + "The stream stopped decoding video after a reconnect and did not recover.", + "0.9.0", + "45", + reporterId, + "en", + englishLanguageCheck, + "{}", + listOf( + AndroidBugReportAttachment( + "too-large.log", + "text/plain", + ByteArray(ANDROID_BUG_REPORT_MAX_FILE_BYTES.toInt() + 1), + ), + ), + ), + ) + } + + @Test + fun parsesServerReferenceWhenPresent() { + assertEquals("report-123", parseAndroidBugReportReference("""{"id":"report-123"}""")) + assertEquals("report-456", parseAndroidBugReportReference("""{"reportId":" report-456 "}""")) + assertEquals("report-789", parseAndroidBugReportReference("""{"bugReportId":"report-789"}""")) + assertEquals(null, parseAndroidBugReportReference("""{"ok":true}""")) + } + + @Test + fun acceptedReceiptReturnsTheReportId() { + assertEquals( + "report-456", + parseAndroidBugReportReceipt("""{"ok":true,"reportId":"report-456"}""").reference, + ) + } + + @Test(expected = AndroidBugReportUploadException::class) + fun acceptedReceiptWithoutAReportIdIsRejected() { + parseAndroidBugReportReceipt("""{"ok":true}""") + } + + @Test(expected = IllegalArgumentException::class) + fun rejectsDescriptionsShorterThanFiftyCharacters() { + buildAndroidBugReportRequest( + AndroidBugReport( + title = "Lag", + description = "It lagged.", + versionName = "1.0.5", + versionCode = "60", + reporterId = reporterId, + appLanguageSelectionTag = "en", + languageCheck = englishLanguageCheck, + metadata = "{}", + files = emptyList(), + ), + ) + } + + @Test + fun playStoreReportsRequireAFreshCurrentVersionCheck() { + val playUpdate = AndroidUpdateState( + installSource = AndroidAppInstallSource(setOf(GOOGLE_PLAY_STORE_PACKAGE)), + status = AndroidUpdateStatus.NotAvailable, + ) + + assertFalse( + androidBugReportsAllowed( + playUpdate, + AndroidBugReportVersionCheckState(AndroidBugReportVersionCheckStatus.NotChecked), + ), + ) + assertTrue( + androidBugReportsAllowed( + playUpdate, + AndroidBugReportVersionCheckState(AndroidBugReportVersionCheckStatus.Current), + ), + ) + assertFalse( + androidBugReportsAllowed( + playUpdate.copy(status = AndroidUpdateStatus.Available), + AndroidBugReportVersionCheckState(AndroidBugReportVersionCheckStatus.Current), + ), + ) + } + + @Test + fun sideloadReportsDoNotDependOnGooglePlayVerification() { + val sideloadUpdate = AndroidUpdateState( + installSource = AndroidAppInstallSource(emptySet()), + status = AndroidUpdateStatus.Idle, + ) + + assertTrue( + androidBugReportsAllowed( + sideloadUpdate, + AndroidBugReportVersionCheckState(AndroidBugReportVersionCheckStatus.NotChecked), + ), + ) + } + + @Test + fun reporterIdIsStableButDoesNotExposeTheRawProviderDeviceId() { + val rawDeviceId = "4fe17fe6-4b40-4897-bc3a-1e61cb4fd3aa" + val first = androidBugReportReporterId(rawDeviceId) + val second = androidBugReportReporterId(rawDeviceId) + val different = androidBugReportReporterId("a-different-installation") + + assertEquals(first, second) + assertTrue(first.startsWith(ANDROID_BUG_REPORT_REPORTER_ID_PREFIX)) + assertEquals(ANDROID_BUG_REPORT_REPORTER_ID_PREFIX.length + 64, first.length) + assertFalse(first.contains(rawDeviceId)) + assertFalse(first == different) + } + + @Test + fun parsesStructuredBanMessageForDisplay() { + val error = parseAndroidBugReportServerError( + body = """ + { + "ok": false, + "error": { + "code": "REPORTER_BANNED", + "message": "Bug reporting is disabled for this installation. Contact support if this is a mistake.", + "retryable": false + } + } + """.trimIndent(), + statusCode = 403, + ) + + assertEquals("REPORTER_BANNED", error.code) + assertEquals( + "Bug reporting is disabled for this installation. Contact support if this is a mistake.", + error.message, + ) + assertEquals(false, error.retryable) + } + + @Test + fun nonJsonFailureUsesSafeStatusMessageInsteadOfRawResponse() { + val error = parseAndroidBugReportServerError( + body = "private reverse proxy failure details", + statusCode = 502, + ) + + assertEquals("Bug report upload failed (HTTP 502).", error.message) + assertFalse(error.message.contains("private reverse proxy")) + } + + @Test + fun rejectsRandomOrRepeatedPaddingThatOnlyPassesTheRawCharacterLimit() { + assertTrue( + androidBugReportDescriptionError("eworuejwgojug ".repeat(8)) + ?.contains("complete English sentences") == true, + ) + assertTrue( + androidBugReportDescriptionError( + "the stream froze while loading the game the stream froze while loading the game", + )?.contains("repeated or random text") == true, + ) + } + + @Test + fun acceptsDetailedDescriptionWithEnoughMeaningfulEnglishWords() { + assertEquals( + null, + androidBugReportDescriptionError( + "The video froze after I reopened the app, while audio continued until I ended the stream.", + ), + ) + } + + @Test + fun languageCandidatesMustConfidentlyIdentifyEnglish() { + assertEquals( + null, + androidBugReportLanguageError( + listOf(AndroidBugReportLanguageCandidate("en", 0.91f)), + ), + ) + assertTrue( + androidBugReportLanguageError( + listOf( + AndroidBugReportLanguageCandidate("es", 0.82f), + AndroidBugReportLanguageCandidate("en", 0.12f), + ), + )?.contains("clear English") == true, + ) + assertTrue( + androidBugReportLanguageError( + listOf(AndroidBugReportLanguageCandidate("und", 1.0f)), + )?.contains("unrecognizable") == true, + ) + } + + @Test + fun mlKitNullFailureCanUseAlreadyValidatedDetailedReport() { + val check = androidBugReportLanguageCheckAfterMlKitNullFailure( + title = "Video freezes after reconnect", + description = "The video stopped after reconnecting, but audio continued until I manually ended the stream.", + ) + + assertEquals("en", check.languageTag) + assertEquals(ANDROID_BUG_REPORT_MIN_ENGLISH_CONFIDENCE, check.confidence) + } + + @Test(expected = IllegalArgumentException::class) + fun mlKitNullFailureDoesNotBypassContentValidation() { + androidBugReportLanguageCheckAfterMlKitNullFailure( + title = "Lag", + description = "It lagged.", + ) + } + + @Test(expected = IllegalArgumentException::class) + fun requestBuilderRejectsReportsWhenTheAppLocaleIsNotEnglish() { + buildAndroidBugReportRequest( + validReport().copy(appLanguageSelectionTag = "fr-FR"), + ) + } + + @Test(expected = IllegalArgumentException::class) + fun requestBuilderRejectsReportsWithoutAConfidentEnglishLanguageCheck() { + buildAndroidBugReportRequest( + validReport().copy( + languageCheck = AndroidBugReportLanguageCheck("es", 0.97f), + ), + ) + } + + @Test + fun appLocaleGateAllowsAnEnglishAppOrDeviceLanguage() { + assertTrue(androidAppLocaleIsEnglish("en-CA")) + assertTrue(androidAppLocaleIsEnglish("en_GB")) + assertFalse(androidAppLocaleIsEnglish("fr-CA")) + assertFalse(androidAppLocaleIsEnglish("")) + assertTrue( + AndroidAppLocaleState( + selectedLanguageTag = "", + effectiveLanguageTag = "en-CA", + ).bugReportsAllowed, + ) + assertTrue( + AndroidAppLocaleState( + selectedLanguageTag = "fr", + effectiveLanguageTag = "fr-FR", + deviceLanguageTag = "en-US", + ).bugReportsAllowed, + ) + assertTrue( + AndroidAppLocaleState( + selectedLanguageTag = "en", + effectiveLanguageTag = "en-CA", + deviceLanguageTag = "fr-FR", + ).bugReportsAllowed, + ) + assertFalse( + AndroidAppLocaleState( + selectedLanguageTag = "fr", + effectiveLanguageTag = "fr-FR", + deviceLanguageTag = "de-DE", + ).bugReportsAllowed, + ) + assertEquals( + "en-US", + AndroidAppLocaleState("fr", "fr-FR", "en-US").bugReportLanguageTag, + ) + } + + @Test + fun androidAppLanguageSelectionSupportsEveryBundledLocale() { + assertTrue(androidAppLanguageSelectionIsSupported("")) + listOf("en", "ar", "de", "es", "fr", "ja", "ko", "nl", "pl", "pt", "ro", "ru", "tr", "zh-Hans") + .forEach { languageTag -> + assertTrue(languageTag, androidAppLanguageSelectionIsSupported(languageTag)) + } + assertFalse(androidAppLanguageSelectionIsSupported("pt-BR")) + assertFalse(androidAppLanguageSelectionIsSupported("zh-Hant")) + } + + private fun validReport() = AndroidBugReport( + title = "Video freezes after reconnect", + description = "The video stopped after reconnecting, but audio continued until I manually ended the stream.", + versionName = "1.2.2", + versionCode = "78", + reporterId = reporterId, + appLanguageSelectionTag = "en-CA", + languageCheck = englishLanguageCheck, + metadata = "{}", + files = emptyList(), + ) + + private val englishLanguageCheck = AndroidBugReportLanguageCheck("en", 0.95f) +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/CatalogBackgroundTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/CatalogBackgroundTest.kt new file mode 100644 index 000000000..7922b2678 --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/CatalogBackgroundTest.kt @@ -0,0 +1,72 @@ +package com.opencloudgaming.opennow + +import java.io.File +import java.nio.file.Files +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class CatalogBackgroundTest { + @Test + fun `blank custom source selects the chosen built in background`() { + assertEquals( + CatalogWallpaperSelection.BuiltIn(CatalogBackgroundPreset.ColorfulAbstract), + catalogWallpaperSelection(CatalogBackgroundPreset.ColorfulAbstract, null), + ) + assertEquals( + CatalogWallpaperSelection.BuiltIn(CatalogBackgroundPreset.Original), + catalogWallpaperSelection(CatalogBackgroundPreset.Original, " "), + ) + assertEquals( + CatalogWallpaperSelection.BuiltIn(CatalogBackgroundPreset.AbsoluteCinema), + catalogWallpaperSelection(CatalogBackgroundPreset.AbsoluteCinema, null), + ) + } + + @Test + fun `custom source replaces the bundled default`() { + assertEquals( + CatalogWallpaperSelection.Custom("file:///data/user/0/opennow/files/custom"), + catalogWallpaperSelection( + CatalogBackgroundPreset.ColorfulAbstract, + " file:///data/user/0/opennow/files/custom ", + ), + ) + } + + @Test + fun `managed background cleanup cannot escape app files directory`() { + val filesDir = Files.createTempDirectory("catalog-background-test").toFile() + val outsideDir = Files.createTempDirectory("catalog-background-outside").toFile() + try { + assertTrue( + isManagedCatalogBackgroundImageFile( + filesDir, + File(filesDir, CATALOG_BACKGROUND_IMAGE_FILE_PREFIX), + ), + ) + assertTrue( + isManagedCatalogBackgroundImageFile( + filesDir, + File(filesDir, "$CATALOG_BACKGROUND_IMAGE_FILE_PREFIX-unique"), + ), + ) + assertFalse( + isManagedCatalogBackgroundImageFile( + filesDir, + File(filesDir, "unrelated-image"), + ), + ) + assertFalse( + isManagedCatalogBackgroundImageFile( + filesDir, + File(outsideDir, "$CATALOG_BACKGROUND_IMAGE_FILE_PREFIX-unique"), + ), + ) + } finally { + filesDir.deleteRecursively() + outsideDir.deleteRecursively() + } + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/CatalogCachePrimingTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/CatalogCachePrimingTest.kt new file mode 100644 index 000000000..324c71d56 --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/CatalogCachePrimingTest.kt @@ -0,0 +1,90 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Startup paints the cached catalogue before any network work. These pin what "usable cache" means + * and that the primed snapshot is matched to the query that will actually be run. + */ +class CatalogCachePrimingTest { + private fun game(id: String) = GameInfo(id = id, title = id) + + private fun key( + search: String = "", + sort: String = CATALOG_SORT_DEFAULT, + filters: List = emptyList(), + ) = CatalogCacheKey.of("user", "https://example.test", search, sort, filters) + + private fun snapshot( + key: CatalogCacheKey = key(), + main: List? = null, + library: List? = null, + catalog: CatalogBrowseResult? = null, + ) = CatalogCacheSnapshot(key, main, library, catalog) + + @Test + fun theDefaultStoreViewIsNotAScopedQuery() { + assertEquals("most_popular", CATALOG_SORT_DEFAULT) + assertFalse(isScopedCatalogQuery("", CATALOG_SORT_DEFAULT, emptyList())) + assertTrue(isScopedCatalogQuery("", "relevance", emptyList())) + } + + @Test + fun search_filters_andSortEachScopeTheQuery() { + assertTrue(isScopedCatalogQuery("halo", CATALOG_SORT_DEFAULT, emptyList())) + assertTrue(isScopedCatalogQuery("", CATALOG_SORT_DEFAULT, listOf("genre-action"))) + assertTrue(isScopedCatalogQuery("", "latest", emptyList())) + } + + @Test + fun aCachedCatalogIsShownStraightAway() { + val cached = CatalogBrowseResult(listOf(game("a"), game("b"))) + assertEquals(listOf("a", "b"), primedStoreGames(snapshot(catalog = cached)).map { it.id }) + } + + @Test + fun theUnscopedStoreFallsBackToTheMainCache() { + assertEquals(listOf("a"), primedStoreGames(snapshot(main = listOf(game("a")))).map { it.id }) + } + + @Test + fun aScopedQueryWillNotBorrowTheUnscopedCache() { + // Default-ordered games under a user-chosen sort would be visibly wrong, not just stale. + val scoped = snapshot(key = key(sort = "latest"), main = listOf(game("a"))) + assertTrue(primedStoreGames(scoped).isEmpty()) + } + + @Test + fun aScopedQueryStillUsesItsOwnCachedResult() { + val scoped = snapshot( + key = key(sort = "latest"), + main = listOf(game("a")), + catalog = CatalogBrowseResult(listOf(game("z"))), + ) + assertEquals(listOf("z"), primedStoreGames(scoped).map { it.id }) + } + + @Test + fun anEmptyCacheOffersNothingToPaint() { + assertTrue(primedStoreGames(snapshot()).isEmpty()) + assertTrue(primedStoreGames(snapshot(main = emptyList())).isEmpty()) + } + + @Test + fun filterOrderDoesNotMakeThePrimedSnapshotMiss() { + // The store keys on sorted filters, so the in-memory handoff has to agree or startup + // silently reparses megabytes of JSON it already has. + assertEquals(key(filters = listOf("a", "b")), key(filters = listOf("b", "a"))) + } + + @Test + fun aDifferentQueryDoesNotReuseThePrimedSnapshot() { + assertNotEquals(key(), key(sort = "latest")) + assertNotEquals(key(), key(search = "halo")) + assertNotEquals(key(), key(filters = listOf("genre-action"))) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/CatalogCacheStoreTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/CatalogCacheStoreTest.kt new file mode 100644 index 000000000..2191107f5 --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/CatalogCacheStoreTest.kt @@ -0,0 +1,78 @@ +package com.opencloudgaming.opennow + +import java.nio.file.Files +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class CatalogCacheStoreTest { + @Test + fun compressedPopularCatalogSurvivesAStoreRecreation() { + val directory = Files.createTempDirectory("opennow-catalog-cache").toFile() + try { + val games = (0 until 360).map { index -> + GameInfo( + id = "popular-$index", + title = "Popular game $index", + longDescription = "compressible catalog metadata ".repeat(180), + ) + } + CatalogCacheStore(directory).saveCatalog( + userId = "user", + providerStreamingBaseUrl = "https://example.test", + searchQuery = "", + sortId = DEFAULT_CATALOG_SORT_ID, + filterIds = emptyList(), + result = CatalogBrowseResult(games), + ) + + val restored = CatalogCacheStore(directory).loadCatalog( + userId = "user", + providerStreamingBaseUrl = "https://example.test", + searchQuery = "", + sortId = DEFAULT_CATALOG_SORT_ID, + filterIds = emptyList(), + ) + + assertEquals(360, restored?.games?.size) + assertTrue(directory.listFiles().orEmpty().any { it.extension == "gz" }) + } finally { + directory.deleteRecursively() + } + } + + @Test + fun catalogFilesRemainSpecificToTheirSort() { + val directory = Files.createTempDirectory("opennow-catalog-cache-sort").toFile() + try { + val store = CatalogCacheStore(directory) + store.saveCatalog( + "user", + "base", + "", + DEFAULT_CATALOG_SORT_ID, + emptyList(), + CatalogBrowseResult(listOf(game("popular"))), + ) + store.saveCatalog( + "user", + "base", + "", + "relevance", + emptyList(), + CatalogBrowseResult(listOf(game("relevance"))), + ) + + val popular = store.loadCatalog("user", "base", "", DEFAULT_CATALOG_SORT_ID, emptyList()) + val relevance = store.loadCatalog("user", "base", "", "relevance", emptyList()) + assertEquals("popular", popular?.games?.single()?.id) + assertEquals("relevance", relevance?.games?.single()?.id) + assertEquals(2, store.clear()) + assertTrue(directory.listFiles().orEmpty().isEmpty()) + } finally { + directory.deleteRecursively() + } + } + + private fun game(id: String) = GameInfo(id = id, title = id) +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/CatalogLoadingStateTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/CatalogLoadingStateTest.kt new file mode 100644 index 000000000..1ebbbab7a --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/CatalogLoadingStateTest.kt @@ -0,0 +1,79 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * "No games loaded" is the answer to a finished request, not a stand-in for one still in flight. + * These pin the rule that decides which of the two the reader sees. + */ +class CatalogLoadingStateTest { + @Test + fun aWarmCacheThatShowsNothingKeepsTheStoreLoading() { + // The regression: a library-only cache satisfied "we have a cache" while leaving the Store + // grid empty, so the spinner dropped and the empty state showed over a live fetch. + assertTrue(catalogStillLoadingAfterCache(hasGamesToShow = false, keepRefreshVisible = false)) + } + + @Test + fun cachedGamesStopTheSpinner() { + assertFalse(catalogStillLoadingAfterCache(hasGamesToShow = true, keepRefreshVisible = false)) + } + + @Test + fun aManualPullKeepsItsIndicatorEvenOverCachedGames() { + assertTrue(catalogStillLoadingAfterCache(hasGamesToShow = true, keepRefreshVisible = true)) + } + + @Test + fun theEmptyStateIsOnlyReachableOnceLoadingHasStopped() { + // shouldShowCatalogLoadingPlaceholder is what stands between a live fetch and the + // "No games loaded" text; with nothing on screen it must win. + assertTrue( + shouldShowCatalogLoadingPlaceholder( + queryLoading = false, + loadingGames = catalogStillLoadingAfterCache(hasGamesToShow = false, keepRefreshVisible = false), + hasVisibleGames = false, + ), + ) + } + + @Test + fun aFinishedFetchWithNoResultsStillReachesTheEmptyState() { + // The flag must not be pinned true, or a genuinely empty catalogue would spin forever. + assertFalse( + shouldShowCatalogLoadingPlaceholder( + queryLoading = false, + loadingGames = false, + hasVisibleGames = false, + ), + ) + } + + @Test + fun backgroundRefreshDoesNotCoverVisibleStoreContentWithAnIndicator() { + assertFalse( + shouldShowCatalogRefreshIndicator( + loadingGames = true, + hasVisibleGames = true, + ), + ) + } + + @Test + fun initialStoreLoadStillShowsTheRefreshIndicator() { + assertTrue( + shouldShowCatalogRefreshIndicator( + loadingGames = true, + hasVisibleGames = false, + ), + ) + assertFalse( + shouldShowCatalogRefreshIndicator( + loadingGames = false, + hasVisibleGames = false, + ), + ) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/CatalogPresentationTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/CatalogPresentationTest.kt new file mode 100644 index 000000000..7b39d6939 --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/CatalogPresentationTest.kt @@ -0,0 +1,114 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class CatalogPresentationTest { + @Test + fun localAppPackagesAreTrimmedAndDeduplicatedInShelfOrder() { + assertEquals( + listOf("com.example.first", "com.example.second"), + normalizeLocalAppPackageNames( + listOf(" com.example.first ", "", "com.example.second", "com.example.first"), + ), + ) + } + + @Test + fun librarySortOffersRecentFirstAndAlphabeticalModes() { + val alpha = GameInfo(id = "a", title = "Alpha", lastPlayed = "2026-01-01") + val beta = GameInfo(id = "b", title = "Beta", lastPlayed = "2026-08-20") + val gamma = GameInfo(id = "c", title = "Gamma") + + assertEquals(listOf("Beta", "Alpha", "Gamma"), sortLibraryGames(listOf(gamma, alpha, beta), LIBRARY_SORT_RECENT).map { it.title }) + assertEquals(listOf("Alpha", "Beta", "Gamma"), sortLibraryGames(listOf(gamma, beta, alpha), LIBRARY_SORT_TITLE).map { it.title }) + } + + @Test + fun touchFilterUsesCatalogControlMetadataInStoreAndLibrary() { + val touchGame = GameInfo( + id = "touch", + title = "Touch game", + variants = listOf(GameVariant("touch-variant", "STEAM", supportedControls = listOf("TOUCHSCREEN", "GAMEPAD"))), + ) + val controllerGame = GameInfo( + id = "controller", + title = "Controller game", + variants = listOf(GameVariant("controller-variant", "STEAM", supportedControls = listOf("GAMEPAD"))), + ) + + assertEquals( + listOf(touchGame), + filterCatalogGamesForLocalControls(listOf(touchGame, controllerGame), listOf(CATALOG_FILTER_TOUCHSCREEN)), + ) + assertTrue(gameMatchesLibraryFilters(touchGame, listOf(CATALOG_FILTER_TOUCHSCREEN))) + assertFalse(gameMatchesLibraryFilters(controllerGame, listOf(CATALOG_FILTER_TOUCHSCREEN))) + assertEquals(listOf("Touchscreen", "Controller"), supportedControlLabels(touchGame)) + } + + @Test + fun scopedTvQueriesMatchTheNormalMobilePageBudget() { + assertEquals(3, catalogPageLimit(androidTvProfile = false, filterIds = emptyList())) + assertEquals( + MAX_CATALOG_REQUEST_PAGES, + catalogPageLimit(androidTvProfile = false, filterIds = listOf(CATALOG_FILTER_TOUCHSCREEN)), + ) + assertEquals( + 3, + catalogPageLimit(androidTvProfile = true, filterIds = listOf(CATALOG_FILTER_TOUCHSCREEN)), + ) + assertEquals(3, catalogPageLimit(androidTvProfile = true, filterIds = emptyList(), searchQuery = "halo")) + assertEquals(1, catalogPageLimit(androidTvProfile = true, filterIds = emptyList())) + } + + @Test + fun libraryCombinesTouchAndLauncherCategoriesInsteadOfBroadeningThem() { + val touchSteam = GameInfo( + id = "touch-steam", + title = "Touch Steam", + isInLibrary = true, + variants = listOf(GameVariant("one", "STEAM", supportedControls = listOf("TOUCHSCREEN"))), + ) + val touchEpic = GameInfo( + id = "touch-epic", + title = "Touch Epic", + isInLibrary = true, + variants = listOf(GameVariant("two", "EPIC", supportedControls = listOf("TOUCHSCREEN"))), + ) + val filters = listOf(CATALOG_FILTER_TOUCHSCREEN, "library_store:STEAM") + + assertTrue(gameMatchesLibraryFilters(touchSteam, filters)) + assertFalse(gameMatchesLibraryFilters(touchEpic, filters)) + } + + @Test + fun searchTermsAreSplitOncePerQueryAndAllMustMatch() { + val game = GameInfo( + id = "a", + title = "Alpha Strike", + description = "A tactical shooter", + publisherName = "Beta Studios", + ) + + // Every term has to hit somewhere in the haystack, not just the first. + assertTrue(gameMatchesSearch(game, searchTermsFor("alpha shooter"))) + assertTrue(gameMatchesSearch(game, searchTermsFor("beta strike"))) + assertFalse(gameMatchesSearch(game, searchTermsFor("alpha racing"))) + + // Blank and whitespace-only queries match everything rather than filtering to nothing. + assertEquals(emptyList(), searchTermsFor(" ")) + assertTrue(gameMatchesSearch(game, searchTermsFor(""))) + assertTrue(gameMatchesSearch(game, searchTermsFor(" "))) + + // Runs of whitespace collapse, and matching stays case-insensitive. + assertEquals(listOf("alpha", "strike"), searchTermsFor(" ALPHA Strike ")) + assertTrue(gameMatchesSearch(game, searchTermsFor(" ALPHA Strike "))) + + // The String overload stays equivalent to splitting first. + assertTrue(gameMatchesSearch(game, "alpha shooter")) + assertFalse(gameMatchesSearch(game, "alpha racing")) + } + +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/CatalogRecoveryTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/CatalogRecoveryTest.kt new file mode 100644 index 000000000..8621a5e98 --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/CatalogRecoveryTest.kt @@ -0,0 +1,73 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class CatalogRecoveryTest { + private fun game(id: String) = GameInfo(id = id, title = id) + + private fun retry( + signedIn: Boolean = true, + loadAttempted: Boolean = true, + hasGames: Boolean = false, + loadInFlight: Boolean = false, + streamActive: Boolean = false, + ) = shouldRetryCatalogLoad(signedIn, loadAttempted, hasGames, loadInFlight, streamActive) + + @Test + fun anEmptyStoreAfterAFailedLoadIsRetried() { + assertTrue(retry()) + } + + @Test + fun theFirstRunLoadIsNotRacedByTheForegroundHook() { + // The Activity resumes before the bootstrap has asked for anything; retrying here would + // run a second identical fetch alongside it. + assertFalse(retry(loadAttempted = false)) + } + + @Test + fun nothingIsRetriedWhileAFetchIsAlreadyRunning() { + assertFalse(retry(loadInFlight = true)) + } + + @Test + fun aPopulatedStoreIsLeftAlone() { + assertFalse(retry(hasGames = true)) + } + + @Test + fun signedOutAndStreamingBothSuppressTheRetry() { + assertFalse(retry(signedIn = false)) + assertFalse(retry(streamActive = true)) + } + + @Test + fun backoffGrowsAndIsCapped() { + assertEquals(CATALOG_RETRY_BASE_DELAY_MS, catalogRetryDelayMs(0)) + assertEquals(CATALOG_RETRY_BASE_DELAY_MS * 2, catalogRetryDelayMs(1)) + assertEquals(CATALOG_RETRY_BASE_DELAY_MS * 4, catalogRetryDelayMs(2)) + assertTrue(catalogRetryDelayMs(3) > catalogRetryDelayMs(2)) + assertEquals(CATALOG_RETRY_MAX_DELAY_MS, catalogRetryDelayMs(99)) + } + + @Test + fun theWholeLadderFitsInsideAReasonableWait() { + // Four attempts a reader would plausibly sit through rather than a minutes-long stall. + val total = (0 until CATALOG_RETRY_MAX_ATTEMPTS).sumOf { catalogRetryDelayMs(it) } + assertTrue("ladder took ${total}ms", total <= 60_000L) + } + + @Test + fun anyOneOfTheThreeListsCountsAsLoaded() { + val empty = OpenNowUiState() + assertFalse(empty.hasLoadedCatalogGames()) + assertTrue(empty.copy(games = listOf(game("a"))).hasLoadedCatalogGames()) + assertTrue(empty.copy(libraryGames = listOf(game("b"))).hasLoadedCatalogGames()) + assertTrue( + empty.copy(catalogResult = CatalogBrowseResult(listOf(game("c")))).hasLoadedCatalogGames(), + ) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/CellularNetworkStatusTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/CellularNetworkStatusTest.kt new file mode 100644 index 000000000..8f2e20e89 --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/CellularNetworkStatusTest.kt @@ -0,0 +1,24 @@ +package com.opencloudgaming.opennow + +import android.telephony.TelephonyDisplayInfo +import android.telephony.TelephonyManager +import org.junit.Assert.assertEquals +import org.junit.Test + +class CellularNetworkStatusTest { + @Test + fun carrierOverridesUseDisplayGeneration() { + assertEquals("5G+", cellularGenerationLabel(TelephonyManager.NETWORK_TYPE_LTE, TelephonyDisplayInfo.OVERRIDE_NETWORK_TYPE_NR_ADVANCED)) + assertEquals("5G", cellularGenerationLabel(TelephonyManager.NETWORK_TYPE_LTE, TelephonyDisplayInfo.OVERRIDE_NETWORK_TYPE_NR_NSA)) + assertEquals("LTE+", cellularGenerationLabel(TelephonyManager.NETWORK_TYPE_LTE, TelephonyDisplayInfo.OVERRIDE_NETWORK_TYPE_LTE_CA)) + } + + @Test + fun baseRadioTypesUseCompactLabels() { + assertEquals("5G", cellularGenerationLabel(TelephonyManager.NETWORK_TYPE_NR, TelephonyDisplayInfo.OVERRIDE_NETWORK_TYPE_NONE)) + assertEquals("LTE", cellularGenerationLabel(TelephonyManager.NETWORK_TYPE_LTE, TelephonyDisplayInfo.OVERRIDE_NETWORK_TYPE_NONE)) + assertEquals("H+", cellularGenerationLabel(TelephonyManager.NETWORK_TYPE_HSPAP, TelephonyDisplayInfo.OVERRIDE_NETWORK_TYPE_NONE)) + assertEquals("3G", cellularGenerationLabel(TelephonyManager.NETWORK_TYPE_UMTS, TelephonyDisplayInfo.OVERRIDE_NETWORK_TYPE_NONE)) + assertEquals("E", cellularGenerationLabel(TelephonyManager.NETWORK_TYPE_EDGE, TelephonyDisplayInfo.OVERRIDE_NETWORK_TYPE_NONE)) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/CodecProbePolicyTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/CodecProbePolicyTest.kt new file mode 100644 index 000000000..333e6cd42 --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/CodecProbePolicyTest.kt @@ -0,0 +1,79 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class CodecProbePolicyTest { + @Test + fun android16ExynosHevcMainDecoderIsEligibleForWebRtcProbe() { + assertTrue( + isSupportedExynosHevcDecoder( + codecName = "c2.exynos.hevc.decoder", + sdkInt = 36, + supportedTypes = listOf("video/hevc"), + hevcProfiles = listOf(1), + ), + ) + } + + @Test + fun android16ExynosHevcMain10DecoderIsEligibleForWebRtcProbe() { + assertTrue( + isSupportedExynosHevcDecoder( + codecName = "OMX.Exynos.HEVC.Decoder", + sdkInt = 36, + supportedTypes = listOf("VIDEO/HEVC"), + hevcProfiles = listOf(2), + ), + ) + } + + @Test + fun android16ExynosHevcHdr10ProfileIsEligibleForWebRtcProbe() { + assertTrue( + isSupportedExynosHevcDecoder( + codecName = "c2.exynos.hevc.decoder", + sdkInt = 36, + supportedTypes = listOf("video/hevc"), + hevcProfiles = listOf(4_096), + ), + ) + } + + @Test + fun legacyExynosHevcPolicyRemainsConservative() { + assertFalse( + isSupportedExynosHevcDecoder( + codecName = "c2.exynos.hevc.decoder", + sdkInt = 35, + supportedTypes = listOf("video/hevc"), + hevcProfiles = listOf(1, 2), + ), + ) + } + + @Test + fun exynosDecoderWithoutSupportedHevcProfileIsNotAdvertised() { + assertFalse( + isSupportedExynosHevcDecoder( + codecName = "c2.exynos.hevc.decoder", + sdkInt = 36, + supportedTypes = listOf("video/hevc"), + hevcProfiles = listOf(4), + ), + ) + } + + @Test + fun exynosAvcDecoderIsNotMistakenForHevc() { + assertFalse( + isSupportedExynosHevcDecoder( + codecName = "c2.exynos.h264.decoder", + sdkInt = 36, + supportedTypes = listOf("video/avc"), + hevcProfiles = listOf(1), + ), + ) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/ControllerFocusFrameTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/ControllerFocusFrameTest.kt new file mode 100644 index 000000000..4e80119d3 --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/ControllerFocusFrameTest.kt @@ -0,0 +1,60 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ControllerFocusFrameTest { + @Test + fun enhancedFrameRequiresFocusedTvOrPhysicalControllerCard() { + assertTrue( + shouldShowEnhancedControllerFocus( + focused = true, + tvProfile = true, + controllerActionMode = false, + ), + ) + assertTrue( + shouldShowEnhancedControllerFocus( + focused = true, + tvProfile = false, + controllerActionMode = true, + ), + ) + assertFalse( + shouldShowEnhancedControllerFocus( + focused = true, + tvProfile = false, + controllerActionMode = false, + ), + ) + assertFalse( + shouldShowEnhancedControllerFocus( + focused = false, + tvProfile = true, + controllerActionMode = false, + ), + ) + } + + @Test + fun energyOrbitLoopsAndKeepsStaticFlickerBounded() { + assertEquals(0f, controllerFocusOrbitPhasePx(progress = 0f, perimeterPx = 240f), 0f) + assertEquals(120f, controllerFocusOrbitPhasePx(progress = 0.5f, perimeterPx = 240f), 0f) + assertEquals(0f, controllerFocusOrbitPhasePx(progress = 1f, perimeterPx = 240f), 0f) + assertEquals(0, controllerFocusStaticStep(0f)) + assertEquals(24, controllerFocusStaticStep(0.5f)) + assertEquals(0, controllerFocusStaticStep(1f)) + (0..100).forEach { step -> + assertTrue(controllerFocusFlickerAlpha(step / 100f) in 0.72f..1f) + } + } + + @Test + fun interactionFocusFallsBackToOneStaticLineWithoutCinemaMotion() { + assertTrue(shouldDrawStaticInteractionFocus(visible = true, cinemaEffectEnabled = false)) + assertFalse(shouldDrawStaticInteractionFocus(visible = true, cinemaEffectEnabled = true)) + assertFalse(shouldDrawStaticInteractionFocus(visible = false, cinemaEffectEnabled = false)) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/DeviceLoginLayoutTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/DeviceLoginLayoutTest.kt new file mode 100644 index 000000000..c5185106b --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/DeviceLoginLayoutTest.kt @@ -0,0 +1,52 @@ +package com.opencloudgaming.opennow + +import android.content.res.Configuration +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class DeviceLoginLayoutTest { + @Test + fun usesSideBySideLayoutWhenHandheldIsLandscape() { + assertTrue( + shouldUseSideBySideDeviceLoginLayout( + orientation = Configuration.ORIENTATION_LANDSCAPE, + preferLandscapeLayout = false, + availableWidthDp = 640, + ), + ) + } + + @Test + fun keepsPortraitDeviceLoginStacked() { + assertFalse( + shouldUseSideBySideDeviceLoginLayout( + orientation = Configuration.ORIENTATION_PORTRAIT, + preferLandscapeLayout = false, + availableWidthDp = 640, + ), + ) + } + + @Test + fun keepsCrampedLandscapeDeviceLoginStacked() { + assertFalse( + shouldUseSideBySideDeviceLoginLayout( + orientation = Configuration.ORIENTATION_LANDSCAPE, + preferLandscapeLayout = false, + availableWidthDp = 480, + ), + ) + } + + @Test + fun honorsExplicitLandscapePreference() { + assertTrue( + shouldUseSideBySideDeviceLoginLayout( + orientation = Configuration.ORIENTATION_PORTRAIT, + preferLandscapeLayout = true, + availableWidthDp = 320, + ), + ) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/DiagnosticHistoryStoreTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/DiagnosticHistoryStoreTest.kt new file mode 100644 index 000000000..d9710dded --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/DiagnosticHistoryStoreTest.kt @@ -0,0 +1,101 @@ +package com.opencloudgaming.opennow + +import java.io.File +import java.nio.file.Files +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class DiagnosticHistoryStoreTest { + @Test + fun currentProcessSnapshotBecomesPreviousOnNextAppRun() { + val directory = Files.createTempDirectory("diagnostic-history").toFile() + val store = DiagnosticHistoryStore(directory) { 1234L } + + store.beginAppRun() + assertNull(store.previousSnapshot()) + store.saveCurrent("OpenNOW Android diagnostics\nevent.1 stream failed") + store.beginAppRun() + + assertEquals( + PreviousDiagnosticSnapshot( + capturedAtEpochMs = 1234L, + text = "OpenNOW Android diagnostics\nevent.1 stream failed", + ), + store.previousSnapshot(), + ) + } + + @Test + fun launchWithoutANewCurrentSnapshotPreservesPreviousEvidence() { + val directory = Files.createTempDirectory("diagnostic-history").toFile() + var now = 1L + val store = DiagnosticHistoryStore(directory) { now } + store.saveCurrent("first run") + store.beginAppRun() + assertEquals("first run", store.previousSnapshot()?.text) + + now = 2L + store.beginAppRun() + + assertEquals(1L, store.previousSnapshot()?.capturedAtEpochMs) + assertEquals("first run", store.previousSnapshot()?.text) + } + + @Test + fun latestSnapshotFromTheRunReplacesEarlierSnapshots() { + val directory = Files.createTempDirectory("diagnostic-history").toFile() + var now = 10L + val store = DiagnosticHistoryStore(directory) { now } + store.saveCurrent("early") + now = 20L + store.saveCurrent("latest") + + store.beginAppRun() + + assertEquals(20L, store.previousSnapshot()?.capturedAtEpochMs) + assertEquals("latest", store.previousSnapshot()?.text) + } + + @Test + fun corruptCurrentSnapshotDoesNotEraseTheLastReadableRun() { + val directory = Files.createTempDirectory("diagnostic-history").toFile() + val store = DiagnosticHistoryStore(directory) { 10L } + store.saveCurrent("readable previous run") + store.beginAppRun() + File(directory, "diagnostic-history/current.txt.gz").writeText("not gzip") + + store.beginAppRun() + + assertEquals("readable previous run", store.previousSnapshot()?.text) + } + + @Test + fun boundedSnapshotKeepsBothTheHeaderAndLatestEvidence() { + val original = "header-" + "x".repeat(500) + "-latest" + + val bounded = boundDiagnosticSnapshot(original, maxCharacters = 256) + + assertTrue(bounded.startsWith("header-")) + assertTrue(bounded.endsWith("-latest")) + assertTrue(bounded.contains("persisted diagnostic snapshot truncated")) + assertTrue(bounded.length <= 256) + } + + @Test + fun exportedLogLabelsPreviousRunWithoutChangingCurrentSection() { + val current = "OpenNOW Android diagnostics\nstreamStatus=idle" + val merged = appendPreviousDiagnosticSnapshot( + current = current, + previous = PreviousDiagnosticSnapshot(42L, "OpenNOW Android diagnostics\nstreamStatus=streaming"), + ) + + assertTrue(merged.startsWith(current)) + assertTrue(merged.contains("previousAppRun.capturedAtEpochMs=42")) + assertTrue(merged.contains("----- BEGIN PREVIOUS APP RUN -----")) + assertTrue(merged.contains("streamStatus=streaming")) + assertFalse(appendPreviousDiagnosticSnapshot(current, null).contains("previousAppRun")) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/DiagnosticsSanitizationTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/DiagnosticsSanitizationTest.kt new file mode 100644 index 000000000..0c2506232 --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/DiagnosticsSanitizationTest.kt @@ -0,0 +1,51 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class DiagnosticsSanitizationTest { + @Test + fun exportKeepsNonUniqueDeviceAndAndroidSupportContext() { + val raw = """ + device.identity manufacturer=NVIDIA brand=NVIDIA model=SHIELD_Android_TV codename=mdarcy product=mdarcy formFactor=tv emulator=false + android.os release=11 codename=REL sdk=30 targetSdk=36 securityPatch=2025-04-05 + device.hardware hardware=darcy board=darcy abis=arm64-v8a|armeabi-v7a runtimeBits=64 processors=8 memoryMiB=3072 lowRam=false + device.display pixels=3840x2160 densityDpi=320 smallestWidthDp=960 + """.trimIndent() + + val sanitized = sanitizeDiagnosticExport(raw) + + assertTrue(sanitized.contains("model=SHIELD_Android_TV")) + assertTrue(sanitized.contains("sdk=30")) + assertTrue(sanitized.contains("securityPatch=2025-04-05")) + assertTrue(sanitized.contains("abis=arm64-v8a|armeabi-v7a")) + assertTrue(sanitized.contains("pixels=3840x2160")) + } + + @Test + fun exportRemovesNamesTokensIdsAndNetworkAddresses() { + val raw = """ + user=Jane Example tier=FREE provider=NVIDIA + sessionId=01234567-89ab-4cde-8fab-0123456789ab sessionStatus=READY serverIp=192.168.10.42 + Authorization: Bearer secret.jwt.value + {"displayName":"Jane Example","email":"jane@example.com","deviceId":"device-secret"} + ipv6=2001:db8::1234 + """.trimIndent() + + val sanitized = sanitizeDiagnosticExport(raw) + + listOf( + "Jane Example", + "jane@example.com", + "secret.jwt.value", + "01234567-89ab-4cde-8fab-0123456789ab", + "192.168.10.42", + "2001:db8::1234", + "device-secret", + ).forEach { sensitive -> assertFalse("Leaked $sensitive in $sanitized", sanitized.contains(sensitive)) } + assertTrue(sanitized.contains("tier=FREE")) + assertTrue(sanitized.contains("provider=NVIDIA")) + assertTrue(sanitized.contains("[redacted]")) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/DisplayRefreshRateTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/DisplayRefreshRateTest.kt new file mode 100644 index 000000000..3843e8e36 --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/DisplayRefreshRateTest.kt @@ -0,0 +1,138 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertEquals +import org.junit.Test + +class DisplayRefreshRateTest { + @Test + fun choosesSmallestModeAtOrAboveRequestedFps() { + val selected = selectStreamDisplayMode( + supportedModes = listOf( + mode(id = 1, refreshRate = 72f), + mode(id = 2, refreshRate = 90f), + mode(id = 3, refreshRate = 120f), + ), + currentMode = mode(id = 1, refreshRate = 72f), + requestedFps = 90, + ) + + assertEquals(2, selected?.id) + } + + @Test + fun keepsCurrentHighRefreshModeWhenItAlreadyCoversStreamFps() { + val selected = selectStreamDisplayMode( + supportedModes = listOf( + mode(id = 1, refreshRate = 60f), + mode(id = 2, refreshRate = 120f), + ), + currentMode = mode(id = 2, refreshRate = 120f), + requestedFps = 60, + ) + + assertEquals(2, selected?.id) + } + + @Test + fun rejectsCadenceIncompatibleCurrentHighRefreshMode() { + val selected = selectStreamDisplayMode( + supportedModes = listOf( + mode(id = 1, refreshRate = 60f), + mode(id = 2, refreshRate = 90f), + mode(id = 3, refreshRate = 120f), + ), + currentMode = mode(id = 2, refreshRate = 90f), + requestedFps = 60, + ) + + assertEquals(1, selected?.id) + } + + @Test + fun prefersCadenceCompatibleModeOverCloserIncompatibleMode() { + val selected = selectStreamDisplayMode( + supportedModes = listOf( + mode(id = 1, refreshRate = 90f), + mode(id = 2, refreshRate = 120f), + mode(id = 3, refreshRate = 144f), + ), + currentMode = mode(id = 3, refreshRate = 144f), + requestedFps = 60, + ) + + assertEquals(2, selected?.id) + } + + @Test + fun usesHighestModeWhenRequestedFpsExceedsDisplaySupport() { + val selected = selectStreamDisplayMode( + supportedModes = listOf( + mode(id = 1, refreshRate = 60f), + mode(id = 2, refreshRate = 90f), + ), + currentMode = mode(id = 1, refreshRate = 60f), + requestedFps = 120, + ) + + assertEquals(2, selected?.id) + } + + @Test + fun selects360HzModeForUltimateStream() { + val selected = selectStreamDisplayMode( + supportedModes = listOf( + mode(id = 1, refreshRate = 120f), + mode(id = 2, refreshRate = 240f), + mode(id = 3, refreshRate = 360f), + ), + currentMode = mode(id = 1, refreshRate = 120f), + requestedFps = 360, + ) + + assertEquals(3, selected?.id) + assertEquals(360f, normalizedStreamDisplayFps(360)) + } + + @Test + fun prefersCurrentPhysicalResolutionWhenModesIncludeMultipleSizes() { + val selected = selectStreamDisplayMode( + supportedModes = listOf( + mode(id = 1, refreshRate = 90f, physicalWidth = 1920, physicalHeight = 1080), + mode(id = 2, refreshRate = 90f, physicalWidth = 2560, physicalHeight = 1440), + mode(id = 3, refreshRate = 120f, physicalWidth = 2560, physicalHeight = 1440), + ), + currentMode = mode(id = 4, refreshRate = 60f, physicalWidth = 2560, physicalHeight = 1440), + requestedFps = 90, + ) + + assertEquals(2, selected?.id) + } + + @Test + fun toleratesFractionalDisplayRatesNearRequestedFps() { + val selected = selectStreamDisplayMode( + supportedModes = listOf( + mode(id = 1, refreshRate = 72f), + mode(id = 2, refreshRate = 89.98f), + mode(id = 3, refreshRate = 119.88f), + ), + currentMode = mode(id = 1, refreshRate = 72f), + requestedFps = 90, + ) + + assertEquals(2, selected?.id) + } + + private fun mode( + id: Int, + refreshRate: Float, + physicalWidth: Int = 1920, + physicalHeight: Int = 1080, + ): DisplayRefreshMode = + DisplayRefreshMode( + id = id, + refreshRate = refreshRate, + physicalWidth = physicalWidth, + physicalHeight = physicalHeight, + ) +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/ExternalLaunchIntentTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/ExternalLaunchIntentTest.kt new file mode 100644 index 000000000..05d60b82b --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/ExternalLaunchIntentTest.kt @@ -0,0 +1,77 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class ExternalLaunchIntentTest { + @Test + fun extractsLaunchIdFromCustomSchemePath() { + val id = externalLaunchIdFromParts( + extras = emptyList(), + scheme = "opennow", + host = "launch", + pathSegments = listOf("100362311"), + schemeSpecificPart = "//launch/100362311", + queryParameters = emptyMap(), + ) + + assertEquals("100362311", id) + } + + @Test + fun extractsLaunchIdFromOpaqueCustomScheme() { + val id = externalLaunchIdFromParts( + extras = emptyList(), + scheme = "opennow", + host = null, + pathSegments = emptyList(), + schemeSpecificPart = "launch/100362311", + queryParameters = emptyMap(), + ) + + assertEquals("100362311", id) + } + + @Test + fun queryParameterBeatsPathFallback() { + val id = externalLaunchIdFromParts( + extras = emptyList(), + scheme = "opennow", + host = "launch", + pathSegments = listOf("wrong"), + schemeSpecificPart = "//launch/wrong", + queryParameters = mapOf("appId" to "100362311"), + ) + + assertEquals("100362311", id) + } + + @Test + fun intentExtraBeatsUri() { + val id = externalLaunchIdFromParts( + extras = listOf("100362311"), + scheme = "opennow", + host = "launch", + pathSegments = listOf("wrong"), + schemeSpecificPart = "//launch/wrong", + queryParameters = emptyMap(), + ) + + assertEquals("100362311", id) + } + + @Test + fun ignoresNonOpenNowUriWithoutExtra() { + val id = externalLaunchIdFromParts( + extras = emptyList(), + scheme = "https", + host = "example.com", + pathSegments = listOf("launch", "100362311"), + schemeSpecificPart = "//example.com/launch/100362311", + queryParameters = mapOf("appId" to "100362311"), + ) + + assertNull(id) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/ExternalMouseAbsolutePositionTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/ExternalMouseAbsolutePositionTest.kt new file mode 100644 index 000000000..c5cd3b339 --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/ExternalMouseAbsolutePositionTest.kt @@ -0,0 +1,34 @@ +package com.opencloudgaming.opennow + +import java.nio.ByteBuffer +import java.nio.ByteOrder +import org.junit.Assert.assertEquals +import org.junit.Test + +class ExternalMouseAbsolutePositionTest { + @Test + fun capturedDeltasMoveAndClampInsideTheRemoteExtent() { + val position = ExternalMouseAbsolutePosition() + + assertEquals(AbsoluteMousePosition(1060, 520, 1920, 1080), position.moveBy(100, -20, 1920, 1080)) + assertEquals(AbsoluteMousePosition(1920, 0, 1920, 1080), position.moveBy(5000, -5000, 1920, 1080)) + + position.reset() + assertEquals(AbsoluteMousePosition(960, 540, 1920, 1080), position.moveBy(0, 0, 1920, 1080)) + } + + @Test + fun absolutePacketMatchesDesktopTypeFiveLayout() { + val encoder = InputEncoder().also { it.setProtocolVersion(2) } + val packet = encoder.encodeMouseAbsolute(1060, 520, 1920, 1080) + val littleEndian = ByteBuffer.wrap(packet).order(ByteOrder.LITTLE_ENDIAN) + val bigEndian = ByteBuffer.wrap(packet).order(ByteOrder.BIG_ENDIAN) + + assertEquals(26, packet.size) + assertEquals(InputEncoder.INPUT_MOUSE_ABS, littleEndian.getInt(0)) + assertEquals(1060, bigEndian.getShort(4).toInt() and 0xffff) + assertEquals(520, bigEndian.getShort(6).toInt() and 0xffff) + assertEquals(1920, bigEndian.getShort(10).toInt() and 0xffff) + assertEquals(1080, bigEndian.getShort(12).toInt() and 0xffff) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/ExternalMousePointerCaptureTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/ExternalMousePointerCaptureTest.kt new file mode 100644 index 000000000..e6d4dc113 --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/ExternalMousePointerCaptureTest.kt @@ -0,0 +1,157 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ExternalMousePointerCaptureTest { + @Test + fun enablesCaptureOnlyDuringUnobstructedGameplayWhenMouseLockIsOn() { + assertTrue( + shouldEnableExternalMousePointerCapture( + streamReady = true, + streamOverlayOpen = false, + pointerLockEnabled = true, + ), + ) + assertFalse( + shouldEnableExternalMousePointerCapture( + streamReady = true, + streamOverlayOpen = true, + pointerLockEnabled = true, + ), + ) + assertFalse( + shouldEnableExternalMousePointerCapture( + streamReady = true, + streamOverlayOpen = false, + pointerLockEnabled = false, + ), + ) + } + + @Test + fun retriesCaptureForMouseMotionWhenStreamingWithoutAnOverlay() { + assertTrue( + shouldRequestAndroidMousePointerCapture( + streamActive = true, + captureEnabled = true, + windowFocused = true, + hasPointerCapture = false, + mouseLikePointer = true, + ), + ) + } + + @Test + fun doesNotStealCaptureFromOverlaysOrOtherPointerSources() { + assertFalse( + shouldRequestAndroidMousePointerCapture( + streamActive = true, + captureEnabled = false, + windowFocused = true, + hasPointerCapture = false, + mouseLikePointer = true, + ), + ) + assertFalse( + shouldRequestAndroidMousePointerCapture( + streamActive = true, + captureEnabled = true, + windowFocused = true, + hasPointerCapture = false, + mouseLikePointer = false, + ), + ) + assertFalse( + shouldRequestAndroidMousePointerCapture( + streamActive = true, + captureEnabled = true, + windowFocused = true, + hasPointerCapture = true, + mouseLikePointer = true, + ), + ) + } + + @Test + fun routesOnlyCapturedMouseEventsWhileStreaming() { + assertTrue( + shouldRouteCapturedAndroidMousePointer( + streamActive = true, + mouseLikePointer = true, + ), + ) + assertFalse( + shouldRouteCapturedAndroidMousePointer( + streamActive = false, + mouseLikePointer = true, + ), + ) + assertFalse( + shouldRouteCapturedAndroidMousePointer( + streamActive = true, + mouseLikePointer = false, + ), + ) + } + + @Test + fun capturedMousePrefersExplicitRelativeAxesAndFallsBackWhenTheyAreEmpty() { + assertTrue(shouldUseAndroidRelativeMouseAxes(relativeDx = 4f, relativeDy = 0f)) + assertTrue(shouldUseAndroidRelativeMouseAxes(relativeDx = 0f, relativeDy = -3f)) + assertFalse(shouldUseAndroidRelativeMouseAxes(relativeDx = 0f, relativeDy = 0f)) + } + + @Test + fun capturedMouseKeepsMatchingRelativeAxesAndRepairsOpposingOnes() { + assertEquals(4f, resolveAndroidCapturedMouseAxis(relativeAxis = 4f, capturedAxis = 3f), 0f) + assertEquals(-4f, resolveAndroidCapturedMouseAxis(relativeAxis = -4f, capturedAxis = -3f), 0f) + assertEquals(3f, resolveAndroidCapturedMouseAxis(relativeAxis = -4f, capturedAxis = 3f), 0f) + assertEquals(-3f, resolveAndroidCapturedMouseAxis(relativeAxis = 4f, capturedAxis = -3f), 0f) + assertEquals(3f, resolveAndroidCapturedMouseAxis(relativeAxis = 0f, capturedAxis = 3f), 0f) + } + + @Test + fun detectsEitherCapturedMouseAxisDirectionConflict() { + assertTrue( + androidCapturedMouseAxesConflict( + relativeDx = -4f, + relativeDy = 2f, + capturedDx = 3f, + capturedDy = 2f, + ), + ) + assertFalse( + androidCapturedMouseAxesConflict( + relativeDx = 4f, + relativeDy = -2f, + capturedDx = 3f, + capturedDy = -2f, + ), + ) + } + + @Test + fun keepsCapturedAndExplicitRelativeMouseMotionUnbounded() { + assertTrue( + shouldSendExternalMouseAsRelative( + capturedPointer = true, + hasRelativeAxisMotion = false, + ), + ) + assertTrue( + shouldSendExternalMouseAsRelative( + capturedPointer = false, + hasRelativeAxisMotion = true, + ), + ) + assertFalse( + shouldSendExternalMouseAsRelative( + capturedPointer = false, + hasRelativeAxisMotion = false, + ), + ) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/FirstVideoFrameWatchdogTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/FirstVideoFrameWatchdogTest.kt new file mode 100644 index 000000000..3c1842895 --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/FirstVideoFrameWatchdogTest.kt @@ -0,0 +1,34 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class FirstVideoFrameWatchdogTest { + @Test + fun recoversWhenPacketsArriveWithoutRenderedFrame() { + val watchdog = FirstVideoFrameWatchdog(timeoutMs = 8_000L) + + assertFalse(watchdog.shouldRecover(1_000L, bytesReceived = 1L, connected = true)) + assertFalse(watchdog.shouldRecover(8_999L, bytesReceived = 10_000L, connected = true)) + assertTrue(watchdog.shouldRecover(9_000L, bytesReceived = 20_000L, connected = true)) + } + + @Test + fun renderedFrameDisarmsRecovery() { + val watchdog = FirstVideoFrameWatchdog(timeoutMs = 1_000L) + + assertFalse(watchdog.shouldRecover(100L, bytesReceived = 1L, connected = true)) + watchdog.markRendered() + assertFalse(watchdog.shouldRecover(5_000L, bytesReceived = 50_000L, connected = true)) + } + + @Test + fun disconnectResetsPendingTimeout() { + val watchdog = FirstVideoFrameWatchdog(timeoutMs = 1_000L) + + assertFalse(watchdog.shouldRecover(100L, bytesReceived = 1L, connected = true)) + assertFalse(watchdog.shouldRecover(2_000L, bytesReceived = 1L, connected = false)) + assertFalse(watchdog.shouldRecover(2_100L, bytesReceived = 2L, connected = true)) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/GameDetailsDismissGestureTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/GameDetailsDismissGestureTest.kt new file mode 100644 index 000000000..ab44e8e8d --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/GameDetailsDismissGestureTest.kt @@ -0,0 +1,27 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertEquals +import org.junit.Test + +class GameDetailsDismissGestureTest { + @Test + fun reachingTopDuringScrollRequiresANewPullBeforeDismissal() { + val gate = SheetDismissGestureGate() + + assertEquals(0f, gate.dismissDelta(childConsumedY = 36f, availableY = 0f)) + assertEquals(0f, gate.dismissDelta(childConsumedY = 8f, availableY = 12f)) + assertEquals(0f, gate.dismissDelta(childConsumedY = 0f, availableY = 24f)) + + gate.reset() + + assertEquals(24f, gate.dismissDelta(childConsumedY = 0f, availableY = 24f)) + } + + @Test + fun pullThatStartsAtTopCanDismissImmediately() { + val gate = SheetDismissGestureGate() + + assertEquals(18f, gate.dismissDelta(childConsumedY = 0f, availableY = 18f)) + assertEquals(0f, gate.dismissDelta(childConsumedY = 0f, availableY = -10f)) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/GameDetailsTransitionTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/GameDetailsTransitionTest.kt new file mode 100644 index 000000000..4cf0e6ade --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/GameDetailsTransitionTest.kt @@ -0,0 +1,57 @@ +package com.opencloudgaming.opennow + +import androidx.compose.ui.geometry.Rect +import org.junit.Assert.assertEquals +import org.junit.Test + +class GameDetailsTransitionTest { + private val source = Rect(left = 120f, top = 240f, right = 320f, bottom = 540f) + private val target = Rect(left = 0f, top = 80f, right = 1_000f, bottom = 1_080f) + + @Test + fun containerStartsAtTheActivatedCardBounds() { + val transform = gameDetailsContainerTransform(source, target, progress = 0f) + + assertEquals(0.2f, transform.scaleX, 0f) + assertEquals(0.3f, transform.scaleY, 0f) + assertEquals(120f, transform.translationX, 0f) + assertEquals(160f, transform.translationY, 0f) + } + + @Test + fun containerFinishesAtItsFullDetailsBounds() { + val transform = gameDetailsContainerTransform(source, target, progress = 1f) + + assertEquals(1f, transform.scaleX, 0f) + assertEquals(1f, transform.scaleY, 0f) + assertEquals(0f, transform.translationX, 0f) + assertEquals(0f, transform.translationY, 0f) + } + + @Test + fun containerClampsOutOfRangeAnimationProgress() { + assertEquals( + gameDetailsContainerTransform(source, target, progress = 0f), + gameDetailsContainerTransform(source, target, progress = -1f), + ) + assertEquals( + gameDetailsContainerTransform(source, target, progress = 1f), + gameDetailsContainerTransform(source, target, progress = 2f), + ) + } + + @Test + fun transitionRegistryKeepsCardAndHeroSourcesDistinct() { + val registry = GameDetailsTransitionRegistry() + + registry.record("card", source, GameDetailsTransitionKind.Card) + assertEquals(GameDetailsTransitionKind.Card, registry.originFor("card")?.kind) + assertEquals(source, registry.originFor("card")?.bounds) + + registry.record("hero", target, GameDetailsTransitionKind.Hero) + + assertEquals(GameDetailsTransitionKind.Hero, registry.originFor("hero")?.kind) + assertEquals(target, registry.originFor("hero")?.bounds) + assertEquals(null, registry.originFor("card")) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/GameMembershipRequirementTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/GameMembershipRequirementTest.kt new file mode 100644 index 000000000..4e6220806 --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/GameMembershipRequirementTest.kt @@ -0,0 +1,61 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test + +class GameMembershipRequirementTest { + + private fun game(tierLabel: String?) = GameInfo(id = "1", title = "Test", membershipTierLabel = tierLabel) + + private fun subscription(tier: String) = SubscriptionInfo(membershipTier = tier) + + @Test + fun aGameWithNoStatedTierNeverWarns() { + assertNull(gameMembershipRequirement(game(null), subscription("FREE"), null)) + assertNull(gameMembershipRequirement(game(" "), subscription("FREE"), null)) + } + + @Test + fun aFreeTierGameNeverWarns() { + assertNull(gameMembershipRequirement(game("Free"), subscription("FREE"), null)) + } + + @Test + fun anUnrecognisedLabelStaysQuietRatherThanGuessing() { + // A spurious gate in front of Play is worse than no gate at all. + assertNull(gameMembershipRequirement(game("Day Pass"), subscription("FREE"), null)) + } + + @Test + fun aFreeAccountIsWarnedAboutAnUltimateGame() { + val requirement = gameMembershipRequirement( + game("GeForce NOW Ultimate"), + subscription("FREE"), + null, + ) + + assertNotNull(requirement) + assertEquals("Ultimate", requirement?.requiredPlanLabel) + assertEquals("Free", requirement?.currentPlanLabel) + } + + @Test + fun aPerformanceAccountIsWarnedAboutUltimateButNotAboutPerformance() { + assertNotNull(gameMembershipRequirement(game("Ultimate"), subscription("PERFORMANCE"), null)) + assertNull(gameMembershipRequirement(game("Performance"), subscription("PERFORMANCE"), null)) + } + + @Test + fun anUltimateAccountIsNeverWarned() { + assertNull(gameMembershipRequirement(game("Ultimate"), subscription("ULTIMATE"), null)) + assertNull(gameMembershipRequirement(game("Priority"), subscription("ULTIMATE"), null)) + } + + @Test + fun theTierFromTheAuthSessionCountsWhenNoSubscriptionHasLoadedYet() { + assertNull(gameMembershipRequirement(game("Ultimate"), null, "ULTIMATE")) + assertNotNull(gameMembershipRequirement(game("Ultimate"), null, "FREE")) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/GameStoreLinkTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/GameStoreLinkTest.kt new file mode 100644 index 000000000..041990b48 --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/GameStoreLinkTest.kt @@ -0,0 +1,67 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class GameStoreLinkTest { + @Test + fun storeDetailsKeepEachGraphQlUrlWithItsVariant() { + val game = GameInfo( + id = "game", + title = "Game", + availableStores = listOf("STEAM", "XBOX"), + variants = listOf( + GameVariant("steam", "STEAM", storeUrl = "https://store.steampowered.com/app/123"), + GameVariant("xbox", "XBOX", storeUrl = "https://www.xbox.com/games/store/game/abc"), + ), + ) + + assertEquals( + listOf( + GameStoreDetail("Steam", "https://store.steampowered.com/app/123"), + GameStoreDetail("Xbox", "https://www.xbox.com/games/store/game/abc"), + ), + gameStoreDetails(game), + ) + } + + @Test + fun storeDetailsStayCombinedWhenGraphQlHasNoLinks() { + val game = GameInfo( + id = "game", + title = "Game", + availableStores = listOf("STEAM", "XBOX"), + variants = listOf(GameVariant("steam", "STEAM"), GameVariant("xbox", "XBOX")), + ) + + assertEquals(listOf(GameStoreDetail("Steam, Xbox", null)), gameStoreDetails(game)) + } + + @Test + fun externalStoreLinksOnlyAllowHostBackedHttpsUrls() { + assertEquals("https://store.example/game", validExternalStoreUrl(" https://store.example/game ")) + assertNull(validExternalStoreUrl("http://store.example/game")) + assertNull(validExternalStoreUrl("javascript:alert(1)")) + assertNull(validExternalStoreUrl("https:///missing-host")) + } + + @Test + fun metadataMergeKeepsTheGraphQlStoreUrl() { + val catalog = GameInfo( + id = "game", + title = "Game", + variants = listOf(GameVariant("variant", "STEAM")), + ) + val metadata = catalog.copy( + variants = listOf( + GameVariant("variant", "STEAM", storeUrl = "https://store.steampowered.com/app/123"), + ), + ) + + assertEquals( + "https://store.steampowered.com/app/123", + mergeGameInfo(catalog, metadata).variants.single().storeUrl, + ) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/GamepadStateBurstLimiterTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/GamepadStateBurstLimiterTest.kt new file mode 100644 index 000000000..b81224c77 --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/GamepadStateBurstLimiterTest.kt @@ -0,0 +1,40 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class GamepadStateBurstLimiterTest { + @Test + fun sendsLeadingSnapshotAndCoalescesBurstToLatestController() { + val limiter = GamepadStateBurstLimiter(minimumIntervalMs = 16) + + assertEquals(0, limiter.offer(controllerId = 0, nowMs = 100)) + assertNull(limiter.offer(controllerId = 0, nowMs = 104)) + assertNull(limiter.offer(controllerId = 1, nowMs = 108)) + assertEquals(8L, limiter.delayUntilFlushMs(nowMs = 108)) + assertEquals(1, limiter.flush(nowMs = 116)) + assertNull(limiter.flush(nowMs = 116)) + } + + @Test + fun idleSnapshotSendsImmediately() { + val limiter = GamepadStateBurstLimiter(minimumIntervalMs = 16) + + assertEquals(0, limiter.offer(controllerId = 0, nowMs = 100)) + assertEquals(0, limiter.offer(controllerId = 0, nowMs = 116)) + assertNull(limiter.delayUntilFlushMs(nowMs = 116)) + } + + @Test + fun resetClearsPendingSnapshotAndTiming() { + val limiter = GamepadStateBurstLimiter(minimumIntervalMs = 16) + + limiter.offer(controllerId = 0, nowMs = 100) + limiter.offer(controllerId = 1, nowMs = 104) + limiter.reset() + + assertNull(limiter.flush(nowMs = 105)) + assertEquals(1, limiter.offer(controllerId = 1, nowMs = 105)) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/GfnApiTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/GfnApiTest.kt new file mode 100644 index 000000000..abd359462 --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/GfnApiTest.kt @@ -0,0 +1,1258 @@ +package com.opencloudgaming.opennow + +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.int +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.putJsonArray +import kotlinx.serialization.json.putJsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import okhttp3.HttpUrl.Companion.toHttpUrl +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class GfnApiTest { + @Test + fun launchOwnershipUsesCurrentAppsGraphQlContract() { + assertEquals("https://apps.gxn.nvidia.com/graphql", GFN_APPS_GRAPHQL_URL) + assertEquals( + "cf8b620dfd03617017ba7c858cee65197e1ace5180e41be194b39227227ced63", + GFN_APP_METADATA_QUERY_HASH, + ) + } + + @Test + fun mostPopularIsTheDefaultWithoutReplacingRelevance() { + val options = listOf( + CatalogSortOption("relevance", "Relevance", "relevance-order"), + CatalogSortOption("last_played", "Last played", "same-order"), + CatalogSortOption("most_popular", "Most popular", "popular-order"), + ) + + assertEquals("most_popular", DEFAULT_CATALOG_SORT_ID) + assertEquals("most_popular", resolveCatalogSort(options, DEFAULT_CATALOG_SORT_ID).id) + assertEquals("relevance", resolveCatalogSort(options, "relevance").id) + assertEquals(CatalogSortKind.Relevance, catalogSortKind("relevance")) + assertEquals(CatalogSortKind.Popular, catalogSortKind("most_popular")) + assertEquals( + "variants.gfn.library.lastPlayedDate:DESC,sortName:ASC", + catalogSortOrder(options[1]), + ) + assertEquals("popular-order", catalogSortOrder(options[2])) + assertEquals( + "itemMetadata.relevance:DESC,sortName:ASC", + catalogSortOrder(options[2].copy(orderBy = "")), + ) + } + + @Test + fun lastPlayedIsDistinctFromProviderOrderedNewGames() { + val newGame = GameInfo(id = "new", title = "New game") + val recentlyPlayed = GameInfo(id = "played", title = "Played", lastPlayed = "2026-08-23T18:00:00Z") + val olderPlayed = GameInfo(id = "older", title = "Older", lastPlayed = "2026-08-20T18:00:00Z") + val providerOrder = listOf(newGame, olderPlayed, recentlyPlayed) + + assertEquals( + listOf("played", "older", "new"), + applyCatalogSortGuarantees(providerOrder, "last_played").map { it.id }, + ) + assertEquals(providerOrder, applyCatalogSortGuarantees(providerOrder, "last_added")) + } + + @Test + fun latestAddedUsesTheAuthoritativeGfnThursdaySectionInPanelOrder() { + val first = GameInfo( + id = "first", + title = "First weekly game", + catalogSectionTitle = "GFN Thursday", + ) + val stale = GameInfo( + id = "stale", + title = "Old generic-sort game", + catalogSectionTitle = "Featured", + ) + val second = GameInfo( + id = "second", + title = "Second weekly game", + catalogSectionTitle = "Localized weekly title", + catalogSectionId = "section-cbc43218-6ad6-4ff3-8538-bc84f90c796c-468.0", + ) + + val weekly = gfnThursdayCatalogGames(listOf(first, stale, second)) + val result = catalogResultWithGfnThursdayGames( + fallback = CatalogBrowseResult( + games = listOf(stale), + numberReturned = 120, + numberSupported = 6_000, + totalCount = 6_000, + hasNextPage = true, + endCursor = "cursor", + selectedSortId = "last_added", + ), + games = weekly, + ) + + assertEquals(listOf("first", "second"), result.games.map(GameInfo::id)) + assertEquals(2, result.numberReturned) + assertEquals(2, result.numberSupported) + assertEquals(2, result.totalCount) + assertFalse(result.hasNextPage) + assertNull(result.endCursor) + assertEquals(NEWLY_ADDED_CATALOG_SORT_ID, result.selectedSortId) + } + + @Test + fun latestAddedFallsBackToProviderSortWhenThursdaySectionIsUnavailable() { + val fallback = CatalogBrowseResult( + games = listOf(GameInfo(id = "fallback", title = "Fallback")), + selectedSortId = NEWLY_ADDED_CATALOG_SORT_ID, + ) + + assertEquals(fallback, catalogResultWithGfnThursdayGames(fallback, emptyList())) + } + + @Test + fun shieldDetectionRequiresAnNvidiaShieldAndroidTv() { + assertTrue(isNvidiaShieldTvDevice(true, "NVIDIA", "SHIELD Android TV")) + assertTrue(isNvidiaShieldTvDevice(true, " nvidia ", "Nvidia Shield")) + assertFalse(isNvidiaShieldTvDevice(false, "NVIDIA", "SHIELD Android TV")) + assertFalse(isNvidiaShieldTvDevice(true, "Google", "Chromecast")) + assertFalse(isNvidiaShieldTvDevice(true, "NVIDIA", "Jetson")) + } + + @Test + fun thirdGenerationFireTvCubeDetectionRequiresTheExactAmazonTvModel() { + assertTrue(isThirdGenerationFireTvCubeDevice(true, "Amazon", "AFTGAZL")) + assertTrue(isThirdGenerationFireTvCubeDevice(true, " amazon ", " aftgazl ")) + assertFalse(isThirdGenerationFireTvCubeDevice(false, "Amazon", "AFTGAZL")) + assertFalse(isThirdGenerationFireTvCubeDevice(true, "Amazon", "AFTKA")) + assertFalse(isThirdGenerationFireTvCubeDevice(true, "Google", "AFTGAZL")) + } + + @Test + fun desktopNativeTvAllocationIsLimitedToKnownAffectedDevices() { + assertTrue(usesDesktopNativeTvCloudMatchIdentity(true, "NVIDIA", "SHIELD Android TV")) + assertTrue(usesDesktopNativeTvCloudMatchIdentity(true, "Amazon", "AFTGAZL")) + assertFalse(usesDesktopNativeTvCloudMatchIdentity(true, "Amazon", "AFTKA")) + assertFalse(usesDesktopNativeTvCloudMatchIdentity(true, "Google", "Chromecast")) + } + + @Test + fun recoveryClaimDoesNotResumeAnAlreadyReadySession() { + assertFalse(shouldResumeClaimedSession(status = 1, recoveryMode = false)) + assertTrue(shouldResumeClaimedSession(status = 2, recoveryMode = false)) + assertFalse(shouldResumeClaimedSession(status = 2, recoveryMode = true)) + assertFalse(shouldResumeClaimedSession(status = 3, recoveryMode = true)) + assertTrue(shouldResumeClaimedSession(status = null, recoveryMode = true)) + } + + @Test + fun libraryBrowseSpecUsesPanelSeeMorePaginationMetadata() { + val payload = buildJsonObject { + putJsonObject("data") { + putJsonArray("panels") { + add(buildJsonObject { + putJsonArray("sections") { + add(buildJsonObject { + putJsonObject("seeMoreInfo") { + put("sortOrderId", JsonPrimitive("last_played")) + putJsonArray("filterIds") { + add(JsonPrimitive("library")) + add(JsonPrimitive("owned")) + } + } + }) + } + }) + } + } + } + + assertEquals( + LibraryBrowseSpec(listOf("library", "owned"), "last_played"), + libraryBrowseSpec(payload), + ) + } + + @Test + fun libraryAppsFilterRequestsEveryOwnedLibraryStatus() { + val variants = libraryAppsFilter().getValue("variants").jsonObject + val gfn = variants.getValue("gfn").jsonObject + val library = gfn.getValue("library").jsonObject + val status = library.getValue("status").jsonObject + + assertEquals("NOT_OWNED", status.getValue("notEquals").jsonPrimitive.content) + } + + @Test + fun freeToPlayPaymentModelUsesGraphQlTypeName() { + val models = buildJsonObject { + putJsonArray("models") { + add(buildJsonObject { put("__typename", JsonPrimitive("PurchasePaymentModel")) }) + add(buildJsonObject { put("__typename", JsonPrimitive("FreeToPlayPaymentModel")) }) + } + }["models"]!!.jsonArray + + assertTrue(hasFreeToPlayPaymentModel(models)) + assertFalse(hasFreeToPlayPaymentModel(null)) + } + + @Test + fun catalogArtworkUsesGameBoxArtForMobileAndTvCards() { + val artwork = catalogCardArtwork( + keyArt = "key-art", + gameBoxArt = "game-box-art", + heroImage = "hero-image", + tvBanner = "tv-banner", + ) + + assertEquals("game-box-art", artwork.mobileImageUrl) + assertEquals("game-box-art", artwork.tvImageUrl) + } + + @Test + fun catalogArtworkDoesNotFallBackToLandscapeArtOnMobile() { + val artwork = catalogCardArtwork( + keyArt = "key-art", + gameBoxArt = null, + heroImage = "hero-image", + tvBanner = "tv-banner", + ) + + assertNull(artwork.mobileImageUrl) + assertEquals("key-art", artwork.tvImageUrl) + } + + @Test + fun catalogScreenshotsPreserveDistinctNonBlankImages() { + val images = buildJsonObject { + putJsonArray("SCREENSHOTS") { + add(JsonPrimitive(" screenshot-one ")) + add(JsonPrimitive("")) + add(JsonPrimitive("screenshot-two")) + add(JsonPrimitive("screenshot-one")) + } + } + + assertEquals( + listOf("screenshot-one", "screenshot-two"), + catalogScreenshotUrls(images), + ) + } + + @Test + fun catalogDescriptionSupportsBrowseAndMetadataFieldNames() { + val browseApp = buildJsonObject { + put("shortDescription", JsonPrimitive("Browse description")) + } + val metadataApp = buildJsonObject { + put("description", JsonPrimitive("Metadata description")) + put("shortDescription", JsonPrimitive("Fallback description")) + } + + assertEquals("Browse description", catalogGameDescription(browseApp)) + assertEquals("Metadata description", catalogGameDescription(metadataApp)) + } + + @Test + fun appStoreEnumSerializationFailureIsRecognizedForFallback() { + val error = IllegalStateException( + "GFN GraphQL failed (400): {\"errors\":[{\"message\":\"Enum 'AppStoreEnum' cannot represent value: 'NCSOFT'\"}]}", + ) + + assertTrue(isAppStoreEnumSerializationError(error)) + assertFalse(isAppStoreEnumSerializationError(IllegalStateException("GFN GraphQL failed (500)"))) + } + + @Test + fun variantStorePrefersGraphQlValueAndInfersEnumFreeMetadata() { + val explicit = buildJsonObject { + put("appStore", JsonPrimitive("STEAM")) + put("storeUrl", JsonPrimitive("https://www.epicgames.com/store/p/example")) + } + val epic = buildJsonObject { + put("storeUrl", JsonPrimitive("https://www.epicgames.com/store/p/example")) + } + val ncsoft = buildJsonObject { + put("shortName", JsonPrimitive("guild_wars_2_gfn_pc")) + put("storeUrl", JsonPrimitive("https://www.guildwars2.com/?utm_source=nvidia")) + put("publisherName", JsonPrimitive("NCsoft Corp.")) + } + + assertEquals("STEAM", gameStoreFromVariant(explicit)) + assertEquals("EPIC", gameStoreFromVariant(epic)) + assertEquals("NCSOFT", gameStoreFromVariant(ncsoft)) + } + + @Test + fun enumFreeVariantFieldsRetainStoreInferenceMetadata() { + val primaryFields = gfnVariantMetadataFields(includeAppStore = true) + val fallbackFields = gfnVariantMetadataFields(includeAppStore = false) + + assertTrue(primaryFields.lineSequence().any { it.trim() == "appStore" }) + assertFalse(fallbackFields.lineSequence().any { it.trim() == "appStore" }) + assertTrue(fallbackFields.contains("shortName")) + assertTrue(fallbackFields.contains("storeUrl")) + assertTrue(fallbackFields.contains("publisherName")) + } + + @Test + fun canonicalizesOldGamesGraphQlHost() { + val url = canonicalizeGfnRequestUrl( + "https://games.geforcenow.com/graphql?requestType=panels%2FMainV2".toHttpUrl(), + ) + + assertEquals("https", url.scheme) + assertEquals("games.geforce.com", url.host) + assertEquals("/graphql", url.encodedPath) + assertEquals("panels/MainV2", url.queryParameter("requestType")) + } + + @Test + fun leavesCanonicalGamesGraphQlHostUnchanged() { + val source = "https://games.geforce.com/graphql".toHttpUrl() + + assertEquals(source, canonicalizeGfnRequestUrl(source)) + } + + @Test + fun claimRequestWithoutSettingsDoesNotRenegotiateMonitorSettings() { + val body = buildMinimalClaimRequestBody(appId = "123", deviceId = "device") + val sessionRequestData = body.getValue("sessionRequestData").jsonObject + val metadata = sessionRequestData.getValue("metaData").jsonArray + + assertEquals(2, body.getValue("action").jsonPrimitive.int) + assertEquals(123, sessionRequestData.getValue("appId").jsonPrimitive.int) + assertFalse(sessionRequestData.containsKey("clientRequestMonitorSettings")) + assertFalse(sessionRequestData.containsKey("requestedStreamingFeatures")) + assertTrue(metadata.none { item -> + item.jsonObject["key"]?.jsonPrimitive?.contentOrNull == "clientPhysicalResolution" + }) + } + + @Test + fun claimRequestCarriesCommonResolutionAspectAndCodecMatrix() { + val cases = STREAM_RESOLUTION_OPTIONS.map { option -> + Triple(option.value, option.aspectRatio, parseResolutionPixels(option.value)) + } + + for ((resolution, aspectRatio, pixels) in cases) { + for (codec in VideoCodec.entries) { + val settings = StreamSettings( + resolution = resolution, + aspectRatio = aspectRatio, + fps = 60, + maxBitrateMbps = 75, + codec = codec, + colorQuality = if (codec == VideoCodec.H264) ColorQuality.EightBit420 else ColorQuality.TenBit420, + ) + val body = buildMinimalClaimRequestBody(appId = "123", deviceId = "device", settings = settings) + val nativeDesktopMode = settings.requiresNativeDesktopCloudMatchMode() + val sessionRequestData = body.getValue("sessionRequestData").jsonObject + val metadata = sessionRequestData.getValue("metaData").jsonArray + val monitor = sessionRequestData.getValue("clientRequestMonitorSettings").jsonArray.single().jsonObject + val features = sessionRequestData.getValue("requestedStreamingFeatures").jsonObject + val signature = metadata.firstNotNullOfOrNull { item -> + item.jsonObject.takeIf { + it["key"]?.jsonPrimitive?.contentOrNull == OPENNOW_STREAM_SETTINGS_METADATA_KEY + }?.get("value")?.jsonPrimitive?.contentOrNull + } + val physicalResolution = metadata.firstNotNullOfOrNull { item -> + item.jsonObject.takeIf { + it["key"]?.jsonPrimitive?.contentOrNull == "clientPhysicalResolution" + }?.get("value")?.jsonPrimitive?.contentOrNull + }?.let { OpenNowJson.parseToJsonElement(it).jsonObject } + + assertEquals("$resolution $codec signature", streamSettingsSessionSignature(settings), signature) + assertEquals("$resolution $codec width", pixels.first, monitor.getValue("widthInPixels").jsonPrimitive.int) + assertEquals("$resolution $codec height", pixels.second, monitor.getValue("heightInPixels").jsonPrimitive.int) + assertEquals("$resolution $codec fps", 60, monitor.getValue("framesPerSecond").jsonPrimitive.int) + assertEquals("$resolution $codec bit depth", if (codec == VideoCodec.H265) 10 else 0, features.getValue("bitDepth").jsonPrimitive.int) + assertEquals(false, features.getValue("reflex").jsonPrimitive.boolean) + assertEquals(nativeDesktopMode, monitor.containsKey("monitorId")) + assertEquals(nativeDesktopMode, monitor.containsKey("positionX")) + assertEquals(nativeDesktopMode, monitor.containsKey("positionY")) + assertEquals(if (nativeDesktopMode) 100 else 0, monitor.getValue("dpi").jsonPrimitive.int) + assertEquals(pixels.first, physicalResolution?.getValue("horizontalPixels")?.jsonPrimitive?.int) + assertEquals(pixels.second, physicalResolution?.getValue("verticalPixels")?.jsonPrimitive?.int) + } + } + } + + @Test + fun ultrawideMetadataKeepsPhysicalDisplaySeparateFromStreamResolution() { + val settings = StreamSettings( + resolution = "1680x720", + aspectRatio = "21:9", + fps = 60, + codec = VideoCodec.H264, + colorQuality = ColorQuality.EightBit420, + ) + + val body = buildMinimalClaimRequestBody( + appId = "123", + deviceId = "device", + settings = settings, + physicalDisplayResolution = 1920 to 1080, + streamingBaseUrl = "https://np-bom-01.cloudmatchbeta.nvidiagrid.net", + ) + val sessionRequestData = body.getValue("sessionRequestData").jsonObject + val monitor = sessionRequestData + .getValue("clientRequestMonitorSettings").jsonArray + .single().jsonObject + val physicalResolution = sessionRequestData + .getValue("metaData").jsonArray + .firstNotNullOf { item -> + item.jsonObject.takeIf { + it["key"]?.jsonPrimitive?.contentOrNull == "clientPhysicalResolution" + }?.get("value")?.jsonPrimitive?.contentOrNull + } + .let { OpenNowJson.parseToJsonElement(it).jsonObject } + + assertEquals(1680, monitor.getValue("widthInPixels").jsonPrimitive.int) + assertEquals(720, monitor.getValue("heightInPixels").jsonPrimitive.int) + assertEquals(0, monitor.getValue("dpi").jsonPrimitive.int) + assertFalse(monitor.containsKey("monitorId")) + assertFalse(monitor.containsKey("positionX")) + assertFalse(monitor.containsKey("positionY")) + assertEquals(JsonNull, monitor.getValue("displayData")) + assertEquals(JsonNull, monitor.getValue("hdr10PlusGamingData")) + assertEquals("browser", sessionRequestData.getValue("clientPlatformName").jsonPrimitive.content) + assertEquals(2, sessionRequestData.getValue("appLaunchMode").jsonPrimitive.int) + assertEquals(false, sessionRequestData.getValue("enablePersistingInGameSettings").jsonPrimitive.boolean) + assertEquals(1920, physicalResolution.getValue("horizontalPixels").jsonPrimitive.int) + assertEquals(1080, physicalResolution.getValue("verticalPixels").jsonPrimitive.int) + } + + @Test + fun larger4kPanelRemainsPhysicalMetadataForRequested1440pViewport() { + val settings = StreamSettings( + resolution = "2560x1440", + aspectRatio = "16:9", + fps = 120, + codec = VideoCodec.H265, + colorQuality = ColorQuality.TenBit420, + ) + + val body = buildMinimalClaimRequestBody( + appId = "123", + deviceId = "device", + settings = settings, + physicalDisplayResolution = 3840 to 2160, + ) + val sessionRequestData = body.getValue("sessionRequestData").jsonObject + val physicalResolution = sessionRequestData + .getValue("metaData").jsonArray + .firstNotNullOf { item -> + item.jsonObject.takeIf { + it["key"]?.jsonPrimitive?.contentOrNull == "clientPhysicalResolution" + }?.get("value")?.jsonPrimitive?.contentOrNull + } + .let { OpenNowJson.parseToJsonElement(it).jsonObject } + + assertEquals(3840, physicalResolution.getValue("horizontalPixels").jsonPrimitive.int) + assertEquals(2160, physicalResolution.getValue("verticalPixels").jsonPrimitive.int) + } + + @Test + fun sessionMonitorSnapshotKeepsRequestedReturnedAndFinalModesSeparate() { + val session = buildJsonObject { + putJsonObject("sessionRequestData") { + putJsonArray("clientRequestMonitorSettings") { + add(buildJsonObject { + put("widthInPixels", JsonPrimitive(1680)) + put("heightInPixels", JsonPrimitive(720)) + put("framesPerSecond", JsonPrimitive(60)) + }) + } + } + putJsonArray("monitorSettings") { + add(buildJsonObject { + put("widthInPixels", JsonPrimitive(1366)) + put("heightInPixels", JsonPrimitive(768)) + put("framesPerSecond", JsonPrimitive(60)) + }) + } + putJsonObject("finalSelectedScreenResolution") { + put("horizontalPixels", JsonPrimitive(1230)) + put("verticalPixels", JsonPrimitive(768)) + } + } + + val snapshot = extractSessionMonitorSnapshot(session) + + assertEquals("1680x720", snapshot?.requestedResolution) + assertEquals(60, snapshot?.requestedFps) + assertEquals("1366x768", snapshot?.returnedResolution) + assertEquals(60, snapshot?.returnedFps) + assertEquals("1230x768", snapshot?.finalSelectedResolution) + } + + @Test + fun sessionMonitorSnapshotAcceptsStringFinalResolutionWithoutReplacingReturnedMode() { + val session = buildJsonObject { + putJsonArray("monitorSettings") { + add(buildJsonObject { + put("widthInPixels", JsonPrimitive(2560)) + put("heightInPixels", JsonPrimitive(1440)) + }) + } + put("finalSelectedScreenResolution", JsonPrimitive("1920x1080")) + } + + val snapshot = extractSessionMonitorSnapshot(session) + + assertEquals("2560x1440", snapshot?.returnedResolution) + assertEquals("1920x1080", snapshot?.finalSelectedResolution) + } + + @Test + fun nvidiaCloudMatchUsesBrowserAndroidClientIdentity() { + val headers = cloudMatchHeaders( + token = "token", + clientId = "client", + deviceId = "device", + includeOrigin = true, + streamingBaseUrl = "https://np-bom-01.cloudmatchbeta.nvidiagrid.net", + ) + + assertEquals("WEBRTC", headers["nv-client-streamer"]) + assertEquals("BROWSER", headers["nv-client-type"]) + assertEquals("2.0.86.124", headers["nv-client-version"]) + assertEquals("ANDROID", headers["nv-device-os"]) + assertEquals("PHONE", headers["nv-device-type"]) + assertTrue(headers["User-Agent"].orEmpty().contains("Android")) + assertEquals("https://play.geforcenow.com", headers["Origin"]) + } + + @Test + fun cloudMatchSessionRequestCarriesArabicAndPortugueseLocales() { + val base = "https://np-ams-06.cloudmatchbeta.nvidiagrid.net" + val arabicUrl = cloudMatchSessionRequestUrl( + base, + StreamSettings(keyboardLayout = "ar-SA", gameLanguage = "ar_SA"), + ) + val portugueseUrl = cloudMatchSessionRequestUrl( + base, + StreamSettings(keyboardLayout = "pt-PT", gameLanguage = "pt_PT"), + sessionId = "session 1", + ) + + assertTrue(arabicUrl.contains("keyboardLayout=ar-SA")) + assertTrue(arabicUrl.contains("languageCode=ar_SA")) + assertTrue(portugueseUrl.contains("/v2/session/session+1?")) + assertTrue(portugueseUrl.contains("keyboardLayout=pt-PT")) + assertTrue(portugueseUrl.contains("languageCode=pt_PT")) + } + + @Test + fun androidAppLocalesMapToCloudMatchCatalogLocales() { + assertEquals("ar_SA", gfnLocaleForAndroidLanguageTag("ar")) + assertEquals("fr_FR", gfnLocaleForAndroidLanguageTag("fr-CA")) + assertEquals("pt_PT", gfnLocaleForAndroidLanguageTag("pt")) + assertEquals("pt_BR", gfnLocaleForAndroidLanguageTag("pt-BR")) + assertEquals("zh_CN", gfnLocaleForAndroidLanguageTag("zh-Hans")) + assertEquals("en_US", gfnLocaleForAndroidLanguageTag("unsupported")) + } + + @Test + fun nvidiaHighPerformanceGamepadLaunchUsesNativeDesktopIdentity() { + val headers = cloudMatchHeaders( + token = "token", + clientId = "client", + deviceId = "device", + includeOrigin = true, + streamingBaseUrl = "https://np-pdx-01.cloudmatchbeta.nvidiagrid.net", + appLaunchMode = GfnAppLaunchMode.GAMEPAD_FRIENDLY, + preferNativeDesktopMode = true, + ) + + assertEquals("NVIDIA-CLASSIC", headers["nv-client-streamer"]) + assertEquals("NATIVE", headers["nv-client-type"]) + assertEquals("WINDOWS", headers["nv-device-os"]) + assertEquals("DESKTOP", headers["nv-device-type"]) + assertTrue(headers["User-Agent"].orEmpty().contains("Linux; Android")) + } + + @Test + fun allianceCloudMatchKeepsDesktopNativeClientIdentity() { + val headers = cloudMatchHeaders( + token = "token", + clientId = "client", + deviceId = "device", + includeOrigin = true, + streamingBaseUrl = "https://my-yes.yes.geforcenow.nvidiagrid.net", + ) + + assertEquals("NVIDIA-CLASSIC", headers["nv-client-streamer"]) + assertEquals("NATIVE", headers["nv-client-type"]) + assertEquals("WINDOWS", headers["nv-device-os"]) + assertEquals("DESKTOP", headers["nv-device-type"]) + assertTrue(headers["User-Agent"].orEmpty().contains("GFN-PC/22.0")) + assertTrue(headers["User-Agent"].orEmpty().contains("Android")) + assertEquals("https://play.geforcenow.com", headers["Origin"]) + } + + @Test + fun cloudMatchUsesNativeAndroidTouchIdentityForTouchFriendly() { + val headers = cloudMatchHeaders( + token = "token", + clientId = "client", + deviceId = "device", + includeOrigin = true, + appLaunchMode = GfnAppLaunchMode.TOUCH_FRIENDLY, + ) + + assertEquals("NVIDIA-CLASSIC", headers["nv-client-streamer"]) + assertEquals("NATIVE", headers["nv-client-type"]) + assertEquals("ANDROID", headers["nv-device-os"]) + assertEquals("TABLET", headers["nv-device-type"]) + val userAgent = headers["User-Agent"].orEmpty() + assertTrue(userAgent.contains("Android-Generic-Touch")) + assertEquals("https://play.geforcenow.com", headers["Origin"]) + } + + @Test + fun cloudMatchUsesAndroidTvIdentityForTvProfile() { + val headers = cloudMatchHeaders( + token = "token", + clientId = "client", + deviceId = "device", + includeOrigin = true, + isAndroidTv = true, + ) + + assertEquals("WEBRTC", headers["nv-client-streamer"]) + assertEquals("BROWSER", headers["nv-client-type"]) + assertEquals("ANDROID", headers["nv-device-os"]) + assertEquals("DESKTOP", headers["nv-device-type"]) + val userAgent = headers["User-Agent"].orEmpty() + assertTrue(userAgent.contains("Android-Generic-TV")) + assertEquals("https://play.geforcenow.com", headers["Origin"]) + } + + @Test + fun cloudMatchUsesDesktopNativeIdentityForHighQualityShieldProfile() { + val headers = cloudMatchHeaders( + token = "token", + clientId = "client", + deviceId = "device", + includeOrigin = true, + streamingBaseUrl = "https://np-sth-04.cloudmatchbeta.nvidiagrid.net", + appLaunchMode = GfnAppLaunchMode.GAMEPAD_FRIENDLY, + preferNativeDesktopMode = true, + isAndroidTv = true, + useDesktopNativeTvIdentity = true, + ) + + assertEquals("NVIDIA-CLASSIC", headers["nv-client-streamer"]) + assertEquals("NATIVE", headers["nv-client-type"]) + assertEquals("WINDOWS", headers["nv-device-os"]) + assertEquals("DESKTOP", headers["nv-device-type"]) + assertTrue(headers["User-Agent"].orEmpty().contains("Linux; Android")) + assertFalse(headers["User-Agent"].orEmpty().contains("Android-Generic-TV")) + } + + @Test + fun cloudMatchUsesDesktopNativeIdentityForHighQualityFireTvCubeProfile() { + val headers = cloudMatchHeaders( + token = "token", + clientId = "client", + deviceId = "device", + includeOrigin = true, + streamingBaseUrl = "https://np-mia-04.cloudmatchbeta.nvidiagrid.net", + appLaunchMode = GfnAppLaunchMode.GAMEPAD_FRIENDLY, + preferNativeDesktopMode = true, + isAndroidTv = true, + useDesktopNativeTvIdentity = usesDesktopNativeTvCloudMatchIdentity(true, "Amazon", "AFTGAZL"), + ) + + assertEquals("NVIDIA-CLASSIC", headers["nv-client-streamer"]) + assertEquals("NATIVE", headers["nv-client-type"]) + assertEquals("WINDOWS", headers["nv-device-os"]) + assertEquals("DESKTOP", headers["nv-device-type"]) + assertFalse(headers["User-Agent"].orEmpty().contains("Android-Generic-TV")) + } + + @Test + fun cloudMatchKeepsAndroidNativeIdentityForOtherHighQualityTvProfiles() { + val headers = cloudMatchHeaders( + token = "token", + clientId = "client", + deviceId = "device", + includeOrigin = true, + streamingBaseUrl = "https://np-sth-04.cloudmatchbeta.nvidiagrid.net", + appLaunchMode = GfnAppLaunchMode.GAMEPAD_FRIENDLY, + preferNativeDesktopMode = true, + isAndroidTv = true, + useDesktopNativeTvIdentity = false, + ) + + assertEquals("NVIDIA-CLASSIC", headers["nv-client-streamer"]) + assertEquals("NATIVE", headers["nv-client-type"]) + assertEquals("ANDROID", headers["nv-device-os"]) + assertEquals("DESKTOP", headers["nv-device-type"]) + assertTrue(headers["User-Agent"].orEmpty().contains("Android-Generic-TV")) + } + + @Test + fun allianceClaimKeepsDesktopMonitorDescriptor() { + val settings = StreamSettings( + resolution = "1680x720", + aspectRatio = "21:9", + fps = 60, + codec = VideoCodec.H264, + colorQuality = ColorQuality.EightBit420, + ) + + val sessionRequestData = buildMinimalClaimRequestBody( + appId = "123", + deviceId = "device", + settings = settings, + streamingBaseUrl = "https://my-yes.yes.geforcenow.nvidiagrid.net", + ).getValue("sessionRequestData").jsonObject + val monitor = sessionRequestData + .getValue("clientRequestMonitorSettings").jsonArray + .single().jsonObject + + assertEquals("windows", sessionRequestData.getValue("clientPlatformName").jsonPrimitive.content) + assertEquals(GfnAppLaunchMode.GAMEPAD_FRIENDLY, sessionRequestData.getValue("appLaunchMode").jsonPrimitive.int) + assertEquals(true, sessionRequestData.getValue("enablePersistingInGameSettings").jsonPrimitive.boolean) + assertEquals(0, monitor.getValue("monitorId").jsonPrimitive.int) + assertEquals(0, monitor.getValue("positionX").jsonPrimitive.int) + assertEquals(0, monitor.getValue("positionY").jsonPrimitive.int) + assertEquals(100, monitor.getValue("dpi").jsonPrimitive.int) + } + + @Test + fun physicalResolutionMetadataDoesNotUndercutRequested1440pStream() { + val settings = StreamSettings( + resolution = "2560x1440", + aspectRatio = "16:9", + fps = 60, + codec = VideoCodec.H265, + colorQuality = ColorQuality.TenBit420, + hdrEnabled = true, + ) + + val body = buildMinimalClaimRequestBody( + appId = "123", + deviceId = "device", + settings = settings, + physicalDisplayResolution = 1920 to 1080, + ) + val sessionRequestData = body.getValue("sessionRequestData").jsonObject + val monitor = sessionRequestData + .getValue("clientRequestMonitorSettings").jsonArray + .single().jsonObject + val physicalResolution = sessionRequestData + .getValue("metaData").jsonArray + .firstNotNullOf { item -> + item.jsonObject.takeIf { + it["key"]?.jsonPrimitive?.contentOrNull == "clientPhysicalResolution" + }?.get("value")?.jsonPrimitive?.contentOrNull + } + .let { OpenNowJson.parseToJsonElement(it).jsonObject } + + assertEquals(2560, monitor.getValue("widthInPixels").jsonPrimitive.int) + assertEquals(1440, monitor.getValue("heightInPixels").jsonPrimitive.int) + assertEquals(2560, physicalResolution.getValue("horizontalPixels").jsonPrimitive.int) + assertEquals(1440, physicalResolution.getValue("verticalPixels").jsonPrimitive.int) + } + + @Test + fun claimRequestExplicitlyMarksSdrColorMetadata() { + val settings = StreamSettings( + resolution = "1920x1080", + codec = VideoCodec.H265, + colorQuality = ColorQuality.EightBit420, + hdrEnabled = false, + ) + + val sessionRequestData = buildMinimalClaimRequestBody("123", "device", settings) + .getValue("sessionRequestData").jsonObject + val monitor = sessionRequestData + .getValue("clientRequestMonitorSettings").jsonArray.single().jsonObject + val features = sessionRequestData.getValue("requestedStreamingFeatures").jsonObject + + assertEquals(0, monitor.getValue("sdrHdrMode").jsonPrimitive.int) + assertEquals(JsonNull, monitor.getValue("displayData")) + assertEquals(JsonNull, monitor.getValue("hdr10PlusGamingData")) + assertEquals(0, features.getValue("bitDepth").jsonPrimitive.int) + assertEquals(false, features.getValue("trueHdr").jsonPrimitive.boolean) + assertEquals(2, features.getValue("sdrColorSpace").jsonPrimitive.int) + assertEquals(0, features.getValue("hdrColorSpace").jsonPrimitive.int) + assertEquals(0, sessionRequestData.getValue("sdrHdrMode").jsonPrimitive.int) + assertEquals(JsonNull, sessionRequestData.getValue("clientDisplayHdrCapabilities")) + } + + @Test + fun claimRequestExplicitlyMarksHdrColorMetadata() { + val settings = StreamSettings( + resolution = "1920x1080", + codec = VideoCodec.H265, + colorQuality = ColorQuality.TenBit420, + hdrEnabled = true, + ) + + val sessionRequestData = buildMinimalClaimRequestBody("123", "device", settings) + .getValue("sessionRequestData").jsonObject + val monitor = sessionRequestData + .getValue("clientRequestMonitorSettings").jsonArray.single().jsonObject + val features = sessionRequestData.getValue("requestedStreamingFeatures").jsonObject + + assertEquals(1, monitor.getValue("sdrHdrMode").jsonPrimitive.int) + assertEquals(1000, monitor.getValue("displayData").jsonObject + .getValue("desiredContentMaxLuminance").jsonPrimitive.int) + assertEquals(true, features.getValue("trueHdr").jsonPrimitive.boolean) + assertEquals(10, features.getValue("bitDepth").jsonPrimitive.int) + assertEquals(2, features.getValue("sdrColorSpace").jsonPrimitive.int) + assertEquals(4, features.getValue("hdrColorSpace").jsonPrimitive.int) + assertEquals(1, sessionRequestData.getValue("sdrHdrMode").jsonPrimitive.int) + assertTrue(sessionRequestData.getValue("clientDisplayHdrCapabilities") is kotlinx.serialization.json.JsonObject) + } + + @Test + fun claimRequestCarriesRequested120FpsMonitorSetting() { + val settings = StreamSettings( + resolution = "1920x1080", + aspectRatio = "16:9", + fps = 120, + maxBitrateMbps = 75, + codec = VideoCodec.H264, + colorQuality = ColorQuality.EightBit420, + ) + + val body = buildMinimalClaimRequestBody(appId = "123", deviceId = "device", settings = settings) + val monitor = body + .getValue("sessionRequestData").jsonObject + .getValue("clientRequestMonitorSettings").jsonArray + .single().jsonObject + + assertEquals(120, monitor.getValue("framesPerSecond").jsonPrimitive.int) + assertEquals( + true, + body.getValue("sessionRequestData").jsonObject + .getValue("requestedStreamingFeatures").jsonObject + .getValue("reflex").jsonPrimitive.boolean, + ) + } + + @Test + fun claimRequestCarriesRequested360FpsMonitorSetting() { + val settings = StreamSettings( + resolution = "1920x1080", + aspectRatio = "16:9", + fps = 360, + maxBitrateMbps = 75, + codec = VideoCodec.AV1, + colorQuality = ColorQuality.EightBit420, + ) + + val body = buildMinimalClaimRequestBody(appId = "123", deviceId = "device", settings = settings) + val monitor = body + .getValue("sessionRequestData").jsonObject + .getValue("clientRequestMonitorSettings").jsonArray + .single().jsonObject + + assertEquals(360, monitor.getValue("framesPerSecond").jsonPrimitive.int) + } + + @Test + fun claimRequestDoesNotAdvertiseAv1Chroma444() { + val settings = StreamSettings( + resolution = "1920x1080", + aspectRatio = "16:9", + fps = 60, + codec = VideoCodec.AV1, + colorQuality = ColorQuality.EightBit444, + ) + + val body = buildMinimalClaimRequestBody(appId = "123", deviceId = "device", settings = settings) + val sessionRequestData = body.getValue("sessionRequestData").jsonObject + val features = sessionRequestData.getValue("requestedStreamingFeatures").jsonObject + val signature = sessionRequestData.getValue("metaData").jsonArray.firstNotNullOfOrNull { item -> + item.jsonObject.takeIf { + it["key"]?.jsonPrimitive?.contentOrNull == OPENNOW_STREAM_SETTINGS_METADATA_KEY + }?.get("value")?.jsonPrimitive?.contentOrNull + } + + assertEquals(0, features.getValue("chromaFormat").jsonPrimitive.int) + assertTrue(signature?.contains("color=EightBit420") == true) + } + + @Test + fun claimRequestDoesNotAdvertiseAv1TenBitOrHdr() { + val settings = StreamSettings( + resolution = "1920x1080", + codec = VideoCodec.AV1, + colorQuality = ColorQuality.TenBit420, + hdrEnabled = true, + ) + + val sessionRequestData = buildMinimalClaimRequestBody("123", "device", settings) + .getValue("sessionRequestData").jsonObject + val monitor = sessionRequestData + .getValue("clientRequestMonitorSettings").jsonArray.single().jsonObject + val features = sessionRequestData.getValue("requestedStreamingFeatures").jsonObject + val signature = sessionRequestData.getValue("metaData").jsonArray.firstNotNullOfOrNull { item -> + item.jsonObject.takeIf { + it["key"]?.jsonPrimitive?.contentOrNull == OPENNOW_STREAM_SETTINGS_METADATA_KEY + }?.get("value")?.jsonPrimitive?.contentOrNull + } + + assertEquals(0, monitor.getValue("sdrHdrMode").jsonPrimitive.int) + assertEquals(JsonNull, monitor.getValue("displayData")) + assertEquals(0, features.getValue("bitDepth").jsonPrimitive.int) + assertEquals(false, features.getValue("trueHdr").jsonPrimitive.boolean) + assertEquals(0, features.getValue("hdrColorSpace").jsonPrimitive.int) + assertEquals(JsonNull, sessionRequestData.getValue("clientDisplayHdrCapabilities")) + assertTrue(signature?.contains("codec=AV1;color=EightBit420;hdr=0") == true) + } + + @Test + fun activeSessionMonitorSettingsPreferActualTopLevelMonitor() { + val session = OpenNowJson.parseToJsonElement( + """ + { + "sessionRequestData": { + "clientRequestMonitorSettings": [ + { "widthInPixels": 1680, "heightInPixels": 720, "framesPerSecond": 60 } + ] + }, + "monitorSettings": [ + { "widthInPixels": 1366, "heightInPixels": 768, "framesPerSecond": 60 } + ] + } + """.trimIndent(), + ).jsonObject + val monitor = requireNotNull(activeSessionMonitorSettings(session)) + + assertEquals(1366, monitor.getValue("widthInPixels").jsonPrimitive.int) + assertEquals(768, monitor.getValue("heightInPixels").jsonPrimitive.int) + } + + @Test + fun activeSessionMonitorSettingsFallsBackToTopLevelMonitor() { + val session = OpenNowJson.parseToJsonElement( + """ + { + "monitorSettings": [ + { "widthInPixels": 1366, "heightInPixels": 768, "framesPerSecond": 60 } + ] + } + """.trimIndent(), + ).jsonObject + val monitor = requireNotNull(activeSessionMonitorSettings(session)) + + assertEquals(1366, monitor.getValue("widthInPixels").jsonPrimitive.int) + assertEquals(768, monitor.getValue("heightInPixels").jsonPrimitive.int) + } + + @Test + fun activeSessionSettingsSignatureReadsSessionRequestMetadata() { + val settings = StreamSettings(resolution = "1680x720", aspectRatio = "21:9", fps = 60, maxBitrateMbps = 150, codec = VideoCodec.H265) + val signature = streamSettingsSessionSignature(settings) + val session = OpenNowJson.parseToJsonElement( + """ + { + "sessionRequestData": { + "metaData": [ + { "key": "$OPENNOW_STREAM_SETTINGS_METADATA_KEY", "value": "$signature" } + ] + } + } + """.trimIndent(), + ).jsonObject + + assertEquals(signature, activeSessionSettingsSignature(session)) + } + + @Test + fun providerLaunchBaseUsesSingleAdvertisedAllianceRegion() { + val base = providerLaunchBaseUrl( + providerBase = "https://prod.yes.geforcenow.nvidiagrid.net/", + regions = listOf(StreamRegion("MY YES", "https://my-yes.yes.geforcenow.nvidiagrid.net")), + ) + + assertEquals("https://my-yes.yes.geforcenow.nvidiagrid.net", base) + } + + @Test + fun providerLaunchBaseDoesNotGuessWhenProviderHasMultipleRegions() { + val base = providerLaunchBaseUrl( + providerBase = "https://prod.example.geforcenow.nvidiagrid.net/", + regions = listOf( + StreamRegion("A", "https://a.example.geforcenow.nvidiagrid.net"), + StreamRegion("B", "https://b.example.geforcenow.nvidiagrid.net"), + ), + ) + + assertEquals("https://prod.example.geforcenow.nvidiagrid.net", base) + } + + @Test + fun providerLaunchBaseDoesNotRewriteCloudmatchRoot() { + val base = providerLaunchBaseUrl( + providerBase = "https://prod.cloudmatchbeta.nvidiagrid.net/", + regions = listOf(StreamRegion("NP-AMS-06", "https://np-ams-06.cloudmatchbeta.nvidiagrid.net")), + ) + + assertEquals("https://prod.cloudmatchbeta.nvidiagrid.net", base) + } + + @Test + fun usableSessionHostRejectsPlaceholderAllianceHosts() { + assertNull(usableSessionHost(".yes.geforcenow.nvidiagrid.net")) + assertNull(usableSessionHost("bad..host")) + assertEquals("183-78-14-238.yes.geforcenow.nvidiagrid.net", usableSessionHost("183-78-14-238.yes.geforcenow.nvidiagrid.net")) + } + + @Test + fun diagnosticLogPayloadRedactsSensitiveJsonFields() { + val exported = sanitizeDiagnosticLogPayload( + """ + { + "session": { + "sessionId": "session-123", + "queuePosition": 4, + "iceServerConfiguration": { + "iceServers": [ + { + "urls": ["turn:example.invalid"], + "username": "ice-user", + "credential": "ice-secret" + } + ] + } + }, + "accessToken": "token-value", + "email": "player@example.invalid" + } + """.trimIndent(), + ) + + assertTrue(exported.contains("\"sessionId\": \"session-123\"")) + assertTrue(exported.contains("\"queuePosition\": 4")) + assertFalse(exported.contains("ice-secret")) + assertFalse(exported.contains("token-value")) + assertFalse(exported.contains("player@example.invalid")) + assertTrue(exported.contains("\"credential\": \"[redacted]\"")) + assertTrue(exported.contains("\"accessToken\": \"[redacted]\"")) + } + + @Test + fun diagnosticLogPayloadRedactsDeviceLoginAndDeviceIds() { + val exported = sanitizeDiagnosticLogPayload( + """ + { + "device_code": "device-secret", + "user_code": "ABCD-EFGH", + "verification_uri_complete": "https://login.example/activate?user_code=ABCD-EFGH", + "deviceHashId": "stable-device-id", + "statusCode": 1 + } + """.trimIndent(), + ) + + assertFalse(exported.contains("device-secret")) + assertFalse(exported.contains("ABCD-EFGH")) + assertFalse(exported.contains("stable-device-id")) + assertTrue(exported.contains("\"device_code\": \"[redacted]\"")) + assertTrue(exported.contains("\"user_code\": \"[redacted]\"")) + assertTrue(exported.contains("\"deviceHashId\": \"[redacted]\"")) + assertTrue(exported.contains("\"statusCode\": 1")) + } + + @Test + fun diagnosticUrlRedactsSensitiveQueryParameters() { + val exported = redactDiagnosticUrl("https://login.example/token?code=abc123&device_id=device-1&requestType=session") + + assertFalse(exported.contains("abc123")) + assertFalse(exported.contains("device-1")) + assertTrue(exported.contains("code=%5Bredacted%5D")) + assertTrue(exported.contains("device_id=%5Bredacted%5D")) + assertTrue(exported.contains("requestType=session")) + } + + @Test + fun diagnosticLogPayloadRedactsFormEncodedAuthFields() { + val exported = sanitizeDiagnosticLogPayload( + "grant_type=client_token&client_token=client-secret&client_id=public-client&sub=user-123", + ) + + assertFalse(exported.contains("client-secret")) + assertFalse(exported.contains("user-123")) + assertTrue(exported.contains("client_token=[redacted]")) + assertTrue(exported.contains("sub=[redacted]")) + assertTrue(exported.contains("client_id=public-client")) + } + + @Test + fun touchFriendlyClaimRequestUsesAndroidPlatformIdentity() { + val body = buildMinimalClaimRequestBody( + appId = "123", + deviceId = "device", + appLaunchMode = GfnAppLaunchMode.TOUCH_FRIENDLY, + ) + val sessionRequestData = body.getValue("sessionRequestData").jsonObject + + assertEquals("android", sessionRequestData.getValue("clientPlatformName").jsonPrimitive.content) + assertEquals(GfnAppLaunchMode.TOUCH_FRIENDLY, sessionRequestData.getValue("appLaunchMode").jsonPrimitive.int) + } + + @Test + fun gamepadFriendlyClaimRequestKeepsBrowserPlatformIdentity() { + val body = buildMinimalClaimRequestBody( + appId = "123", + deviceId = "device", + appLaunchMode = GfnAppLaunchMode.GAMEPAD_FRIENDLY, + ) + val sessionRequestData = body.getValue("sessionRequestData").jsonObject + assertEquals("browser", sessionRequestData.getValue("clientPlatformName").jsonPrimitive.content) + assertEquals(GfnAppLaunchMode.GAMEPAD_FRIENDLY, sessionRequestData.getValue("appLaunchMode").jsonPrimitive.int) + } + + @Test + fun highPerformanceGamepadClaimUsesNativeDesktopPlatformIdentity() { + val body = buildMinimalClaimRequestBody( + appId = "123", + deviceId = "device", + settings = StreamSettings(resolution = "2560x1440", fps = 120), + appLaunchMode = GfnAppLaunchMode.GAMEPAD_FRIENDLY, + ) + val sessionRequestData = body.getValue("sessionRequestData").jsonObject + + assertEquals("windows", sessionRequestData.getValue("clientPlatformName").jsonPrimitive.content) + assertEquals(true, sessionRequestData.getValue("enablePersistingInGameSettings").jsonPrimitive.boolean) + assertEquals(100, sessionRequestData.getValue("clientRequestMonitorSettings").jsonArray.single().jsonObject.getValue("dpi").jsonPrimitive.int) + } + + @Test + fun shieldFourKHdrClaimUsesDesktopAllocationAndPreservesRequestedProfile() { + val settings = StreamSettings( + resolution = "3840x2160", + aspectRatio = "16:9", + fps = 60, + codec = VideoCodec.H265, + colorQuality = ColorQuality.TenBit420, + hdrEnabled = true, + ) + val body = buildMinimalClaimRequestBody( + appId = "123", + deviceId = "device", + settings = settings, + physicalDisplayResolution = 3840 to 2160, + streamingBaseUrl = "https://np-sth-04.cloudmatchbeta.nvidiagrid.net", + appLaunchMode = GfnAppLaunchMode.GAMEPAD_FRIENDLY, + isAndroidTv = true, + useDesktopNativeTvIdentity = true, + ) + val sessionRequestData = body.getValue("sessionRequestData").jsonObject + val monitor = sessionRequestData.getValue("clientRequestMonitorSettings").jsonArray.single().jsonObject + val features = sessionRequestData.getValue("requestedStreamingFeatures").jsonObject + + assertEquals("windows", sessionRequestData.getValue("clientPlatformName").jsonPrimitive.content) + assertEquals(true, sessionRequestData.getValue("enablePersistingInGameSettings").jsonPrimitive.boolean) + assertEquals(3840, monitor.getValue("widthInPixels").jsonPrimitive.int) + assertEquals(2160, monitor.getValue("heightInPixels").jsonPrimitive.int) + assertEquals(60, monitor.getValue("framesPerSecond").jsonPrimitive.int) + assertEquals(1, monitor.getValue("sdrHdrMode").jsonPrimitive.int) + assertEquals(0, monitor.getValue("monitorId").jsonPrimitive.int) + assertEquals(100, monitor.getValue("dpi").jsonPrimitive.int) + assertEquals(10, features.getValue("bitDepth").jsonPrimitive.int) + assertEquals(true, features.getValue("trueHdr").jsonPrimitive.boolean) + assertEquals(4, features.getValue("hdrColorSpace").jsonPrimitive.int) + } + + @Test + fun fireTvCube1440pClaimUsesDesktopAllocationAndPreservesRequestedProfile() { + val sessionRequestData = buildMinimalClaimRequestBody( + appId = "123", + deviceId = "device", + settings = StreamSettings( + resolution = "2560x1440", + aspectRatio = "16:9", + fps = 60, + codec = VideoCodec.H265, + maxBitrateMbps = 75, + ), + physicalDisplayResolution = 1920 to 1080, + streamingBaseUrl = "https://np-mia-04.cloudmatchbeta.nvidiagrid.net", + appLaunchMode = GfnAppLaunchMode.GAMEPAD_FRIENDLY, + isAndroidTv = true, + useDesktopNativeTvIdentity = usesDesktopNativeTvCloudMatchIdentity(true, "Amazon", "AFTGAZL"), + ).getValue("sessionRequestData").jsonObject + val monitor = sessionRequestData.getValue("clientRequestMonitorSettings").jsonArray.single().jsonObject + + assertEquals("windows", sessionRequestData.getValue("clientPlatformName").jsonPrimitive.content) + assertEquals(true, sessionRequestData.getValue("enablePersistingInGameSettings").jsonPrimitive.boolean) + assertEquals(2560, monitor.getValue("widthInPixels").jsonPrimitive.int) + assertEquals(1440, monitor.getValue("heightInPixels").jsonPrimitive.int) + assertEquals(60, monitor.getValue("framesPerSecond").jsonPrimitive.int) + assertEquals(100, monitor.getValue("dpi").jsonPrimitive.int) + } + + @Test + fun otherAndroidTvFourKClaimKeepsAndroidPlatformAllocation() { + val sessionRequestData = buildMinimalClaimRequestBody( + appId = "123", + deviceId = "device", + settings = StreamSettings(resolution = "3840x2160", fps = 60, codec = VideoCodec.H265), + streamingBaseUrl = "https://np-sth-04.cloudmatchbeta.nvidiagrid.net", + appLaunchMode = GfnAppLaunchMode.GAMEPAD_FRIENDLY, + isAndroidTv = true, + useDesktopNativeTvIdentity = false, + ).getValue("sessionRequestData").jsonObject + + assertEquals("android", sessionRequestData.getValue("clientPlatformName").jsonPrimitive.content) + assertEquals(false, sessionRequestData.getValue("enablePersistingInGameSettings").jsonPrimitive.boolean) + } + + @Test + fun hdrClaimAlsoRequiresDesktopAllocationAt1080p() { + val settings = StreamSettings( + resolution = "1920x1080", + fps = 60, + codec = VideoCodec.H265, + colorQuality = ColorQuality.TenBit420, + hdrEnabled = true, + ) + + assertTrue(settings.requiresNativeDesktopCloudMatchMode()) + + val sessionRequestData = buildMinimalClaimRequestBody( + appId = "123", + deviceId = "device", + settings = settings, + appLaunchMode = GfnAppLaunchMode.GAMEPAD_FRIENDLY, + ).getValue("sessionRequestData").jsonObject + + assertEquals("windows", sessionRequestData.getValue("clientPlatformName").jsonPrimitive.content) + assertEquals(true, sessionRequestData.getValue("enablePersistingInGameSettings").jsonPrimitive.boolean) + assertEquals(100, sessionRequestData.getValue("clientRequestMonitorSettings").jsonArray.single().jsonObject.getValue("dpi").jsonPrimitive.int) + } + + @Test + fun highPerformanceTouchClaimUsesNativeAndroidMonitorIdentity() { + val body = buildMinimalClaimRequestBody( + appId = "123", + deviceId = "device", + settings = StreamSettings(resolution = "2560x1440", fps = 120), + appLaunchMode = GfnAppLaunchMode.TOUCH_FRIENDLY, + ) + val sessionRequestData = body.getValue("sessionRequestData").jsonObject + + assertEquals("android", sessionRequestData.getValue("clientPlatformName").jsonPrimitive.content) + assertEquals(false, sessionRequestData.getValue("enablePersistingInGameSettings").jsonPrimitive.boolean) + assertEquals(100, sessionRequestData.getValue("clientRequestMonitorSettings").jsonArray.single().jsonObject.getValue("dpi").jsonPrimitive.int) + } + +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/InitialStreamConnectionStatusTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/InitialStreamConnectionStatusTest.kt new file mode 100644 index 000000000..31f402eec --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/InitialStreamConnectionStatusTest.kt @@ -0,0 +1,37 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertEquals +import org.junit.Test + +class InitialStreamConnectionStatusTest { + @Test + fun translatesInitialNativeStatesIntoUserFacingCopy() { + assertEquals("Preparing your stream", initialStreamConnectionStatus("Preparing").title) + assertEquals("Connecting to your game", initialStreamConnectionStatus("Connecting signaling").title) + assertEquals("Starting the video stream", initialStreamConnectionStatus("Waiting for offer").title) + assertEquals("Almost ready", initialStreamConnectionStatus("ICE_CHECKING").title) + assertEquals("Connection established", initialStreamConnectionStatus("Streaming").title) + } + + @Test + fun explainsAnInitialRetryWithoutTechnicalErrorText() { + val retry = initialStreamConnectionStatus("Reconnecting stream (1/3)") + + assertEquals("Retrying connection", retry.phase) + assertEquals("Connecting again", retry.title) + assertEquals( + "The initial connection did not finish, so OpenNOW is retrying it.", + retry.detail, + ) + } + + @Test + fun explainsSafeVideoFallback() { + val fallback = initialStreamConnectionStatus( + "Video packets arrived but no frame rendered. Restarting with safe H264 profile.", + ) + + assertEquals("Optimizing video", fallback.phase) + assertEquals("Trying a compatible video mode", fallback.title) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/InputDataChannelLabelsTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/InputDataChannelLabelsTest.kt new file mode 100644 index 000000000..0d9ad443d --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/InputDataChannelLabelsTest.kt @@ -0,0 +1,14 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertEquals +import org.junit.Test + +class InputDataChannelLabelsTest { + @Test + fun classifiesOnlyInputChannelsAsInputTransport() { + assertEquals(InputDataChannelRole.Reliable, InputDataChannelLabels.classify("input_channel_v1")) + assertEquals(InputDataChannelRole.PartiallyReliable, InputDataChannelLabels.classify("input_channel_partially_reliable")) + assertEquals(InputDataChannelRole.Other, InputDataChannelLabels.classify("control_channel")) + assertEquals(InputDataChannelRole.Other, InputDataChannelLabels.classify("remote_trace_channel")) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/InputDiagnosticsTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/InputDiagnosticsTest.kt new file mode 100644 index 000000000..8ea4c2dbc --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/InputDiagnosticsTest.kt @@ -0,0 +1,119 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class InputDiagnosticsTest { + @Test + fun retainedControllerStateSurvivesRecentEventOverflow() { + var now = 100L + val buffer = InputDiagnosticsBuffer( + maxRecentLines = 2, + maxRetainedLines = 4, + elapsedRealtime = { now++ }, + ) + + buffer.addRetained("controller.axes.0", "physical gamepad axes x=1.000 y=0.000") + buffer.add("touch event one") + buffer.add("touch event two") + buffer.add("touch event three") + + val snapshot = buffer.snapshot() + assertTrue(snapshot.contains("input.state:")) + assertTrue(snapshot.contains("controller.axes.0 100 physical gamepad axes x=1.000 y=0.000")) + assertFalse(snapshot.contains("touch event one")) + assertTrue(snapshot.contains("touch event two")) + assertTrue(snapshot.contains("touch event three")) + } + + @Test + fun throttledStateSkipsFormattingUntilIntervalExpires() { + var now = 1_000L + var formatted = 0 + val buffer = InputDiagnosticsBuffer( + maxRecentLines = 2, + maxRetainedLines = 2, + elapsedRealtime = { now }, + ) + + buffer.retainThrottled("controller.packet.0", 1_000L) { + formatted += 1 + "packet first" + } + now = 1_500L + buffer.retainThrottled("controller.packet.0", 1_000L) { + formatted += 1 + "packet suppressed" + } + now = 2_000L + buffer.retainThrottled("controller.packet.0", 1_000L) { + formatted += 1 + "packet latest" + } + + assertEquals(2, formatted) + val snapshot = buffer.snapshot() + assertFalse(snapshot.contains("packet first")) + assertFalse(snapshot.contains("packet suppressed")) + assertTrue(snapshot.contains("controller.packet.0 2000 packet latest")) + } + + @Test + fun retainedStateRemainsBounded() { + var now = 10L + val buffer = InputDiagnosticsBuffer( + maxRecentLines = 1, + maxRetainedLines = 2, + elapsedRealtime = { now++ }, + ) + + buffer.retain("oldest", "one") + buffer.retain("middle", "two") + buffer.retain("newest", "three") + + val snapshot = buffer.snapshot() + assertFalse(snapshot.contains("oldest")) + assertTrue(snapshot.contains("middle")) + assertTrue(snapshot.contains("newest")) + } + + @Test + fun countedStateAggregatesRepetitiveEventsWithoutUsingRecentCapacity() { + var now = 50L + val buffer = InputDiagnosticsBuffer( + maxRecentLines = 1, + maxRetainedLines = 2, + elapsedRealtime = { now++ }, + ) + + repeat(3) { + buffer.retainCounted("touch-route.activity") { "touch consumed by view" } + } + + val snapshot = buffer.snapshot() + assertTrue(snapshot.contains("touch-route.activity 52 count=3 touch consumed by view")) + assertFalse(snapshot.contains("input.diagnostics:")) + } + + @Test + fun resultStateRetainsLatestOutcomeAndLastSuccessAndFailure() { + var now = 100L + val buffer = InputDiagnosticsBuffer( + maxRecentLines = 1, + maxRetainedLines = 4, + elapsedRealtime = { now }, + ) + + buffer.retainResult("heartbeat.input", succeeded = false) { "path=worker" } + now = 200L + buffer.retainResult("heartbeat.input", succeeded = true) { "path=worker" } + + val snapshot = buffer.snapshot() + assertTrue(snapshot.contains("heartbeat.input.last 200 count=2 success=true path=worker")) + assertTrue(snapshot.contains("heartbeat.input.failure 100 count=1 path=worker")) + assertTrue(snapshot.contains("heartbeat.input.success 200 count=1 path=worker")) + assertFalse(snapshot.contains("input.diagnostics:")) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/InputEncoderGamepadTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/InputEncoderGamepadTest.kt new file mode 100644 index 000000000..4a59f006f --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/InputEncoderGamepadTest.kt @@ -0,0 +1,574 @@ +package com.opencloudgaming.opennow + +import android.view.InputDevice +import android.view.KeyEvent +import java.nio.ByteBuffer +import java.nio.ByteOrder +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class InputEncoderGamepadTest { + @Test + fun encodesGuideButtonMaskForSteamOverlay() { + val encoder = InputEncoder().apply { setProtocolVersion(2) } + val payload = encoder.encodeGamepadState( + controllerId = 0, + buttons = 0x0400, + leftTrigger = 0, + rightTrigger = 0, + leftStickX = 0, + leftStickY = 0, + rightStickX = 0, + rightStickY = 0, + bitmap = 0x0101, + partiallyReliable = false, + timestampUs = 0L, + ) + val bytes = ByteBuffer.wrap(payload).order(ByteOrder.LITTLE_ENDIAN) + + assertEquals(InputEncoder.INPUT_GAMEPAD, bytes.getInt(0)) + assertEquals(0x0400, bytes.getShort(12).toInt() and 0xffff) + } + + @Test + fun mapsAndroidGamepadButtonsToXinputMasks() { + assertEquals(GamepadButtonMapping.GUIDE, GamepadButtonMapping.maskForKeyCode(KeyEvent.KEYCODE_BUTTON_MODE)) + assertEquals(GamepadButtonMapping.START, GamepadButtonMapping.maskForKeyCode(KeyEvent.KEYCODE_BUTTON_START)) + assertEquals(GamepadButtonMapping.BACK, GamepadButtonMapping.maskForKeyCode(KeyEvent.KEYCODE_BUTTON_SELECT)) + assertEquals(GamepadButtonMapping.START, GamepadButtonMapping.maskForKeyCode(KeyEvent.KEYCODE_MENU, controllerActivation = true)) + assertEquals(GamepadButtonMapping.BACK, GamepadButtonMapping.maskForKeyCode(KeyEvent.KEYCODE_BACK, controllerActivation = true)) + assertNull(GamepadButtonMapping.maskForKeyCode(KeyEvent.KEYCODE_MENU)) + assertNull(GamepadButtonMapping.maskForKeyCode(KeyEvent.KEYCODE_BACK)) + assertEquals(GamepadButtonMapping.LEFT_THUMB, GamepadButtonMapping.maskForKeyCode(KeyEvent.KEYCODE_BUTTON_THUMBL)) + assertEquals(GamepadButtonMapping.RIGHT_THUMB, GamepadButtonMapping.maskForKeyCode(KeyEvent.KEYCODE_BUTTON_THUMBR)) + } + + @Test + fun buildsSteamMenuAsGuideHeldWithA() { + assertEquals(GamepadButtonMapping.GUIDE, SteamMenuChord.buttons(aPressed = false)) + assertEquals( + GamepadButtonMapping.GUIDE, + SteamMenuChord.buttons(aPressed = true), + ) + } + + @Test + fun viewAndStartChordSendsHomeAWithoutLeakingTopButtons() { + val chord = SteamOverlayChordState() + + assertFalse(chord.update(GamepadButtonMapping.BACK)) + assertEquals(GamepadButtonMapping.BACK, chord.effectiveButtons(GamepadButtonMapping.BACK)) + + val both = GamepadButtonMapping.BACK or GamepadButtonMapping.START + assertTrue(chord.update(both)) + assertEquals( + GamepadButtonMapping.GUIDE, + chord.effectiveButtons(both), + ) + assertTrue(chord.releaseChord()) + assertEquals(0, chord.effectiveButtons(both)) + + assertFalse(chord.update(0)) + assertFalse(chord.update(GamepadButtonMapping.START)) + assertEquals(GamepadButtonMapping.START, chord.effectiveButtons(GamepadButtonMapping.START)) + } + + @Test + fun classifiesControllerButtonKeyCodesWithoutDependingOnEventSource() { + assertTrue(GamepadButtonMapping.isControllerButtonKeyCode(KeyEvent.KEYCODE_BUTTON_MODE)) + assertTrue(GamepadButtonMapping.isControllerButtonKeyCode(KeyEvent.KEYCODE_BUTTON_START)) + assertTrue(GamepadButtonMapping.isControllerButtonKeyCode(KeyEvent.KEYCODE_BUTTON_SELECT)) + assertTrue(GamepadButtonMapping.isControllerButtonKeyCode(KeyEvent.KEYCODE_BUTTON_L2)) + assertTrue(GamepadButtonMapping.isControllerButtonKeyCode(KeyEvent.KEYCODE_BUTTON_R2)) + assertFalse(GamepadButtonMapping.isControllerButtonKeyCode(KeyEvent.KEYCODE_DPAD_CENTER)) + assertFalse(GamepadButtonMapping.isControllerButtonKeyCode(KeyEvent.KEYCODE_ENTER)) + } + + @Test + fun detectsControllerCapableAndroidSources() { + assertTrue(AndroidControllerInput.hasControllerSource(InputDevice.SOURCE_GAMEPAD or InputDevice.SOURCE_DPAD)) + assertTrue(AndroidControllerInput.hasControllerSource(InputDevice.SOURCE_JOYSTICK or InputDevice.SOURCE_DPAD)) + assertFalse(AndroidControllerInput.hasControllerSource(InputDevice.SOURCE_DPAD)) + assertFalse(AndroidControllerInput.hasControllerSource(InputDevice.SOURCE_KEYBOARD or InputDevice.SOURCE_DPAD)) + } + + @Test + fun doesNotAdvertiseCompositeKeyboardsAndMiceAsGamepads() { + val misleadingSources = + InputDevice.SOURCE_GAMEPAD or + InputDevice.SOURCE_JOYSTICK or + InputDevice.SOURCE_KEYBOARD or + InputDevice.SOURCE_MOUSE + + assertFalse(AndroidControllerInput.isControllerDevice(misleadingSources, "SEMICO USB Keyboard System Control")) + assertFalse(AndroidControllerInput.isControllerDevice(misleadingSources, "BT5.2 Mouse")) + assertFalse(AndroidControllerInput.isControllerDevice(misleadingSources, "Gaming KB Gaming KB Keyboard")) + assertFalse(AndroidControllerInput.isControllerDevice(misleadingSources, "uinput-goodix")) + assertFalse(AndroidControllerInput.isControllerDevice(misleadingSources, "uinput-fpc")) + assertFalse(AndroidControllerInput.isControllerDevice(misleadingSources, "Fingerprint Sensor")) + assertTrue(AndroidControllerInput.isControllerDevice(misleadingSources, "Xbox Wireless Controller")) + } + + @Test + fun syntheticControllerEventsReuseTheLiveAndroidDeviceId() { + val controllerSlots = linkedMapOf() + val assignment = AndroidControllerSlotRegistry.assign( + controllerSlots = controllerSlots, + deviceId = -1, + connectedDeviceIds = setOf(14), + maxControllers = 4, + ) + + assertEquals(0, assignment.slot) + assertEquals(mapOf(14 to 0), controllerSlots) + assertTrue( + AndroidControllerSlotRegistry.retainConnected( + controllerSlots = controllerSlots, + connectedDeviceIds = setOf(14), + ).isEmpty(), + ) + } + + @Test + fun syntheticControllerEventsPreferTheAlreadyAssignedPrimaryController() { + val controllerSlots = linkedMapOf(14 to 0, 15 to 1) + val assignment = AndroidControllerSlotRegistry.assign( + controllerSlots = controllerSlots, + deviceId = -1, + connectedDeviceIds = setOf(14, 15), + maxControllers = 4, + ) + + assertEquals(0, assignment.slot) + assertTrue(assignment.removedDevices.isEmpty()) + assertEquals(mapOf(14 to 0, 15 to 1), controllerSlots) + } + + @Test + fun neutralControllerKeepaliveDoesNotFightFingerMouse() { + assertFalse( + shouldSendGamepadKeepalive( + hasControllerState = true, + hasActiveControllerInput = false, + touchMouseEnabled = true, + ), + ) + assertTrue( + shouldSendGamepadKeepalive( + hasControllerState = true, + hasActiveControllerInput = true, + touchMouseEnabled = true, + ), + ) + assertTrue( + shouldSendGamepadKeepalive( + hasControllerState = true, + hasActiveControllerInput = false, + touchMouseEnabled = false, + ), + ) + } + + @Test + fun reusesPrimarySlotWhenAndroidReassignsControllerDeviceId() { + val controllerSlots = linkedMapOf() + val initial = AndroidControllerSlotRegistry.assign( + controllerSlots = controllerSlots, + deviceId = 21, + connectedDeviceIds = setOf(21), + maxControllers = 4, + ) + + val reconnected = AndroidControllerSlotRegistry.assign( + controllerSlots = controllerSlots, + deviceId = 44, + connectedDeviceIds = setOf(44), + maxControllers = 4, + ) + + assertEquals(0, initial.slot) + assertEquals(mapOf(21 to 0), reconnected.removedDevices) + assertEquals(0, reconnected.slot) + assertEquals(mapOf(44 to 0), controllerSlots) + } + + @Test + fun disconnectScanReleasesControllerSlotBeforeReconnect() { + val controllerSlots = linkedMapOf(21 to 0) + + val removed = AndroidControllerSlotRegistry.retainConnected( + controllerSlots = controllerSlots, + connectedDeviceIds = emptySet(), + ) + val reconnected = AndroidControllerSlotRegistry.assign( + controllerSlots = controllerSlots, + deviceId = 44, + connectedDeviceIds = setOf(44), + maxControllers = 4, + ) + + assertEquals(mapOf(21 to 0), removed) + assertEquals(0, reconnected.slot) + assertEquals(mapOf(44 to 0), controllerSlots) + } + + @Test + fun reconnectReusesOnlyTheSlotVacatedByDisconnectedController() { + val controllerSlots = linkedMapOf(21 to 0, 32 to 1) + + val reconnected = AndroidControllerSlotRegistry.assign( + controllerSlots = controllerSlots, + deviceId = 44, + connectedDeviceIds = setOf(32, 44), + maxControllers = 4, + ) + + assertEquals(mapOf(21 to 0), reconnected.removedDevices) + assertEquals(0, reconnected.slot) + assertEquals(mapOf(32 to 1, 44 to 0), controllerSlots) + } + + @Test + fun recognizesStadiaControllerNamesWithDpadOnlySources() { + assertTrue(AndroidControllerInput.isKnownControllerName("Stadia Controller rev. A")) + assertTrue(AndroidControllerInput.isKnownControllerName("Google Stadia Controller")) + assertTrue(AndroidControllerInput.isControllerDevice(InputDevice.SOURCE_DPAD, "Stadia Controller")) + assertTrue(AndroidControllerInput.isControllerDevice(InputDevice.SOURCE_DPAD, "DualSense Wireless Controller")) + assertTrue(AndroidControllerInput.isControllerDevice(InputDevice.SOURCE_DPAD, "Xbox Wireless Controller")) + assertFalse(AndroidControllerInput.isControllerDevice(InputDevice.SOURCE_DPAD, "TV Remote")) + } + + @Test + fun classifiesControllerFamiliesForBackButtonHints() { + assertEquals(AndroidControllerFamily.Google, AndroidControllerInput.controllerFamily("Chromecast Remote")) + assertEquals(AndroidControllerFamily.Xbox, AndroidControllerInput.controllerFamily("Xbox Wireless Controller")) + assertEquals(AndroidControllerFamily.PlayStation, AndroidControllerInput.controllerFamily("DualSense Wireless Controller")) + assertEquals(AndroidControllerFamily.PlayStation, AndroidControllerInput.controllerFamily("Sony Interactive Entertainment Wireless Controller")) + assertEquals(AndroidControllerFamily.PlayStation, AndroidControllerInput.controllerFamily("Generic Gamepad", vendorId = 0x054c)) + assertEquals(AndroidControllerFamily.Nintendo, AndroidControllerInput.controllerFamily("Nintendo Switch Pro Controller")) + assertEquals(AndroidControllerFamily.Generic, AndroidControllerInput.controllerFamily("8BitDo Gamepad")) + } + + @Test + fun advertisesPlayStationControllersWithoutTheXinputStyleBit() { + assertEquals( + 0x0001, + androidGamepadConnectionBitmap( + controllerId = 0, + connected = true, + physicalControllerFamily = AndroidControllerFamily.PlayStation, + ), + ) + assertEquals( + 0x0202, + androidGamepadConnectionBitmap( + controllerId = 1, + connected = true, + physicalControllerFamily = AndroidControllerFamily.Xbox, + ), + ) + assertEquals( + 0x0404, + androidGamepadConnectionBitmap( + controllerId = 2, + connected = true, + physicalControllerFamily = null, + ), + ) + assertEquals( + 0, + androidGamepadConnectionBitmap( + controllerId = 0, + connected = false, + physicalControllerFamily = AndroidControllerFamily.PlayStation, + ), + ) + } + + @Test + fun forcedControllerRumbleUsesTheXinputCompatiblePlayStationIdentity() { + assertEquals( + 0x0101, + androidGamepadConnectionBitmap( + controllerId = 0, + connected = true, + physicalControllerFamily = AndroidControllerFamily.PlayStation, + playStationRumbleCompatibility = true, + ), + ) + } + + @Test + fun resolvesHatOnlyControllerMotionAsLeftStick() { + val axes = AndroidGamepadAxisMapping.resolve( + raw = AndroidGamepadRawAxes(hatX = -1f, hatY = 0.75f), + available = AndroidGamepadAxisAvailability( + x = false, + y = false, + z = false, + rz = false, + rx = false, + ry = false, + hatX = true, + hatY = true, + ), + ) + + assertEquals(-1f, axes.leftX, 0.0001f) + assertEquals(0.75f, axes.leftY, 0.0001f) + assertEquals("hat", axes.leftSource) + assertTrue(axes.hatUsedAsLeftStick) + } + + @Test + fun keepsStandardControllerAxesOnExpectedSticks() { + val axes = AndroidGamepadAxisMapping.resolve( + AndroidGamepadRawAxes(x = 0.5f, y = -0.25f, z = 0.6f, rz = -0.7f, rx = -0.2f, ry = 0.1f), + ) + + assertEquals(0.5f, axes.leftX, 0.0001f) + assertEquals(-0.25f, axes.leftY, 0.0001f) + assertEquals(0.6f, axes.rightX, 0.0001f) + assertEquals(-0.7f, axes.rightY, 0.0001f) + assertEquals("x/y", axes.leftSource) + assertEquals("z/rz", axes.rightSource) + assertFalse(axes.hatUsedAsLeftStick) + } + + @Test + fun mapsControllerMouseAssistFromRightStick() { + val delta = requireNotNull(AndroidControllerMouseAssist.mouseDelta(0.75f, -0.5f)) + + assertTrue(delta.dx > 0) + assertTrue(delta.dy < 0) + assertNull(AndroidControllerMouseAssist.mouseDelta(0f, 0f)) + } + + @Test + fun controllerMouseAssistDropsNonFiniteAxisValues() { + assertNull(AndroidControllerMouseAssist.mouseDelta(Float.NaN, 0f)) + assertNull(AndroidControllerMouseAssist.mouseDelta(0f, Float.POSITIVE_INFINITY)) + assertEquals(Pair(0, 0f), AndroidControllerMouseAssist.scrollNotches(Float.NaN, 30, 0f)) + assertEquals(Pair(0, 0f), AndroidControllerMouseAssist.scrollNotches(1f, 30, Float.NaN)) + } + + @Test + fun mapsControllerMouseClicksWithoutTakingOverOtherGameplayButtons() { + assertNull(AndroidControllerMouseAssist.mouseButtonForGamepad(GamepadButtonMapping.RIGHT_THUMB)) + assertEquals(1, AndroidControllerMouseAssist.mouseButtonForGamepad(GamepadButtonMapping.A)) + assertEquals(3, AndroidControllerMouseAssist.mouseButtonForGamepad(GamepadButtonMapping.B)) + assertNull(AndroidControllerMouseAssist.mouseButtonForTrigger(left = true)) + assertNull(AndroidControllerMouseAssist.mouseButtonForTrigger(left = false)) + } + + @Test + fun classifiesMemoryConstrainedTvWithoutDowngradingSameMemoryMobile() { + val twoGiB = 2L * 1024L * 1024L * 1024L + + assertTrue(isLowPowerStreamingProfile(androidTvProfile = true, renderer = "amlogic", totalMemoryBytes = twoGiB)) + assertFalse(isLowPowerStreamingProfile(androidTvProfile = false, renderer = "adreno", totalMemoryBytes = twoGiB)) + assertFalse(isLowPowerStreamingProfile(androidTvProfile = true, renderer = "adreno", totalMemoryBytes = 4L * 1024L * 1024L * 1024L)) + } + + @Test + fun classifies32BitPhonesAndMemoryConstrainedTvsAsConstrainedRuntimes() { + val twoGiB = 2L * 1024L * 1024L * 1024L + val fourGiB = 4L * 1024L * 1024L * 1024L + + assertTrue(isConstrainedStreamingRuntime(androidTvProfile = false, is64BitRuntime = false, totalMemoryBytes = fourGiB)) + assertTrue(isConstrainedStreamingRuntime(androidTvProfile = true, is64BitRuntime = true, totalMemoryBytes = twoGiB)) + assertFalse(isConstrainedStreamingRuntime(androidTvProfile = false, is64BitRuntime = true, totalMemoryBytes = twoGiB)) + assertTrue( + isLowPowerStreamingProfile( + androidTvProfile = false, + renderer = "adreno", + totalMemoryBytes = fourGiB, + is64BitRuntime = false, + ), + ) + } + + @Test + fun mapsControllerActivationKeysToPrimaryGamepadButtonOnlyForControllers() { + assertEquals( + GamepadButtonMapping.A, + GamepadButtonMapping.maskForKeyCode(KeyEvent.KEYCODE_DPAD_CENTER, controllerActivation = true), + ) + assertEquals( + GamepadButtonMapping.A, + GamepadButtonMapping.maskForKeyCode(KeyEvent.KEYCODE_ENTER, controllerActivation = true), + ) + assertNull(GamepadButtonMapping.maskForKeyCode(KeyEvent.KEYCODE_DPAD_CENTER)) + assertNull(GamepadButtonMapping.maskForKeyCode(KeyEvent.KEYCODE_ENTER)) + } + + @Test + fun normalizesControllerAForNativeUiActivation() { + assertEquals( + KeyEvent.KEYCODE_DPAD_CENTER, + NativeStreamInputRouter.normalizedAppUiKeyCode(KeyEvent.KEYCODE_BUTTON_A, streamUiActive = false), + ) + } + + @Test + fun consumesControllerBAsNativeUiBackNavigation() { + assertTrue( + NativeStreamInputRouter.isControllerAppBackKey( + keyCode = KeyEvent.KEYCODE_BUTTON_B, + controllerSource = false, + streamUiActive = false, + ), + ) + } + + @Test + fun reservesOnlyNonControllerMenuForStreamControls() { + assertTrue(NativeStreamInputRouter.shouldOpenStreamSystemMenuKey(KeyEvent.KEYCODE_MENU, controllerInputDevice = false)) + assertFalse(NativeStreamInputRouter.shouldOpenStreamSystemMenuKey(KeyEvent.KEYCODE_MENU, controllerInputDevice = true)) + assertFalse(NativeStreamInputRouter.shouldOpenStreamSystemMenuKey(KeyEvent.KEYCODE_BUTTON_START, controllerInputDevice = true)) + } + + @Test + fun reservesRemoteBackAliasesForStreamControlsWithoutTakingControllerButtons() { + assertTrue( + NativeStreamInputRouter.shouldHandleStreamExitKey( + KeyEvent.KEYCODE_BACK, + controllerInputDevice = false, + hardwareKeyboardSource = false, + ), + ) + assertFalse( + NativeStreamInputRouter.shouldHandleStreamExitKey( + KeyEvent.KEYCODE_BACK, + controllerInputDevice = true, + hardwareKeyboardSource = false, + ), + ) + assertTrue( + NativeStreamInputRouter.shouldHandleStreamExitKey( + KeyEvent.KEYCODE_BUTTON_B, + controllerInputDevice = false, + hardwareKeyboardSource = false, + androidTvProfile = true, + dpadSource = true, + ), + ) + assertFalse( + NativeStreamInputRouter.shouldHandleStreamExitKey( + KeyEvent.KEYCODE_BUTTON_B, + controllerInputDevice = true, + hardwareKeyboardSource = false, + androidTvProfile = true, + dpadSource = true, + ), + ) + assertFalse( + NativeStreamInputRouter.shouldHandleStreamExitKey( + KeyEvent.KEYCODE_BUTTON_B, + controllerInputDevice = false, + hardwareKeyboardSource = false, + androidTvProfile = false, + dpadSource = true, + ), + ) + assertFalse( + NativeStreamInputRouter.shouldHandleStreamExitKey( + KeyEvent.KEYCODE_BUTTON_SELECT, + controllerInputDevice = false, + hardwareKeyboardSource = false, + ), + ) + assertFalse( + NativeStreamInputRouter.shouldHandleStreamExitKey( + KeyEvent.KEYCODE_BUTTON_B, + controllerInputDevice = false, + hardwareKeyboardSource = false, + ), + ) + } + + @Test + fun opensOverlayWithGuideButtonOnAndroidTv() { + assertTrue( + NativeStreamInputRouter.shouldOpenStreamSystemMenuKey( + KeyEvent.KEYCODE_BUTTON_MODE, + controllerInputDevice = true, + androidTvProfile = true, + ), + ) + assertFalse( + NativeStreamInputRouter.shouldOpenStreamSystemMenuKey( + KeyEvent.KEYCODE_BUTTON_MODE, + controllerInputDevice = true, + ), + ) + } + + @Test + fun controllerBackStaysInGameOnAndroidTvWhileRemoteBackOpensOverlay() { + assertFalse( + NativeStreamInputRouter.shouldHandleStreamExitKey( + KeyEvent.KEYCODE_BACK, + controllerInputDevice = true, + hardwareKeyboardSource = false, + androidTvProfile = true, + ), + ) + assertTrue( + NativeStreamInputRouter.shouldHandleStreamExitKey( + KeyEvent.KEYCODE_BACK, + controllerInputDevice = false, + hardwareKeyboardSource = false, + androidTvProfile = true, + ), + ) + assertFalse( + NativeStreamInputRouter.shouldHandleStreamExitKey( + KeyEvent.KEYCODE_BACK, + controllerInputDevice = true, + hardwareKeyboardSource = false, + ), + ) + } + + @Test + fun clampsStreamSharpnessShaderStrength() { + assertEquals(0f, streamSharpnessShaderStrength(enabled = false, amount = 1f), 0.0001f) + assertEquals(0f, streamSharpnessShaderStrength(enabled = true, amount = -1f), 0.0001f) + assertEquals(0.28f, streamSharpnessShaderStrength(enabled = true, amount = 2f), 0.0001f) + assertFalse(streamSharpnessShaderActive(streamSharpnessShaderStrength(enabled = false, amount = 1f))) + assertFalse(streamSharpnessShaderActive(Float.NaN)) + assertTrue(streamSharpnessShaderActive(streamSharpnessShaderStrength(enabled = true, amount = 1f))) + } + + @Test + fun controllerMouseLoopOnlyRunsWhileAMouseModeIsActive() { + assertFalse(shouldRunControllerMouseLoop(controllerMouseAssistActive = false, controllerMouseEmulationActive = false)) + assertTrue(shouldRunControllerMouseLoop(controllerMouseAssistActive = true, controllerMouseEmulationActive = false)) + assertTrue(shouldRunControllerMouseLoop(controllerMouseAssistActive = false, controllerMouseEmulationActive = true)) + } + + @Test + fun mapsRightStickDeflectionToScrollNotches() { + // Deadzone behavior + assertEquals(Pair(0, 0f), AndroidControllerMouseAssist.scrollNotches(0.05f, 30, 0f)) + assertEquals(Pair(0, 0f), AndroidControllerMouseAssist.scrollNotches(-0.09f, 30, 0f)) + + // Pushing UP (negative stickY) should scroll UP (positive notches) + val (upNotches, _) = AndroidControllerMouseAssist.scrollNotches(-1.0f, 30, 0.9f) + assertTrue(upNotches > 0) + + // Pushing DOWN (positive stickY) should scroll DOWN (negative notches) + val (downNotches, _) = AndroidControllerMouseAssist.scrollNotches(1.0f, 30, -0.9f) + assertTrue(downNotches < 0) + + // Sensitivity modulations + // High sensitivity (low setting e.g. 10) should scroll faster (generate more/equal notches for the same deflection/accumulator) + val (fastNotches, _) = AndroidControllerMouseAssist.scrollNotches(-1.0f, 10, 0.9f) + val (slowNotches, _) = AndroidControllerMouseAssist.scrollNotches(-1.0f, 100, 0.9f) + assertTrue(fastNotches >= slowNotches) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/InputEncoderKeyboardTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/InputEncoderKeyboardTest.kt new file mode 100644 index 000000000..a6d60c01d --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/InputEncoderKeyboardTest.kt @@ -0,0 +1,114 @@ +package com.opencloudgaming.opennow + +import android.view.KeyEvent +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Test + +class InputEncoderKeyboardTest { + @Test + fun mapsNumberRowKeysWhenAndroidReportsNoScanCode() { + val one = InputEncoder.mapKeyboardPayload(keyCode = KeyEvent.KEYCODE_1, unicode = 0, scanCode = 0, timestampUs = 0L) + val zero = InputEncoder.mapKeyboardPayload(keyCode = KeyEvent.KEYCODE_0, unicode = 0, scanCode = 0, timestampUs = 0L) + + assertNotNull(one) + assertEquals(0x31, one?.keycode) + assertEquals(0x0002, one?.scancode) + assertNotNull(zero) + assertEquals(0x30, zero?.keycode) + assertEquals(0x000b, zero?.scancode) + } + + @Test + fun mapsNumpadDigitsWhenAndroidReportsNoScanCode() { + val numpadOne = InputEncoder.mapKeyboardPayload(keyCode = KeyEvent.KEYCODE_NUMPAD_1, unicode = 0, scanCode = 0, timestampUs = 0L) + val numpadZero = InputEncoder.mapKeyboardPayload(keyCode = KeyEvent.KEYCODE_NUMPAD_0, unicode = 0, scanCode = 0, timestampUs = 0L) + + assertNotNull(numpadOne) + assertEquals(0x61, numpadOne?.keycode) + assertEquals(0x004f, numpadOne?.scancode) + assertNotNull(numpadZero) + assertEquals(0x60, numpadZero?.keycode) + assertEquals(0x0052, numpadZero?.scancode) + } + + @Test + fun mapsOverlayEscapeWhenAndroidReportsNoScanCode() { + val escape = InputEncoder.mapKeyboardPayload( + keyCode = KeyEvent.KEYCODE_ESCAPE, + unicode = 0, + scanCode = 0, + timestampUs = 0L, + ) + + assertNotNull(escape) + assertEquals(0x1b, escape?.keycode) + assertEquals(0x0001, escape?.scancode) + } + + @Test + fun mapsOverlayBackspaceUsedByClearWhenAndroidReportsNoScanCode() { + val backspace = InputEncoder.mapKeyboardPayload( + keyCode = KeyEvent.KEYCODE_DEL, + unicode = 0, + scanCode = 0, + timestampUs = 0L, + ) + + assertNotNull(backspace) + assertEquals(0x08, backspace?.keycode) + assertEquals(0x000e, backspace?.scancode) + } + + @Test + fun mapsOverlayTextCharactersLikeDesktopTextInput() { + val upperD = InputEncoder.mapTextCharToKeySpec('D') + val lowerA = InputEncoder.mapTextCharToKeySpec('a') + val space = InputEncoder.mapTextCharToKeySpec(' ') + val colon = InputEncoder.mapTextCharToKeySpec(':') + val atSign = InputEncoder.mapTextCharToKeySpec('@') + + assertNotNull(upperD) + assertEquals(0x44, upperD?.keycode) + assertEquals(0x0020, upperD?.scancode) + assertEquals(true, upperD?.shift) + assertNotNull(lowerA) + assertEquals(0x41, lowerA?.keycode) + assertEquals(0x001e, lowerA?.scancode) + assertEquals(false, lowerA?.shift) + assertNotNull(space) + assertEquals(0x20, space?.keycode) + assertEquals(0x0039, space?.scancode) + assertNotNull(colon) + assertEquals(0xba, colon?.keycode) + assertEquals(0x0027, colon?.scancode) + assertEquals(true, colon?.shift) + assertNotNull(atSign) + assertEquals(0x32, atSign?.keycode) + assertEquals(0x0003, atSign?.scancode) + assertEquals(true, atSign?.shift) + } + + @Test + fun encodesUnicodeTextWithOfficialSendUnicodeFraming() { + val packet = InputEncoder().encodeTextInput("язык 🙂 ß").single() + + assertEquals(0x22, packet[0].toInt()) + assertEquals( + InputEncoder.INPUT_TEXT, + java.nio.ByteBuffer.wrap(packet).order(java.nio.ByteOrder.LITTLE_ENDIAN).getInt(1), + ) + assertArrayEquals("язык 🙂 ß".toByteArray(Charsets.UTF_8), packet.copyOfRange(5, packet.size)) + } + + @Test + fun chunksUnicodeTextWithoutSplittingUtf8Characters() { + val packets = InputEncoder().encodeTextInput("a".repeat(1015) + "🙂b") + + assertEquals(2, packets.size) + assertEquals("a".repeat(1015), packets[0].copyOfRange(5, packets[0].size).toString(Charsets.UTF_8)) + assertEquals("🙂b", packets[1].copyOfRange(5, packets[1].size).toString(Charsets.UTF_8)) + } + +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/InputHapticsParserTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/InputHapticsParserTest.kt new file mode 100644 index 000000000..ddc1ec87f --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/InputHapticsParserTest.kt @@ -0,0 +1,133 @@ +package com.opencloudgaming.opennow + +import java.nio.ByteBuffer +import java.nio.ByteOrder +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class InputHapticsParserTest { + @Test + fun vibrationPrefersControllerAndFallsBackToDeviceHaptics() { + assertEquals( + HapticsOutputTarget.Controller, + selectHapticsOutputTarget( + vibrationEnabled = true, + controllerRumbleAvailable = true, + deviceHapticsAvailable = true, + ), + ) + assertEquals( + HapticsOutputTarget.Device, + selectHapticsOutputTarget( + vibrationEnabled = true, + controllerRumbleAvailable = false, + deviceHapticsAvailable = true, + ), + ) + } + + @Test + fun disabledVibrationSuppressesEveryOutput() { + assertEquals( + HapticsOutputTarget.None, + selectHapticsOutputTarget( + vibrationEnabled = false, + controllerRumbleAvailable = true, + deviceHapticsAvailable = true, + ), + ) + } + + @Test + fun onlyForcedControllerOutputEnablesPlayStationRumbleCompatibility() { + assertEquals( + false, + usesPlayStationRumbleCompatibility( + vibrationEnabled = true, + preference = HapticsOutputPreference.Auto, + ), + ) + assertEquals( + true, + usesPlayStationRumbleCompatibility( + vibrationEnabled = true, + preference = HapticsOutputPreference.Controller, + ), + ) + assertEquals( + false, + usesPlayStationRumbleCompatibility( + vibrationEnabled = false, + preference = HapticsOutputPreference.Controller, + ), + ) + } + + @Test + fun parsesLegacyHapticPacket() { + val packet = ByteBuffer.allocate(12).order(ByteOrder.LITTLE_ENDIAN).apply { + putShort(267.toShort()) + putShort(1.toShort()) + putShort(6.toShort()) + putShort(2.toShort()) + putShort(0x4000.toShort()) + putShort(0x7fff.toShort()) + }.array() + + val command = HapticsPacketParser.parse(packet) + + assertEquals(2, command?.controllerId) + assertEquals(0x4000, command?.weakMagnitude) + assertEquals(0x7fff, command?.strongMagnitude) + } + + @Test + fun parsesWrappedOcHapticPacket() { + val packet = ByteArray(14) + packet[0] = 34 + ByteBuffer.wrap(packet).order(ByteOrder.LITTLE_ENDIAN).putInt(1, 17) + packet[5] = 7 + packet[8] = 5 + packet[9] = 1 + packet[12] = 0x20 + packet[13] = 0x60 + + val command = HapticsPacketParser.parse(packet) + + assertEquals(1, command?.controllerId) + assertEquals(0x2000, command?.weakMagnitude) + assertEquals(0x6000, command?.strongMagnitude) + } + + @Test + fun parsesPacketCopiedFromDirectDataChannelBuffer() { + val dataChannelBuffer = ByteBuffer.allocateDirect(18).order(ByteOrder.LITTLE_ENDIAN).apply { + position(3) + putShort(267.toShort()) + putShort(1.toShort()) + putShort(6.toShort()) + putShort(3.toShort()) + putShort(0x2000.toShort()) + putShort(0x6000.toShort()) + limit(position()) + position(3) + } + val packet = dataChannelBuffer.duplicate().let { data -> + ByteArray(data.remaining()).also(data::get) + } + + val command = HapticsPacketParser.parse(packet) + + assertEquals(3, dataChannelBuffer.position()) + assertEquals(3, command?.controllerId) + assertEquals(0x2000, command?.weakMagnitude) + assertEquals(0x6000, command?.strongMagnitude) + } + + @Test + fun ignoresHandshakeAndInputWrappers() { + assertNull(HapticsPacketParser.parse(byteArrayOf(0x0e, 0x02, 0x03, 0x00))) + assertNull(HapticsPacketParser.parse(byteArrayOf(33, 0, 0, 0))) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/InputSessionClockTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/InputSessionClockTest.kt new file mode 100644 index 000000000..59262723c --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/InputSessionClockTest.kt @@ -0,0 +1,40 @@ +package com.opencloudgaming.opennow + +import java.nio.ByteBuffer +import java.nio.ByteOrder +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class InputSessionClockTest { + @Test + fun timestampsAreRelativeToTheInputHandshake() { + var nowNanos = 8_000_000_000L + val clock = InputSessionClock { nowNanos } + + clock.start() + nowNanos += 1_750_000L + + assertEquals(1_750L, clock.timestampUs()) + } + + @Test + fun protocolV3OuterTimestampIsRestampedAtSendTime() { + val packet = ByteArray(34).also { it[0] = 0x23 } + + assertTrue(restampProtocolV3OuterTimestamp(packet, nowUs = 4_242L)) + assertEquals( + 4_242L, + ByteBuffer.wrap(packet).order(ByteOrder.BIG_ENDIAN).getLong(1), + ) + } + + @Test + fun rawProtocolV2PacketsAreNotModified() { + val packet = byteArrayOf(2, 0, 0, 0) + + assertFalse(restampProtocolV3OuterTimestamp(packet, nowUs = 4_242L)) + assertEquals(listOf(2, 0, 0, 0), packet.toList()) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/KeyboardSymbolFallbackTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/KeyboardSymbolFallbackTest.kt new file mode 100644 index 000000000..92074d5a5 --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/KeyboardSymbolFallbackTest.kt @@ -0,0 +1,84 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * Symbols were reaching the host as nothing at all, or as the wrong key, while letters worked. + * See [InputEncoder.keyboardTextFallbackChar]. + */ +class KeyboardSymbolFallbackTest { + private fun fallback( + unicodeChar: Int, + baseUnicodeChar: Int = unicodeChar, + mapped: Boolean = true, + altGraph: Boolean = false, + ) = InputEncoder.keyboardTextFallbackChar(unicodeChar, baseUnicodeChar, mapped, altGraph) + + @Test + fun anUnmappableSymbolKeyIsSentAsText() { + // KEYCODE_AT has no fallback scancode, so mapKeyboardPayload returns null and the key + // used to be dropped on the floor. + assertEquals('@', fallback(unicodeChar = '@'.code, mapped = false)) + } + + @Test + fun anAltGraphComposedSymbolIsSentAsTextRatherThanAsCtrlAltLetter() { + // German layout: AltGr+Q is '@'. The key maps — to VK_Q — which is the wrong character. + assertEquals( + '@', + fallback(unicodeChar = '@'.code, baseUnicodeChar = 'q'.code, mapped = true, altGraph = true), + ) + } + + @Test + fun anOrdinaryMappedKeyIsLeftOnTheKeyPath() { + // US layout Shift+2: already correct as VK_2 + Shift, and games need the real scancode. + assertNull(fallback(unicodeChar = '@'.code, mapped = true)) + assertNull(fallback(unicodeChar = 'a'.code, mapped = true)) + } + + @Test + fun altGraphThatChangesNothingStaysOnTheKeyPath() { + // Ctrl+Alt held over a key whose character did not change is a shortcut, not a composition. + assertNull(fallback(unicodeChar = 'f'.code, baseUnicodeChar = 'f'.code, altGraph = true)) + } + + @Test + fun shortcutsThatResolveToNoCharacterAreNeverDiverted() { + // Android reports unicodeChar 0 for Ctrl+Alt+F on a US layout. + assertNull(fallback(unicodeChar = 0, baseUnicodeChar = 'f'.code, altGraph = true)) + assertNull(fallback(unicodeChar = 0, mapped = false)) + } + + @Test + fun controlCharactersKeepTheirOwnKeys() { + // Enter, Tab and Backspace must stay key presses; as text the host would type a raw + // control byte instead of pressing the key. + assertNull(fallback(unicodeChar = '\n'.code, mapped = false)) + assertNull(fallback(unicodeChar = '\t'.code, mapped = false)) + assertNull(fallback(unicodeChar = 0x08, mapped = false)) + assertNull(fallback(unicodeChar = 0x1b, mapped = false)) + assertNull(fallback(unicodeChar = 0x7f, mapped = false)) + } + + @Test + fun spaceIsNotTreatedAsAControlCharacter() { + assertEquals(' ', fallback(unicodeChar = ' '.code, mapped = false)) + } + + @Test + fun everyAsciiSymbolSurvivesTheUnmappedPath() { + val symbols = "!@#$%^&*()_+-=[]{}|;':\",./<>?`~" + symbols.forEach { symbol -> + assertEquals("symbol $symbol", symbol, fallback(unicodeChar = symbol.code, mapped = false)) + } + } + + @Test + fun nonBmpCharactersAreNotTruncatedIntoTheWrongGlyph() { + // An emoji arrives as a surrogate pair; halving it would send a lone surrogate. + assertNull(fallback(unicodeChar = 0x1F600, mapped = false)) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/LaunchErrorsSessionRecoveryTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/LaunchErrorsSessionRecoveryTest.kt new file mode 100644 index 000000000..d38428376 --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/LaunchErrorsSessionRecoveryTest.kt @@ -0,0 +1,19 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertEquals +import org.junit.Test + +class LaunchErrorsSessionRecoveryTest { + @Test + fun terminalSessionDoesNotPromiseAnAutomaticReplacementQueue() { + val message = normalizeLaunchErrorMessage( + TerminalSessionStatusException(status = 7, latestSession = null), + ) + + assertEquals( + "The cloud provider ended this session (status 7). " + + "OpenNOW did not stop it or start a replacement queue.", + message, + ) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/LaunchErrorsTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/LaunchErrorsTest.kt new file mode 100644 index 000000000..7b553cb3f --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/LaunchErrorsTest.kt @@ -0,0 +1,84 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertEquals +import org.junit.Test + +class LaunchErrorsTest { + @Test + fun freeTierEntitlementFailureExplainsMembershipRequirement() { + val error = CloudMatchRequestStatusException( + statusCode = 18, + statusDescription = "ENTITLEMENT_FAILURE_STATUS 8A910006", + unifiedErrorCode = "-1970208762", + ) + + assertEquals( + "Your GeForce NOW account is on the Free tier. This game requires a Priority or Ultimate membership.", + normalizeLaunchErrorMessage(error, "Subnautica 2"), + ) + } + + @Test + fun limitedModeCloudMatchStatusUsesGameTitle() { + val error = CloudMatchRequestStatusException( + statusCode = 81, + statusDescription = "STREAMING_NOT_ALLOWED_IN_LIMITED_MODE 8A91000D", + unifiedErrorCode = "-1970208755", + ) + + assertEquals( + "Subnautica 2 is only available for Priority or Ultimate members", + normalizeLaunchErrorMessage(error, "Subnautica 2"), + ) + } + + @Test + fun limitedModeCloudMatchStatusFallsBackWithoutGameTitle() { + val error = CloudMatchRequestStatusException( + statusCode = 81, + statusDescription = "STREAMING_NOT_ALLOWED_IN_LIMITED_MODE", + unifiedErrorCode = null, + ) + + assertEquals( + "This game is only available for Priority or Ultimate members", + normalizeLaunchErrorMessage(error), + ) + } + + @Test + fun unrelatedCloudMatchFailureKeepsItsOwnMessage() { + val error = CloudMatchRequestStatusException( + statusCode = 42, + statusDescription = "CAPACITY_FAILURE_STATUS", + unifiedErrorCode = "DEADBEEF", + ) + + assertEquals( + "CloudMatch returned status 42: CAPACITY_FAILURE_STATUS (unified error DEADBEEF)", + normalizeLaunchErrorMessage(error, "Subnautica 2"), + ) + } + + @Test + fun entitlementWordsInsideAnUnstructuredErrorAreNotMisclassified() { + val error = IllegalStateException( + "Diagnostics mentioned ENTITLEMENT_FAILURE_STATUS, but DNS lookup failed", + ) + + assertEquals( + "Diagnostics mentioned ENTITLEMENT_FAILURE_STATUS, but DNS lookup failed", + normalizeLaunchErrorMessage(error, "Subnautica 2"), + ) + } + + @Test + fun maintenanceErrorsStillUseFriendlyCopy() { + val error = IllegalStateException("Game server is under maintenance") + + assertEquals( + "Game is patching or under maintenance. Try again when NVIDIA finishes updating it.", + normalizeLaunchErrorMessage(error, "Subnautica 2"), + ) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/LaunchOwnershipTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/LaunchOwnershipTest.kt new file mode 100644 index 000000000..a0918b12f --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/LaunchOwnershipTest.kt @@ -0,0 +1,270 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class LaunchOwnershipTest { + @Test + fun ownedStatusesMatchDesktopContract() { + assertTrue(isOwnedLibraryStatus("MANUAL")) + assertTrue(isOwnedLibraryStatus("PLATFORM_SYNC")) + assertTrue(isOwnedLibraryStatus("IN_LIBRARY")) + assertFalse(isOwnedLibraryStatus("NOT_OWNED")) + assertFalse(isOwnedLibraryStatus(null)) + } + + @Test + fun accountLinkedRequiresOwnedVariantOrLibraryGame() { + val unownedSteam = variant(store = "Steam", libraryStatus = "NOT_OWNED") + val ownedSteam = variant(store = "Steam", libraryStatus = "PLATFORM_SYNC") + val unownedEpic = variant(store = "Epic", libraryStatus = "NOT_OWNED") + + assertFalse(shouldLaunchWithAccountLinked(game(listOf(unownedSteam)), unownedSteam)) + assertFalse(shouldLaunchWithAccountLinked(game(listOf(unownedEpic)), unownedEpic)) + assertTrue(shouldLaunchWithAccountLinked(game(listOf(ownedSteam), isInLibrary = true), ownedSteam)) + assertTrue(shouldLaunchWithAccountLinked(game(listOf(unownedSteam, ownedSteam)), unownedSteam)) + } + + @Test + fun installToPlayDoesNotUseAccountLinkedEvenWhenOwned() { + val ownedSteam = variant(store = "Steam", libraryStatus = "IN_LIBRARY") + + assertFalse( + shouldLaunchWithAccountLinked( + game(listOf(ownedSteam), playType = "INSTALL_TO_PLAY", isInLibrary = true), + ownedSteam, + ), + ) + } + + @Test + fun explicitNotOwnedVariantIsMarkedBeforeLaunch() { + val free = variant(libraryStatus = "NOT_OWNED", isFreeToPlay = true) + val ownedFree = variant(libraryStatus = "MANUAL", isFreeToPlay = true) + val paid = variant(libraryStatus = "NOT_OWNED") + val unknown = variant(librarySelected = false) + + assertTrue(shouldMarkVariantOwnedBeforeLaunch(free)) + assertTrue(shouldMarkVariantOwnedBeforeLaunch(paid)) + assertFalse(shouldMarkVariantOwnedBeforeLaunch(ownedFree)) + assertFalse(shouldMarkVariantOwnedBeforeLaunch(unknown)) + assertFalse(shouldMarkVariantOwnedBeforeLaunch(null)) + } + + @Test + fun markingVariantUpdatesSelectedOwnershipLocally() { + val game = game( + variants = listOf( + variant(id = "ubisoft", store = "Ubisoft", librarySelected = true), + variant(id = "steam", store = "Steam", isFreeToPlay = true), + ), + ) + + val marked = game.withManuallyOwnedVariant("steam") + + assertTrue(marked.isInLibrary) + assertEquals(1, marked.selectedVariantIndex) + assertEquals(true, marked.variants[0].librarySelected) + assertEquals(null, marked.variants[1].librarySelected) + assertEquals("MANUAL", marked.variants[1].libraryStatus) + } + + @Test + fun launchableVariantsPreferOwnedStoreEntryOverUnownedDuplicate() { + val unowned = variant(id = "public-steam", store = "Steam", libraryStatus = "NOT_OWNED", librarySelected = true) + val owned = variant(id = "owned-steam", store = "Steam", libraryStatus = "PLATFORM_SYNC") + + val variants = launchableGameVariants(listOf(unowned, owned)) + + assertEquals(listOf("owned-steam"), variants.map { it.id }) + } + + @Test + fun publicCatalogMergeKeepsEveryStoreForDuplicateTitles() { + val catalogUno = game( + title = "UNO", + variants = listOf(variant(id = "ubisoft", store = "Ubisoft Connect")), + ) + val publicUnoSteam = game( + id = "steam-uno", + title = "UNO", + variants = listOf(variant(id = "100236911", store = "Steam")), + ) + val publicUnoUbisoft = game( + id = "ubisoft-uno", + title = "UNO", + variants = listOf(variant(id = "100932011", store = "Ubisoft Connect")), + ) + + val merged = mergeSupplementalPublicGameVariants( + games = listOf(catalogUno), + publicGames = listOf(publicUnoSteam, publicUnoUbisoft), + ).single() + + assertEquals(listOf("Ubisoft Connect", "Steam"), merged.variants.map { it.store }) + } + + @Test + fun mergesOwnedCatalogResultsIntoLibrary() { + val library = game( + variants = listOf(variant(id = "steam", store = "Steam", libraryStatus = "PLATFORM_SYNC")), + isInLibrary = true, + ) + val catalogOnlyOwned = game( + id = "subnautica-2", + uuid = "subnautica-2-uuid", + title = "Subnautica 2", + variants = listOf(variant(id = "subnautica-steam", store = "Steam", libraryStatus = "IN_LIBRARY")), + isInLibrary = true, + ) + val catalogUnowned = game( + id = "catalog-only", + uuid = "catalog-only-uuid", + title = "Catalog Only", + variants = listOf(variant(id = "catalog-steam", store = "Steam", libraryStatus = "NOT_OWNED")), + ) + + val merged = mergeKnownLibraryGames(listOf(library), listOf(catalogOnlyOwned, catalogUnowned)) + + assertEquals(listOf("Game", "Subnautica 2"), merged.map { it.title }) + } + + @Test + fun metadataEnrichmentPreservesPanelLibraryOwnership() { + val panelGame = game( + variants = listOf( + variant( + id = "steam", + store = "Steam", + libraryStatus = "MANUAL", + librarySelected = true, + ), + ), + isInLibrary = true, + ) + val metadataGame = game( + variants = listOf(variant(id = "steam", store = "Steam")), + ).copy( + description = "Enriched description", + genres = listOf("ACTION", "ROLE_PLAYING"), + imageUrl = "game-box-art", + screenshotUrls = listOf("screenshot-one", "screenshot-two"), + ) + val panelWithFallbackArtwork = panelGame.copy( + imageUrl = "panel-banner-fallback", + screenshotUrls = listOf("screenshot-one"), + ) + + val merged = mergePanelGameWithMetadata(panelWithFallbackArtwork, metadataGame) + + assertTrue(merged.isInLibrary) + assertEquals("MANUAL", merged.variants.single().libraryStatus) + assertEquals(true, merged.variants.single().librarySelected) + assertEquals("Enriched description", merged.description) + assertEquals(listOf("ACTION", "ROLE_PLAYING"), merged.genres) + assertEquals("game-box-art", merged.imageUrl) + assertEquals(listOf("screenshot-one", "screenshot-two"), merged.screenshotUrls) + } + + @Test + fun metadataEnrichmentMergesFieldsForTheSameVariant() { + val panelGame = game( + variants = listOf(variant(id = "steam", libraryStatus = "PLATFORM_SYNC")), + isInLibrary = true, + ) + val metadataGame = game( + variants = listOf( + variant(id = "steam", isFreeToPlay = true).copy(supportedControls = listOf("GAMEPAD")), + ), + ) + + val merged = mergePanelGameWithMetadata(panelGame, metadataGame) + + assertEquals("PLATFORM_SYNC", merged.variants.single().libraryStatus) + assertTrue(merged.variants.single().isFreeToPlay) + assertEquals(listOf("GAMEPAD"), merged.variants.single().supportedControls) + assertEquals( + "PLATFORM_SYNC", + mergeGameInfo( + metadataGame.copy(variants = listOf(variant(id = "steam", libraryStatus = "NOT_OWNED"))), + panelGame, + ).variants.single().libraryStatus, + ) + } + + @Test + fun libraryStoreFiltersOnlyUseOwnedStores() { + val game = game( + variants = listOf( + variant(id = "epic", store = "Epic", libraryStatus = "PLATFORM_SYNC"), + variant(id = "steam", store = "Steam", libraryStatus = "NOT_OWNED"), + variant(id = "xbox", store = "Xbox"), + ), + isInLibrary = true, + ) + + assertEquals(listOf("Epic"), libraryStoreDisplayNames(game)) + } + + @Test + fun libraryStoreFiltersFallBackToSelectedVariantForLegacyLibraryRows() { + val game = game( + variants = listOf( + variant(id = "steam", store = "Steam"), + variant(id = "xbox", store = "Xbox", librarySelected = true), + ), + isInLibrary = true, + selectedVariantIndex = 0, + ) + + assertEquals(listOf("Xbox"), libraryStoreDisplayNames(game)) + } + + @Test + fun detailMetadataHydrationOnlyRunsWhenCatalogGenresAreMissing() { + val catalogGame = game( + variants = listOf(variant(libraryStatus = "NOT_OWNED")), + uuid = "catalog-app", + ) + + assertTrue(shouldHydrateGameDetails(catalogGame)) + assertFalse(shouldHydrateGameDetails(catalogGame.copy(genres = listOf("ACTION")))) + assertFalse(shouldHydrateGameDetails(catalogGame.copy(uuid = null))) + } + + private fun variant( + id: String = "variant", + store: String = "Steam", + libraryStatus: String? = null, + librarySelected: Boolean? = null, + isFreeToPlay: Boolean = false, + ): GameVariant = + GameVariant( + id = id, + store = store, + librarySelected = librarySelected, + libraryStatus = libraryStatus, + isFreeToPlay = isFreeToPlay, + ) + + private fun game( + variants: List, + id: String = "game", + uuid: String? = null, + title: String = "Game", + playType: String? = null, + isInLibrary: Boolean = false, + selectedVariantIndex: Int = 0, + ): GameInfo = + GameInfo( + id = id, + uuid = uuid, + title = title, + playType = playType, + isInLibrary = isInLibrary, + variants = variants, + selectedVariantIndex = selectedVariantIndex, + ) +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/LibraryShelfLayoutTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/LibraryShelfLayoutTest.kt new file mode 100644 index 000000000..1a4ae212a --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/LibraryShelfLayoutTest.kt @@ -0,0 +1,55 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class LibraryShelfLayoutTest { + @Test + fun collapsingTheShelfMovesTheGridsUpTargetToTheHeader() { + // The tiles are gone when folded, and requesting focus on an uncomposed target throws. + assertEquals( + "header", + libraryGridUpFocusTarget( + shelfVisible = true, + shelfCollapsed = true, + shelfTile = "tile", + shelfHeader = "header", + topBar = "top", + ), + ) + assertEquals( + "tile", + libraryGridUpFocusTarget( + shelfVisible = true, + shelfCollapsed = false, + shelfTile = "tile", + shelfHeader = "header", + topBar = "top", + ), + ) + } + + @Test + fun hiddenShelfSendsFocusStraightToTheTopBar() { + assertEquals( + "top", + libraryGridUpFocusTarget( + shelfVisible = false, + shelfCollapsed = false, + shelfTile = "tile", + shelfHeader = "header", + topBar = "top", + ), + ) + assertNull( + libraryGridUpFocusTarget( + shelfVisible = false, + shelfCollapsed = true, + shelfTile = "tile", + shelfHeader = "header", + topBar = null, + ), + ) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/LoadingShimmerTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/LoadingShimmerTest.kt new file mode 100644 index 000000000..9a7394eed --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/LoadingShimmerTest.kt @@ -0,0 +1,37 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class LoadingShimmerTest { + @Test + fun recreatedPlaceholderKeepsTheDeviceWideSweepPhase() { + assertEquals(0f, shimmerProgressAtUptime(0L), 0f) + assertEquals(0.5f, shimmerProgressAtUptime(380L), 0f) + assertEquals(0f, shimmerProgressAtUptime(760L), 0f) + assertEquals(0.5f, shimmerProgressAtUptime(1_140L), 0f) + } + + @Test + fun sweepRepeatsOnlyWhileHighlightBandIsFullyOutsidePlaceholder() { + val containerWidth = 300f + val bandWidth = 156f + + val start = shimmerBandStartX(0f, containerWidth, bandWidth) + val middle = shimmerBandStartX(0.5f, containerWidth, bandWidth) + val end = shimmerBandStartX(1f, containerWidth, bandWidth) + + assertEquals(-bandWidth, start, 0f) + assertEquals(containerWidth, end, 0f) + assertTrue(start < middle) + assertTrue(middle < end) + assertEquals(containerWidth / 2f, middle + bandWidth / 2f, 0f) + } + + @Test + fun sweepProgressIsClampedWithoutReversingDirection() { + assertEquals(-100f, shimmerBandStartX(-1f, 240f, 100f), 0f) + assertEquals(240f, shimmerBandStartX(2f, 240f, 100f), 0f) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/LocalTvPairingTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/LocalTvPairingTest.kt new file mode 100644 index 000000000..d5713e59c --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/LocalTvPairingTest.kt @@ -0,0 +1,15 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class LocalTvPairingTest { + @Test + fun pairingCodeRequiresExactlyFourDigits() { + assertEquals("0427", normalizeLocalTvPairingCode(" 0427 ")) + assertNull(normalizeLocalTvPairingCode("427")) + assertNull(normalizeLocalTvPairingCode("04270")) + assertNull(normalizeLocalTvPairingCode("04A7")) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/LoginProviderTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/LoginProviderTest.kt new file mode 100644 index 000000000..8cbe329e1 --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/LoginProviderTest.kt @@ -0,0 +1,26 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class LoginProviderTest { + @Test + fun deviceCodeLoginIsAvailableForNvidiaOnly() { + val nvidia = LoginProvider( + idpId = "idp-nvidia", + code = "NVIDIA", + displayName = "NVIDIA", + streamingServiceUrl = "https://prod.cloudmatchbeta.nvidiagrid.net/", + ) + val alliance = LoginProvider( + idpId = "idp-alliance", + code = "YES", + displayName = "YES Malaysia", + streamingServiceUrl = "https://yes.geforcenow.nvidiagrid.net/", + ) + + assertTrue(nvidia.supportsDeviceCodeLogin) + assertFalse(alliance.supportsDeviceCodeLogin) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/ManualTokenSignInTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/ManualTokenSignInTest.kt new file mode 100644 index 000000000..a2da09116 --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/ManualTokenSignInTest.kt @@ -0,0 +1,60 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class ManualTokenSignInTest { + @Test + fun acceptsRawBearerAccessToken() { + val tokens = parseManualAuthTokens(" Bearer access-value ", currentTimeMs = 1_000L) + + assertEquals("access-value", tokens.accessToken) + assertEquals(86_401_000L, tokens.expiresAt) + assertNull(tokens.refreshToken) + } + + @Test + fun acceptsOAuthTokenResponseJson() { + val tokens = parseManualAuthTokens( + """ + { + "access_token": "access-value", + "refresh_token": "refresh-value", + "id_token": "id-value", + "client_token": "client-value", + "expires_in": 3600 + } + """.trimIndent(), + currentTimeMs = 10_000L, + ) + + assertEquals("access-value", tokens.accessToken) + assertEquals("refresh-value", tokens.refreshToken) + assertEquals("id-value", tokens.idToken) + assertEquals("client-value", tokens.clientToken) + assertEquals(3_610_000L, tokens.expiresAt) + } + + @Test + fun acceptsPersistedSessionTokenShape() { + val tokens = parseManualAuthTokens( + """ + { + "tokens": { + "accessToken": "access-value", + "refreshToken": "refresh-value", + "expiresAt": 2000000000, + "authClientId": "saved-client" + } + } + """.trimIndent(), + currentTimeMs = 10_000L, + ) + + assertEquals("access-value", tokens.accessToken) + assertEquals("refresh-value", tokens.refreshToken) + assertEquals(2_000_000_000_000L, tokens.expiresAt) + assertEquals("saved-client", tokens.authClientId) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/MicrophoneSupportTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/MicrophoneSupportTest.kt new file mode 100644 index 000000000..88164f0f8 --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/MicrophoneSupportTest.kt @@ -0,0 +1,63 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class MicrophoneSupportTest { + @Test + fun microphoneCaptureRequiresEnabledModeAndRuntimePermission() { + assertTrue( + shouldCaptureMicrophone( + mode = MicrophoneMode.VoiceActivity, + permissionGranted = true, + ), + ) + assertTrue( + shouldCaptureMicrophone( + mode = MicrophoneMode.PushToTalk, + permissionGranted = true, + ), + ) + assertFalse( + shouldCaptureMicrophone( + mode = MicrophoneMode.Disabled, + permissionGranted = true, + ), + ) + assertFalse( + shouldCaptureMicrophone( + mode = MicrophoneMode.VoiceActivity, + permissionGranted = false, + ), + ) + } + + @Test + fun videoPresetChangesPreserveMicrophonePreferences() { + val source = StreamSettings( + microphoneMode = MicrophoneMode.VoiceActivity, + microphoneDeviceId = "preferred-device", + ) + + val updated = StreamSettings(resolution = "1280x720") + .withMicrophoneSettingsFrom(source) + + assertTrue(updated.microphoneMode == MicrophoneMode.VoiceActivity) + assertTrue(updated.microphoneDeviceId == "preferred-device") + } + + @Test + fun microphoneCleanupOnlyIgnoresTheWebRtcDisposedSenderFailure() { + assertTrue( + isDisposedRtpSenderFailure( + IllegalStateException("RtpSender has been disposed."), + ), + ) + assertFalse( + isDisposedRtpSenderFailure( + IllegalStateException("RtpSender track update failed."), + ), + ) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/MobileGyroscopeTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/MobileGyroscopeTest.kt new file mode 100644 index 000000000..e0e9bb6eb --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/MobileGyroscopeTest.kt @@ -0,0 +1,94 @@ +package com.opencloudgaming.opennow + +import android.view.Surface +import org.junit.Assert.assertEquals +import org.junit.Test + +class MobileGyroscopeTest { + @Test + fun portraitMapsDeviceYawAndPitchToScreenRelativeAngularVelocity() { + val horizontal = gyroscopeAimForScreen( + rotation = Surface.ROTATION_0, + angularVelocityX = 0f, + angularVelocityY = -2.5f, + sensitivity = 1f, + deadZone = 0f, + invertHorizontal = false, + invertVertical = false, + ) + val vertical = gyroscopeAimForScreen( + rotation = Surface.ROTATION_0, + angularVelocityX = -2.5f, + angularVelocityY = 0f, + sensitivity = 1f, + deadZone = 0f, + invertHorizontal = false, + invertVertical = false, + ) + + assertEquals(2.5f, horizontal.x, 0.001f) + assertEquals(0f, horizontal.y, 0.001f) + assertEquals(0f, vertical.x, 0.001f) + assertEquals(2.5f, vertical.y, 0.001f) + } + + @Test + fun landscapeRotationUsesScreenRelativeAxes() { + val sample = gyroscopeAimForScreen( + rotation = Surface.ROTATION_90, + angularVelocityX = 2.5f, + angularVelocityY = 0f, + sensitivity = 1f, + deadZone = 0f, + invertHorizontal = false, + invertVertical = false, + ) + + assertEquals(2.5f, sample.x, 0.001f) + assertEquals(0f, sample.y, 0.001f) + } + + @Test + fun deadZoneAndInversionAreAppliedBeforeSending() { + val quiet = gyroscopeAimForScreen( + rotation = Surface.ROTATION_0, + angularVelocityX = 0.01f, + angularVelocityY = 0.01f, + sensitivity = 1f, + deadZone = 0.05f, + invertHorizontal = false, + invertVertical = false, + ) + val inverted = gyroscopeAimForScreen( + rotation = Surface.ROTATION_0, + angularVelocityX = -1f, + angularVelocityY = -1f, + sensitivity = 1f, + deadZone = 0f, + invertHorizontal = true, + invertVertical = true, + ) + + assertEquals(0f, quiet.x, 0.001f) + assertEquals(0f, quiet.y, 0.001f) + assertEquals(inverted.x, inverted.y, 0.001f) + assertEquals(true, inverted.x < 0f) + } + + @Test + fun angularVelocityIntegratesIntoMouseDeltaAndCapsResumeGaps() { + val normal = gyroscopeMouseDelta( + angularVelocity = androidx.compose.ui.geometry.Offset(2f, -1f), + elapsedSeconds = 0.01f, + ) + val resumed = gyroscopeMouseDelta( + angularVelocity = androidx.compose.ui.geometry.Offset(2f, -1f), + elapsedSeconds = 1f, + ) + + assertEquals(10f, normal.x, 0.001f) + assertEquals(-5f, normal.y, 0.001f) + assertEquals(50f, resumed.x, 0.001f) + assertEquals(-25f, resumed.y, 0.001f) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/MouseMotionAccumulatorTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/MouseMotionAccumulatorTest.kt new file mode 100644 index 000000000..1eda850e1 --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/MouseMotionAccumulatorTest.kt @@ -0,0 +1,154 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class MouseMotionAccumulatorTest { + @Test + fun externalMouseKeepsVerySlowFractionalRelativeMotion() { + val accumulator = MouseMotionAccumulator(minimumSendIntervalMs = 0L) + val sent = mutableListOf() + + repeat(20) { index -> + accumulator.add( + dx = 0.2f, + dy = -0.1f, + eventTimeMs = index.toLong(), + sensitivity = 1f, + acceleration = 1, + )?.let(sent::add) + } + + assertEquals(4, sent.sumOf { it.dx }) + assertEquals(-2, sent.sumOf { it.dy }) + } + + @Test + fun externalMouseFastMotionRemainsLinearWithoutAcceleration() { + val accumulator = MouseMotionAccumulator(minimumSendIntervalMs = 0L) + val sent = mutableListOf() + + repeat(10) { index -> + accumulator.add( + dx = 12.5f, + dy = -7.5f, + eventTimeMs = index.toLong(), + sensitivity = 1f, + acceleration = 1, + )?.let(sent::add) + } + + assertEquals(125, sent.sumOf { it.dx }) + assertEquals(-75, sent.sumOf { it.dy }) + } + + @Test + fun preservesFractionalMotionAcrossHighFrequencyEvents() { + val accumulator = MouseMotionAccumulator(minimumSendIntervalMs = 8L) + val sent = mutableListOf() + + repeat(20) { index -> + accumulator.add( + dx = 0.2f, + dy = -0.1f, + eventTimeMs = index.toLong(), + sensitivity = 1f, + acceleration = 1, + )?.let(sent::add) + } + accumulator.add( + dx = 0f, + dy = 0f, + eventTimeMs = 20L, + sensitivity = 1f, + acceleration = 1, + force = true, + )?.let(sent::add) + + assertEquals(4, sent.sumOf { it.dx }) + assertEquals(-2, sent.sumOf { it.dy }) + assertEquals(true, sent.size < 20) + } + + @Test + fun externalMouseKeepsSlowMotionAtReducedSensitivity() { + val accumulator = MouseMotionAccumulator(minimumSendIntervalMs = 0L) + val sent = mutableListOf() + + repeat(8) { index -> + accumulator.add( + dx = 1f, + dy = 0f, + eventTimeMs = index.toLong(), + sensitivity = 0.25f, + acceleration = 1, + )?.let(sent::add) + } + + assertEquals(2, sent.sumOf { it.dx }) + assertEquals(0, sent.sumOf { it.dy }) + } + + @Test + fun coalescesEventsInsideSendInterval() { + val accumulator = MouseMotionAccumulator(minimumSendIntervalMs = 8L) + + assertEquals( + MouseMotionDelta(1, 0), + accumulator.add(1f, 0f, eventTimeMs = 0L, sensitivity = 1f, acceleration = 1), + ) + assertNull( + accumulator.add(1f, 0f, eventTimeMs = 3L, sensitivity = 1f, acceleration = 1), + ) + assertEquals( + MouseMotionDelta(2, 0), + accumulator.add(1f, 0f, eventTimeMs = 8L, sensitivity = 1f, acceleration = 1), + ) + } + + @Test + fun resetDoesNotLeakResidualMotionIntoNextGesture() { + val accumulator = MouseMotionAccumulator(minimumSendIntervalMs = 8L) + + assertNull( + accumulator.add(0.4f, 0f, eventTimeMs = 0L, sensitivity = 1f, acceleration = 1), + ) + accumulator.reset() + assertNull( + accumulator.add(0.2f, 0f, eventTimeMs = 1L, sensitivity = 1f, acceleration = 1), + ) + } + + @Test + fun nonFiniteMotionIsDroppedAndDoesNotPoisonNextGesture() { + val accumulator = MouseMotionAccumulator(minimumSendIntervalMs = 0L) + + assertNull( + accumulator.add(Float.NaN, 1f, eventTimeMs = 0L, sensitivity = 1f, acceleration = 1), + ) + assertNull( + accumulator.add(1f, Float.POSITIVE_INFINITY, eventTimeMs = 1L, sensitivity = 1f, acceleration = 1), + ) + assertNull( + accumulator.add(1f, 1f, eventTimeMs = 2L, sensitivity = Float.NaN, acceleration = 1), + ) + assertEquals( + MouseMotionDelta(2, -1), + accumulator.add(2f, -1f, eventTimeMs = 3L, sensitivity = 1f, acceleration = 1), + ) + } + + @Test + fun overflowingMotionIsDroppedBeforeRounding() { + val accumulator = MouseMotionAccumulator(minimumSendIntervalMs = 0L) + + assertNull( + accumulator.add(Float.MAX_VALUE, 0f, eventTimeMs = 0L, sensitivity = Float.MAX_VALUE, acceleration = 1), + ) + assertEquals( + MouseMotionDelta(1, 0), + accumulator.add(1f, 0f, eventTimeMs = 1L, sensitivity = 1f, acceleration = 1), + ) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/MouseMoveBurstLimiterTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/MouseMoveBurstLimiterTest.kt new file mode 100644 index 000000000..4e9dae0c9 --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/MouseMoveBurstLimiterTest.kt @@ -0,0 +1,62 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class MouseMoveBurstLimiterTest { + @Test + fun firstMovementIsNeverDelayed() { + val limiter = MouseMoveBurstLimiter(minimumIntervalMs = 8L) + + assertEquals(MouseMoveBatch(4, -2, true), limiter.offer(4, -2, true, nowMs = 100L)) + assertFalse(limiter.hasPendingMovement) + } + + @Test + fun highRateMovementBecomesOneTrailingPacket() { + val limiter = MouseMoveBurstLimiter(minimumIntervalMs = 8L) + limiter.offer(1, 1, true, nowMs = 100L) + + assertNull(limiter.offer(2, 3, true, nowMs = 102L)) + assertNull(limiter.offer(4, -1, true, nowMs = 104L)) + assertTrue(limiter.hasPendingMovement) + assertEquals(4L, limiter.delayUntilFlushMs(nowMs = 104L)) + assertEquals(MouseMoveBatch(6, 2, true), limiter.flush(nowMs = 108L)) + } + + @Test + fun movementAtTheNextIntervalFlushesPendingAndCurrentTogether() { + val limiter = MouseMoveBurstLimiter(minimumIntervalMs = 8L) + limiter.offer(1, 0, true, nowMs = 100L) + limiter.offer(2, 0, true, nowMs = 103L) + + assertEquals(MouseMoveBatch(5, 0, true), limiter.offer(3, 0, true, nowMs = 108L)) + assertFalse(limiter.hasPendingMovement) + } + + @Test + fun reliableMovementKeepsTheCombinedPacketReliable() { + val limiter = MouseMoveBurstLimiter(minimumIntervalMs = 8L) + limiter.offer(1, 0, true, nowMs = 100L) + limiter.offer(2, 0, true, nowMs = 102L) + limiter.offer(3, 0, false, nowMs = 104L) + + assertEquals(false, limiter.flush(nowMs = 108L)?.partiallyReliable) + } + + @Test + fun resetDropsMovementFromThePreviousTransport() { + val limiter = MouseMoveBurstLimiter(minimumIntervalMs = 8L) + limiter.offer(1, 0, true, nowMs = 100L) + limiter.offer(2, 0, true, nowMs = 102L) + + limiter.reset() + + assertFalse(limiter.hasPendingMovement) + assertNull(limiter.flush(nowMs = 108L)) + assertEquals(MouseMoveBatch(7, 0, true), limiter.offer(7, 0, true, nowMs = 109L)) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/NativeStreamReadinessTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/NativeStreamReadinessTest.kt new file mode 100644 index 000000000..9f1869414 --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/NativeStreamReadinessTest.kt @@ -0,0 +1,43 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class NativeStreamReadinessTest { + @Test + fun readyCloudSessionDoesNotStartNativeTransportBeforeClaimCompletes() { + val readySession = readySession() + + assertFalse( + OpenNowUiState( + streamStatus = "queue", + streamSession = readySession, + ).isNativeStreamReady(), + ) + assertTrue( + OpenNowUiState( + streamStatus = "connecting", + streamSession = readySession, + ).isNativeStreamReady(), + ) + } + + @Test + fun connectingStateStillRequiresAStreamReadySession() { + assertFalse( + OpenNowUiState( + streamStatus = "connecting", + streamSession = readySession().copy(status = 1), + ).isNativeStreamReady(), + ) + } + + private fun readySession(): SessionInfo = SessionInfo( + sessionId = "session-id", + status = 2, + serverIp = "stream.example.test", + signalingServer = "stream.example.test:443", + signalingUrl = "wss://stream.example.test/nvst/", + ) +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/NativeStreamTransportIdentityTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/NativeStreamTransportIdentityTest.kt new file mode 100644 index 000000000..056ac26ce --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/NativeStreamTransportIdentityTest.kt @@ -0,0 +1,61 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Test + +class NativeStreamTransportIdentityTest { + @Test + fun runtimeSessionSnapshotDoesNotChangeTransportIdentity() { + val initial = session() + val refreshed = initial.copy( + status = 3, + queuePosition = 0, + seatSetupStep = 4, + negotiatedStreamProfile = NegotiatedStreamProfile( + resolution = "1680x720", + fps = 120, + codec = VideoCodec.H265, + ), + monitorSnapshot = SessionMonitorSnapshot( + requestedResolution = "1376x640", + requestedFps = 120, + returnedResolution = "1680x720", + returnedFps = 120, + ), + requestedStreamingFeatures = StreamingFeatures(bitDepth = 0), + finalizedStreamingFeatures = StreamingFeatures(bitDepth = 0), + ) + + assertEquals( + initial.nativeStreamTransportIdentity(), + refreshed.nativeStreamTransportIdentity(), + ) + } + + @Test + fun endpointChangeDoesChangeTransportIdentity() { + val initial = session() + val moved = initial.copy( + serverIp = "203.0.113.11", + signalingServer = "new.example.test", + signalingUrl = "wss://new.example.test/nvst/sign_in", + mediaConnectionInfo = MediaConnectionInfo("203.0.113.11", 5005), + ) + + assertNotEquals( + initial.nativeStreamTransportIdentity(), + moved.nativeStreamTransportIdentity(), + ) + } + + private fun session(): SessionInfo = SessionInfo( + sessionId = "session-1", + status = 2, + serverIp = "203.0.113.10", + signalingServer = "stream.example.test", + signalingUrl = "wss://stream.example.test/nvst/sign_in", + iceServers = listOf(IceServer(listOf("stun:stun.example.test:3478"))), + mediaConnectionInfo = MediaConnectionInfo("203.0.113.10", 5004), + ) +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/NativeTouchGamesTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/NativeTouchGamesTest.kt new file mode 100644 index 000000000..a25850249 --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/NativeTouchGamesTest.kt @@ -0,0 +1,205 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Native touch reroutes every finger away from the cursor and gamepad paths, so *where* it switches + * on is a safety question, not a preference one. These tests pin the catalog capability signal + * used by NVIDIA and the user's explicit mode override. + */ +class NativeTouchGamesTest { + + private fun game( + id: String = "id-1", + title: String = "Some Game", + supportedControls: List = emptyList(), + ) = GameInfo( + id = id, + title = title, + variants = listOf(GameVariant(id = "v1", store = "STEAM", supportedControls = supportedControls)), + ) + + @Test + fun theCatalogTouchFlagEnablesAutoMode() { + val touchGame = game(title = "Honkai: Star Rail", supportedControls = listOf("TOUCHSCREEN")) + assertTrue(catalogClaimsTouchSupport(touchGame)) + assertTrue(shouldUseNativeTouch(NativeTouchMode.Auto, touchGame)) + } + + @Test + fun fortniteCanPreferTheVirtualControllerWithoutLosingCatalogTouchSupport() { + val fortnite = game(title = "Fortnite", supportedControls = listOf("TOUCHSCREEN")) + val streamSettings = StreamSettings() + + assertTrue(catalogClaimsTouchSupport(fortnite)) + assertTrue( + shouldUseNativeTouchForStream( + NativeTouchMode.Auto, + fortnite, + streamSettings, + preferVirtualController = false, + ), + ) + assertFalse( + shouldUseNativeTouchForStream( + NativeTouchMode.Auto, + fortnite, + streamSettings, + preferVirtualController = true, + ), + ) + } + + @Test + fun keyboardMousePreferenceWinsOverNativeTouchForTheSession() { + val fortnite = game(title = "Fortnite", supportedControls = listOf("TOUCHSCREEN", "MOUSE")) + val streamSettings = StreamSettings() + + assertFalse( + shouldUseNativeTouchForStream( + NativeTouchMode.Auto, + fortnite, + streamSettings, + preferVirtualController = false, + preferKeyboardMouse = true, + ), + ) + assertFalse( + shouldUseNativeTouchForStream( + NativeTouchMode.Always, + fortnite, + streamSettings, + preferVirtualController = false, + preferKeyboardMouse = true, + ), + ) + } + + @Test + fun catalogTouchFlagIsCaseInsensitive() { + assertTrue(catalogClaimsTouchSupport(game(supportedControls = listOf("Touchscreen")))) + assertFalse(catalogClaimsTouchSupport(game(supportedControls = listOf("X_INPUT_GAMEPAD")))) + assertFalse(catalogClaimsTouchSupport(game())) + } + + // -- Mode --------------------------------------------------------------------------------- + + @Test + fun autoFollowsTheCatalogCapability() { + assertTrue(shouldUseNativeTouch(NativeTouchMode.Auto, game(supportedControls = listOf("TOUCHSCREEN")))) + assertFalse(shouldUseNativeTouch(NativeTouchMode.Auto, game(title = "Cyberpunk 2077"))) + } + + @Test + fun autoKeepsCatalogTouchAtHighPerformanceStreamAllocation() { + val touchGame = game(supportedControls = listOf("TOUCHSCREEN")) + + assertTrue( + shouldUseNativeTouch( + NativeTouchMode.Auto, + touchGame, + StreamSettings(resolution = "2560x1440", fps = 120), + ), + ) + assertTrue( + shouldUseNativeTouch( + NativeTouchMode.Auto, + touchGame, + StreamSettings(resolution = "1920x1080", fps = 60), + ), + ) + } + + @Test + fun alwaysTouchStillOverridesHighPerformanceAllocation() { + assertTrue( + shouldUseNativeTouch( + NativeTouchMode.Always, + game(supportedControls = listOf("TOUCHSCREEN")), + StreamSettings(resolution = "2560x1440", fps = 120), + ), + ) + } + + @Test + fun offWinsOverEverything() { + assertFalse(shouldUseNativeTouch(NativeTouchMode.Off, game(supportedControls = listOf("TOUCHSCREEN")))) + } + + @Test + fun alwaysAppliesToUnmarkedGamesToo() { + assertTrue(shouldUseNativeTouch(NativeTouchMode.Always, game(title = "Cyberpunk 2077"))) + } + + /** No game means no session to route touches into; Auto must not guess. */ + @Test + fun autoStaysOffWithoutAGame() { + assertFalse(shouldUseNativeTouch(NativeTouchMode.Auto, null)) + } + + @Test + fun nativeTouchDefaultsToSupportedCatalogGames() { + val settings = AndroidTouchSettings() + assertEquals(NativeTouchMode.Auto, settings.nativeTouchMode) + assertTrue(settings.nativeTouchOptedIn) + assertEquals(NativeTouchMode.Auto, settings.effectiveNativeTouchMode()) + } + + @Test + fun legacyAutoSettingKeepsItsPreviouslySavedBehavior() { + val legacy = AndroidTouchSettings( + nativeTouchMode = NativeTouchMode.Auto, + nativeTouchOptedIn = false, + ) + assertFalse(legacy.nativeTouchOptedIn) + assertEquals(NativeTouchMode.Auto, legacy.effectiveNativeTouchMode()) + } + + @Test + fun release159LegacyAutoStillSelectsFortniteNativeTouch() { + val release159Settings = AndroidTouchSettings( + nativeTouchMode = NativeTouchMode.Auto, + nativeTouchOptedIn = false, + ) + val fortnite = game( + title = "Fortnite®", + supportedControls = listOf("GAMEPAD", "KEYBOARD", "MOUSE", "TOUCHSCREEN"), + ) + + assertTrue( + shouldUseNativeTouchForStream( + mode = release159Settings.effectiveNativeTouchMode(), + game = fortnite, + streamSettings = StreamSettings(), + preferVirtualController = false, + preferKeyboardMouse = false, + ), + ) + } + + @Test + fun choosingNativeTouchExplicitlyEnablesIt() { + val enabled = AndroidTouchSettings().withNativeTouchMode(NativeTouchMode.Auto) + assertTrue(enabled.nativeTouchOptedIn) + assertEquals(NativeTouchMode.Auto, enabled.effectiveNativeTouchMode()) + + val disabled = enabled.withNativeTouchMode(NativeTouchMode.Off) + assertFalse(disabled.nativeTouchOptedIn) + assertEquals(NativeTouchMode.Off, disabled.effectiveNativeTouchMode()) + } + + @Test + fun diagnosticsCarryTheCatalogDecision() { + val line = nativeTouchDiagnostics( + game(id = "abc123", title = "Genshin Impact", supportedControls = listOf("TOUCHSCREEN", "KEYBOARD")), + enabled = true, + ) + assertTrue(line, line.contains("id=abc123")) + assertTrue(line, line.contains("title=Genshin Impact")) + assertTrue(line, line.contains("TOUCHSCREEN")) + assertTrue(line, line.contains("catalogTouch=true")) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/NativeTouchTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/NativeTouchTest.kt new file mode 100644 index 000000000..7f8537044 --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/NativeTouchTest.kt @@ -0,0 +1,409 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Native touch is the one input path whose wire format we transcribed from the official client + * rather than derived, so the byte layout is asserted literally here — a mistake in it would show + * up on a device only as "the game ignores my fingers", with nothing to point at. + * + * The behavioural rules matter just as much and are just as easy to get wrong: fingers are + * identified by a dense slot index rather than the platform's pointer id, and a lift must be + * reported even when the finger has left the picture, or the host holds it down forever. + */ +class NativeTouchTest { + + // -- Local UI ownership ----------------------------------------------------------------- + + @Test + fun removingLauncherBoundsDoesNotReclassifyItsActiveFingerAsGameTouch() { + val routing = NativeUiTouchRoutingState() + routing.setStreamChromeBounds(left = 20, top = 20, right = 120, bottom = 80) + routing.beginPointerGesture( + pointerId = 7, + touchesUi = routing.touchesRegisteredUi(x = 60f, y = 50f, width = 1280, height = 720), + ) + + routing.clearStreamChromeBounds() + + assertFalse(routing.touchesRegisteredUi(x = 60f, y = 50f, width = 1280, height = 720)) + assertTrue(routing.ownsPointer(7)) + assertTrue(routing.passthroughActive) + + routing.endPointerGesture() + assertFalse(routing.ownsPointer(7)) + assertFalse(routing.passthroughActive) + } + + @Test + fun replacingTransientOverlayKeepsPointerOwnershipUntilLift() { + val routing = NativeUiTouchRoutingState() + routing.setOverlayBound("menu-launcher", left = 900, top = 20, right = 1020, bottom = 100) + routing.beginPointerGesture( + pointerId = 12, + touchesUi = routing.touchesRegisteredUi(x = 960f, y = 60f, width = 1280, height = 720), + ) + + routing.clearOverlayBound("menu-launcher") + routing.setOverlayBound("controls-panel", left = 700, top = 10, right = 1280, bottom = 720) + + assertTrue(routing.ownsPointer(12)) + routing.releasePointer(12) + assertFalse(routing.hasOwnedPointer()) + } + + @Test + fun measuredTouchControllerLeavesUnoccupiedLowerScreenForFingerMouse() { + val routing = NativeUiTouchRoutingState() + routing.setTouchControllerVisible(true) + + // Protect the controller area until Compose reports its first measured control. + assertTrue(routing.touchesRegisteredUi(x = 640f, y = 600f, width = 1280, height = 720)) + + routing.setTouchControllerBound("face", left = 1040, top = 500, right = 1240, bottom = 700) + + assertTrue(routing.touchesRegisteredUi(x = 1140f, y = 600f, width = 1280, height = 720)) + assertFalse(routing.touchesRegisteredUi(x = 640f, y = 600f, width = 1280, height = 720)) + } + + @Test + fun visibleControllerKeepsFingerMouseGestureInComposeForEitherPointerOrder() { + val routing = NativeUiTouchRoutingState() + assertFalse(routing.routesTouchMouseThroughCompose()) + + routing.setTouchControllerVisible(true) + routing.setTouchControllerBound("left-stick", left = 20, top = 450, right = 260, bottom = 700) + + assertTrue(routing.routesTouchMouseThroughCompose()) + + routing.beginPointerGesture(pointerId = 1, touchesUi = false) + routing.addPointer(pointerId = 2, touchesUi = true) + assertFalse(routing.ownsPointer(1)) + assertTrue(routing.ownsPointer(2)) + + routing.endPointerGesture() + routing.beginPointerGesture(pointerId = 2, touchesUi = true) + routing.addPointer(pointerId = 1, touchesUi = false) + assertFalse(routing.ownsPointer(1)) + assertTrue(routing.ownsPointer(2)) + + routing.setTouchControllerVisible(false) + assertFalse(routing.routesTouchMouseThroughCompose()) + } + + @Test + fun pointerOwnershipDoesNotChangeWhenFingerCrossesControllerBounds() { + val routing = NativeUiTouchRoutingState() + + routing.beginPointerGesture(pointerId = 4, touchesUi = false) + assertFalse(routing.classifiesPointerAsUi(pointerId = 4, touchesUiNow = true)) + + routing.endPointerGesture() + routing.beginPointerGesture(pointerId = 7, touchesUi = true) + assertTrue(routing.classifiesPointerAsUi(pointerId = 7, touchesUiNow = false)) + } + + @Test + fun untrackedPointerFallsBackToCurrentUiBounds() { + val routing = NativeUiTouchRoutingState() + + assertTrue(routing.classifiesPointerAsUi(pointerId = 9, touchesUiNow = true)) + assertFalse(routing.classifiesPointerAsUi(pointerId = 9, touchesUiNow = false)) + } + + @Test + fun onlyAnOwnedLauncherGestureIsConsumedAfterUiOpens() { + assertTrue(shouldConsumeNativeUiTransitionTouch(streamUiActive = true, hasOwnedPointer = true)) + assertFalse(shouldConsumeNativeUiTransitionTouch(streamUiActive = false, hasOwnedPointer = true)) + assertFalse(shouldConsumeNativeUiTransitionTouch(streamUiActive = true, hasOwnedPointer = false)) + } + + // -- Slot allocation --------------------------------------------------------------------- + + @Test + fun slotsAreDenseAndStartAtZero() { + val allocator = TouchSlotAllocator() + assertEquals(0, allocator.acquire(pointerId = 42)) + assertEquals(1, allocator.acquire(pointerId = 7)) + assertEquals(2, allocator.acquire(pointerId = 99)) + } + + @Test + fun theSamePointerKeepsItsSlot() { + val allocator = TouchSlotAllocator() + val first = allocator.acquire(pointerId = 42) + assertEquals(first, allocator.acquire(pointerId = 42)) + assertEquals(1, allocator.activeCount) + } + + /** The reason this class exists: platform pointer ids climb, host slots must not. */ + @Test + fun aFreedSlotIsReusedRatherThanSkipped() { + val allocator = TouchSlotAllocator() + allocator.acquire(pointerId = 10) // slot 0 + allocator.acquire(pointerId = 11) // slot 1 + + assertEquals(0, allocator.release(pointerId = 10)) + // A brand new finger, with a pointer id nothing like the old one. + assertEquals(0, allocator.acquire(pointerId = 5000)) + } + + @Test + fun releasingAnUnknownPointerIsHarmless() { + val allocator = TouchSlotAllocator() + assertNull(allocator.release(pointerId = 3)) + } + + @Test + fun allocationStopsAtTheHostLimit() { + val allocator = TouchSlotAllocator() + repeat(MAX_CONCURRENT_TOUCHES) { index -> assertNotNull(allocator.acquire(pointerId = index)) } + assertNull(allocator.acquire(pointerId = 999)) + } + + // -- Batch construction ------------------------------------------------------------------ + + private fun batch( + allocator: TouchSlotAllocator = TouchSlotAllocator(), + phase: Int, + pointers: List, + viewWidth: Int = 1280, + viewHeight: Int = 720, + ) = buildTouchBatch( + allocator = allocator, + phase = phase, + pointers = pointers, + viewWidth = viewWidth, + viewHeight = viewHeight, + streamWidth = 1920, + streamHeight = 1080, + stretchToFit = false, + renderingAspectRatio = 0f, + ) + + @Test + fun coordinatesAreAFractionOfTheVideoArea() { + val records = batch( + phase = TouchPhase.DOWN, + pointers = listOf(TouchPointerSample(pointerId = 1, x = 640f, y = 360f)), + ) + assertEquals(1, records.size) + // Dead centre of a matching-aspect view. + assertEquals((TOUCH_COORDINATE_MAX / 2).toDouble(), records[0].x.toDouble(), 1.0) + assertEquals((TOUCH_COORDINATE_MAX / 2).toDouble(), records[0].y.toDouble(), 1.0) + } + + @Test + fun twoFingersGetDistinctSlotsInOneBatch() { + val records = batch( + phase = TouchPhase.MOVE, + pointers = listOf( + TouchPointerSample(pointerId = 1, x = 100f, y = 100f), + TouchPointerSample(pointerId = 2, x = 900f, y = 500f), + ), + ) + assertEquals(2, records.size) + assertEquals(setOf(0, 1), records.map { it.slot }.toSet()) + } + + /** + * A finger on the letterbox bar is not a finger on the picture. Reporting it clamped to the + * edge would fire an unintended tap right where the game's UI usually lives. + */ + @Test + fun aTouchOutsideThePictureIsDroppedWhilePressed() { + // 4:3 view onto a 16:9 stream: 90px bars top and bottom, video occupies y 90..630. + val records = batch( + phase = TouchPhase.MOVE, + pointers = listOf(TouchPointerSample(pointerId = 1, x = 480f, y = 10f)), + viewWidth = 960, + viewHeight = 720, + ) + assertTrue("expected the touch on the bar to be dropped, got $records", records.isEmpty()) + } + + /** ...but a lift out there must still be sent, or that finger never comes up on the host. */ + @Test + fun aLiftOutsideThePictureIsStillReported() { + val allocator = TouchSlotAllocator() + batch( + allocator = allocator, + phase = TouchPhase.DOWN, + pointers = listOf(TouchPointerSample(pointerId = 1, x = 480f, y = 360f)), + viewWidth = 960, + viewHeight = 720, + ) + + val records = batch( + allocator = allocator, + phase = TouchPhase.UP, + pointers = listOf(TouchPointerSample(pointerId = 1, x = 480f, y = 10f)), + viewWidth = 960, + viewHeight = 720, + ) + assertEquals(1, records.size) + assertEquals(TouchPhase.UP, records[0].phase) + assertEquals(0, records[0].slot) + assertEquals(0, allocator.activeCount) + } + + @Test + fun coordinatesAreClampedIntoRange() { + // Slightly outside, but within the finger's radius, so it survives and clamps. + val records = batch( + phase = TouchPhase.MOVE, + pointers = listOf(TouchPointerSample(pointerId = 1, x = -2f, y = 360f, radiusX = 40f)), + ) + assertEquals(1, records.size) + assertEquals(0, records[0].x) + } + + @Test + fun degenerateSizesProduceNothing() { + val pointers = listOf(TouchPointerSample(pointerId = 1, x = 10f, y = 10f)) + assertTrue(batch(phase = TouchPhase.DOWN, pointers = pointers, viewWidth = 0).isEmpty()) + assertTrue(batch(phase = TouchPhase.DOWN, pointers = pointers, viewHeight = 0).isEmpty()) + } + + @Test + fun nonFinitePointerIsDroppedButItsLiftStillReleasesTheHostSlot() { + val allocator = TouchSlotAllocator() + batch( + allocator = allocator, + phase = TouchPhase.DOWN, + pointers = listOf(TouchPointerSample(pointerId = 1, x = 640f, y = 360f)), + ) + + assertTrue( + batch( + allocator = allocator, + phase = TouchPhase.MOVE, + pointers = listOf(TouchPointerSample(pointerId = 1, x = Float.NaN, y = 360f)), + ).isEmpty(), + ) + val release = batch( + allocator = allocator, + phase = TouchPhase.UP, + pointers = listOf(TouchPointerSample(pointerId = 1, x = Float.NaN, y = Float.NaN)), + ) + + assertEquals(1, release.size) + assertEquals(TouchPhase.UP, release.single().phase) + assertEquals(0, allocator.activeCount) + } + + // -- Wire format ------------------------------------------------------------------------- + + /** Strips the transport wrapper so the assertions below address the payload itself. */ + private fun payloadOf(packet: ByteArray, recordCount: Int): ByteArray { + val payloadSize = 8 + 16 * recordCount + return packet.copyOfRange(packet.size - payloadSize, packet.size) + } + + private fun be16(bytes: ByteArray, offset: Int): Int = + ((bytes[offset].toInt() and 0xff) shl 8) or (bytes[offset + 1].toInt() and 0xff) + + private fun le32(bytes: ByteArray, offset: Int): Int = + (bytes[offset].toInt() and 0xff) or + ((bytes[offset + 1].toInt() and 0xff) shl 8) or + ((bytes[offset + 2].toInt() and 0xff) shl 16) or + ((bytes[offset + 3].toInt() and 0xff) shl 24) + + @Test + fun packetMatchesTheDocumentedLayout() { + val encoder = InputEncoder() + val packet = encoder.encodeTouchBatch( + listOf( + TouchRecord(slot = 3, phase = TouchPhase.DOWN, x = 0x1234, y = 0x5678, radiusX = 9, radiusY = 11), + TouchRecord(slot = 0, phase = TouchPhase.MOVE, x = 1, y = 2), + ), + nowUs = FIXED_NOW_US, + ) + assertNotNull(packet) + val payload = payloadOf(packet!!, recordCount = 2) + + // Opcode is little-endian; everything after it is big-endian. + assertEquals(InputEncoder.INPUT_TOUCH, le32(payload, 0)) + assertEquals(8 + 16 * 2, be16(payload, 4)) + assertEquals(2, be16(payload, 6)) + + assertEquals(3, payload[8].toInt()) + assertEquals(TouchPhase.DOWN, payload[9].toInt()) + assertEquals(0x1234, be16(payload, 10)) + assertEquals(0x5678, be16(payload, 12)) + assertEquals(9, payload[14].toInt()) + assertEquals(11, payload[15].toInt()) + + assertEquals(0, payload[24].toInt()) + assertEquals(TouchPhase.MOVE, payload[25].toInt()) + assertEquals(1, be16(payload, 26)) + assertEquals(2, be16(payload, 28)) + } + + @Test + fun theTopCoordinateSurvivesAsUnsigned() { + val encoder = InputEncoder() + val packet = encoder.encodeTouchBatch( + listOf(TouchRecord(slot = 0, phase = TouchPhase.MOVE, x = TOUCH_COORDINATE_MAX, y = TOUCH_COORDINATE_MAX)), + nowUs = FIXED_NOW_US, + ) + val payload = payloadOf(packet!!, recordCount = 1) + assertEquals(TOUCH_COORDINATE_MAX, be16(payload, 10)) + assertEquals(TOUCH_COORDINATE_MAX, be16(payload, 12)) + } + + /** A record left at 0 is stamped by the encoder, so the host never sees a zero timestamp. */ + @Test + fun anUnstampedRecordGetsTheEncodersClock() { + val payload = payloadOf( + InputEncoder().encodeTouchBatch( + listOf(TouchRecord(slot = 0, phase = TouchPhase.DOWN, x = 0, y = 0)), + nowUs = FIXED_NOW_US, + )!!, + recordCount = 1, + ) + val stamp = (16..23).fold(0L) { acc, i -> (acc shl 8) or (payload[i].toLong() and 0xff) } + assertEquals(FIXED_NOW_US, stamp) + } + + @Test + fun anExplicitRecordTimestampIsKept() { + val payload = payloadOf( + InputEncoder().encodeTouchBatch( + listOf(TouchRecord(slot = 0, phase = TouchPhase.DOWN, x = 0, y = 0, timestampUs = 4242L)), + nowUs = FIXED_NOW_US, + )!!, + recordCount = 1, + ) + val stamp = (16..23).fold(0L) { acc, i -> (acc shl 8) or (payload[i].toLong() and 0xff) } + assertEquals(4242L, stamp) + } + + @Test + fun anEmptyBatchProducesNoPacket() { + assertNull(InputEncoder().encodeTouchBatch(emptyList(), nowUs = FIXED_NOW_US)) + } + + @Test + fun aBatchIsCappedRatherThanOverflowing() { + val encoder = InputEncoder() + val touches = (0 until MAX_TOUCH_RECORDS_PER_BATCH + 10).map { + TouchRecord(slot = 0, phase = TouchPhase.MOVE, x = 0, y = 0) + } + val payload = payloadOf( + encoder.encodeTouchBatch(touches, nowUs = FIXED_NOW_US)!!, + recordCount = MAX_TOUCH_RECORDS_PER_BATCH, + ) + assertEquals(MAX_TOUCH_RECORDS_PER_BATCH, be16(payload, 6)) + } + + private companion object { + /** Any fixed value; SystemClock is unavailable on the JVM, and exactness beats "> 0". */ + const val FIXED_NOW_US = 1_234_567L + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/OpenNowAnalyticsTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/OpenNowAnalyticsTest.kt new file mode 100644 index 000000000..ed2d0b18e --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/OpenNowAnalyticsTest.kt @@ -0,0 +1,99 @@ +package com.opencloudgaming.opennow + +import com.posthog.PostHogEvent +import com.posthog.android.PostHogAndroidConfig +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class OpenNowAnalyticsTest { + @Test + fun appliesPrivacyFirstAnalyticsConfig() { + val config = PostHogAndroidConfig( + apiKey = "phc_test", + host = "https://us.i.posthog.com", + ).apply { + sessionReplay = false + sessionReplayConfig.screenshot = false + sessionReplayConfig.maskAllTextInputs = false + sessionReplayConfig.maskAllImages = false + sessionReplayConfig.captureLogcat = true + captureDeepLinks = true + } + + config.applyOpenNowSettings( + AppSettings( + analyticsConsentAsked = true, + analyticsOptOut = false, + ), + ) + + assertFalse(config.optOut) + assertTrue(config.captureApplicationLifecycleEvents) + assertFalse(config.captureDeepLinks) + assertTrue(config.captureScreenViews) + assertEquals(10, config.flushIntervalSeconds) + assertFalse(config.sessionReplay) + assertFalse(config.sessionReplayConfig.screenshot) + assertFalse(config.sessionReplayConfig.captureLogcat) + assertTrue(config.sessionReplayConfig.maskAllTextInputs) + assertTrue(config.sessionReplayConfig.maskAllImages) + assertTrue(config.errorTrackingConfig.autoCapture) + assertEquals(1, config.beforeSendList.size) + + val sanitizedCrash = config.beforeSendList.single().run( + PostHogEvent( + event = "\$exception", + distinctId = "anonymous", + properties = mutableMapOf( + "\$exception_list" to listOf( + mapOf( + "type" to "IllegalStateException", + "value" to "token=secret for player@example.invalid", + ), + ), + ), + ), + ) + val exception = (sanitizedCrash?.properties?.get("\$exception_list") as List<*>).single() as Map<*, *> + assertEquals("IllegalStateException", exception["type"]) + assertFalse(exception.containsKey("value")) + assertEquals(true, sanitizedCrash.properties?.get("\$geoip_disable")) + } + + @Test + fun analyticsStayOffUntilConsentIsRecorded() { + val config = PostHogAndroidConfig( + apiKey = "phc_test", + host = "https://us.i.posthog.com", + ) + + config.applyOpenNowSettings(AppSettings(analyticsOptOut = false, analyticsConsentAsked = false)) + + assertTrue(config.optOut) + } + + @Test + fun analyticsPropertiesDropFreeFormAndIdentifyingValues() { + val properties = sanitizedAnalyticsProperties( + mapOf( + "query" to "private search", + "error_message" to "token=secret from player@example.invalid", + "provider" to "NP-PCC", + "server" to "203.0.113.42", + "metadata" to "sessionId=private deviceName=Kiefers-Controller email=player@example.invalid", + ), + ) + + assertFalse(properties.containsKey("query")) + assertFalse(properties.containsKey("error_message")) + assertEquals("NP-PCC", properties["provider"]) + assertEquals("[redacted-ip]", properties["server"]) + val metadata = properties["metadata"] as String + assertFalse(metadata.contains("private")) + assertFalse(metadata.contains("Kiefers-Controller")) + assertFalse(metadata.contains("player@example.invalid")) + assertEquals(true, properties["\$geoip_disable"]) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/OpenNowHapticsTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/OpenNowHapticsTest.kt new file mode 100644 index 000000000..7166ae5df --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/OpenNowHapticsTest.kt @@ -0,0 +1,139 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class OpenNowHapticsTest { + @Test + fun recognizesBuiltInControllerAndroidHandhelds() { + assertTrue( + isGamingHandheldDevice( + manufacturer = "AYN", + brand = "AYN", + model = "Odin 2 Portal", + device = "odin2portal", + product = "odin2portal", + ), + ) + assertTrue( + isGamingHandheldDevice( + manufacturer = "Moorechip", + brand = "Retroid Pocket", + model = "Retroid Pocket 5", + device = "RP5", + product = "RP5", + ), + ) + assertTrue( + isGamingHandheldDevice( + manufacturer = "Anbernic", + brand = "Anbernic", + model = "RG556", + device = "RG556", + product = "RG556", + ), + ) + } + + @Test + fun ordinaryPhonesDoNotEnableHandheldNavigationHaptics() { + assertFalse( + isGamingHandheldDevice( + manufacturer = "Google", + brand = "google", + model = "Pixel 10 Pro", + device = "mustang", + product = "mustang", + ), + ) + assertFalse( + isGamingHandheldDevice( + manufacturer = "Samsung", + brand = "samsung", + model = "SM-S938W", + device = "pa3q", + product = "pa3qcsx", + ), + ) + } + + @Test + fun focusTicksAreLighterThanActivations() { + val focus = hapticPulseFor(HapticCue.FocusMove) + val activate = hapticPulseFor(HapticCue.Activate) + assertTrue(focus.amplitude < activate.amplitude) + assertTrue(focus.durationMs < activate.durationMs) + } + + @Test + fun everyCueRequestsAPlayableAmplitude() { + HapticCue.entries.forEach { cue -> + val pulse = hapticPulseFor(cue) + assertTrue("$cue amplitude", pulse.amplitude in 1..255) + assertTrue("$cue duration", pulse.durationMs > 0) + } + } + + @Test + fun handheldPulsesAreLongerAndStrongerThanGenericPulses() { + HapticCue.entries.forEach { cue -> + val generic = hapticPulseFor(cue) + val handheld = handheldHapticPulseFor(cue) + assertTrue("$cue duration", handheld.durationMs > generic.durationMs) + assertTrue("$cue amplitude", handheld.amplitude >= generic.amplitude) + } + assertEquals(255, handheldHapticPulseFor(HapticCue.FocusMove).amplitude) + } + + @Test + fun firstFocusTickAlwaysFires() { + assertTrue(shouldEmitFocusHaptic(lastAtMs = 0L, nowMs = 0L)) + } + + @Test + fun repeatedFocusMovesAreThrottled() { + assertFalse(shouldEmitFocusHaptic(lastAtMs = 1_000L, nowMs = 1_000L + FOCUS_HAPTIC_MIN_INTERVAL_MS - 1)) + assertTrue(shouldEmitFocusHaptic(lastAtMs = 1_000L, nowMs = 1_000L + FOCUS_HAPTIC_MIN_INTERVAL_MS)) + } + + @Test + fun forcedOutputDoesNotFallBackToTheOtherDevice() { + // The whole point of forcing is that the other output is the one being avoided. + assertEquals( + HapticsOutputTarget.None, + selectHapticsOutputTarget( + vibrationEnabled = true, + controllerRumbleAvailable = false, + deviceHapticsAvailable = true, + preference = HapticsOutputPreference.Controller, + ), + ) + assertEquals( + HapticsOutputTarget.Device, + selectHapticsOutputTarget( + vibrationEnabled = true, + controllerRumbleAvailable = true, + deviceHapticsAvailable = true, + preference = HapticsOutputPreference.Device, + ), + ) + } + + @Test + fun forcedOutputStillObeysTheVibrationSwitch() { + HapticsOutputPreference.entries.forEach { preference -> + assertEquals( + "$preference", + HapticsOutputTarget.None, + selectHapticsOutputTarget( + vibrationEnabled = false, + controllerRumbleAvailable = true, + deviceHapticsAvailable = true, + preference = preference, + ), + ) + } + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/PhysicalInputLifecycleTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/PhysicalInputLifecycleTest.kt new file mode 100644 index 000000000..7d1eac51c --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/PhysicalInputLifecycleTest.kt @@ -0,0 +1,38 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class PhysicalInputLifecycleTest { + @Test + fun snapshotsOnlySuccessfullyForwardedPressedInput() { + val state = ForwardedPhysicalInputState() + val alt = InputEncoder.KeyboardPayload(keycode = 0x12, scancode = 0x38, modifiers = 0x04, timestampUs = 1) + val w = InputEncoder.KeyboardPayload(keycode = 0x57, scancode = 0x11, modifiers = 0, timestampUs = 2) + + state.recordKey(deviceId = 12, keyCode = 57, scanCode = 56, payload = alt, pressed = true, sent = true) + state.recordKey(deviceId = 12, keyCode = 51, scanCode = 17, payload = w, pressed = true, sent = false) + state.recordMouseButton(button = 1, pressed = true, sent = true) + state.recordMouseButton(button = 3, pressed = true, sent = false) + + val snapshot = state.takeReleaseSnapshot() + + assertEquals(listOf(alt), snapshot.keys) + assertEquals(listOf(1), snapshot.mouseButtons) + assertTrue(state.takeReleaseSnapshot().isEmpty) + } + + @Test + fun matchingUpRemovesInputBeforeFocusLoss() { + val state = ForwardedPhysicalInputState() + val alt = InputEncoder.KeyboardPayload(keycode = 0x12, scancode = 0x38, modifiers = 0x04, timestampUs = 1) + + state.recordKey(deviceId = 12, keyCode = 57, scanCode = 56, payload = alt, pressed = true, sent = true) + state.recordKey(deviceId = 12, keyCode = 57, scanCode = 56, payload = alt, pressed = false, sent = true) + state.recordMouseButton(button = 1, pressed = true, sent = true) + state.recordMouseButton(button = 1, pressed = false, sent = false) + + assertTrue(state.takeReleaseSnapshot().isEmpty) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/PhysicalMouseDevicesTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/PhysicalMouseDevicesTest.kt new file mode 100644 index 000000000..0d311025c --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/PhysicalMouseDevicesTest.kt @@ -0,0 +1,33 @@ +package com.opencloudgaming.opennow + +import android.view.InputDevice +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class PhysicalMouseDevicesTest { + @Test + fun recognizesAbsoluteAndCapturedRelativeMouseSources() { + assertTrue(isMouseInputSource(InputDevice.SOURCE_MOUSE)) + assertTrue(isMouseInputSource(InputDevice.SOURCE_MOUSE_RELATIVE)) + } + + @Test + fun doesNotTreatTouchscreenOrControllerAsAMouse() { + assertFalse(isMouseInputSource(InputDevice.SOURCE_TOUCHSCREEN)) + assertFalse(isMouseInputSource(InputDevice.SOURCE_GAMEPAD or InputDevice.SOURCE_JOYSTICK)) + } + + @Test + fun recognizesOnlyAlphabeticKeyboardSourcesAsPhysicalTypingDevices() { + assertTrue( + isKeyboardInputSource(InputDevice.SOURCE_KEYBOARD, InputDevice.KEYBOARD_TYPE_ALPHABETIC), + ) + assertFalse( + isKeyboardInputSource(InputDevice.SOURCE_KEYBOARD, InputDevice.KEYBOARD_TYPE_NON_ALPHABETIC), + ) + assertFalse( + isKeyboardInputSource(InputDevice.SOURCE_GAMEPAD, InputDevice.KEYBOARD_TYPE_ALPHABETIC), + ) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/PrintedWasteRecommendationTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/PrintedWasteRecommendationTest.kt new file mode 100644 index 000000000..172cff116 --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/PrintedWasteRecommendationTest.kt @@ -0,0 +1,64 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertEquals +import org.junit.Test + +class PrintedWasteRecommendationTest { + @Test + fun recommendationUsesClosestRegionWhenQueueAwareChoiceExceedsOneHundredMs() { + val closest = zone("NP-CLOSE", pingMs = 90, queuePosition = 1_000) + val shorterQueue = zone("NP-SHORT-QUEUE", pingMs = 101, queuePosition = 0) + + assertEquals( + closest.zoneId, + recommendedPrintedWasteZone(listOf(closest, shorterQueue))?.zoneId, + ) + } + + @Test + fun recommendationUsesClosestRegionWhenEveryMeasuredRegionExceedsOneHundredMs() { + val closest = zone("NP-CLOSE", pingMs = 105, queuePosition = 1_000) + val shorterQueue = zone("NP-SHORT-QUEUE", pingMs = 150, queuePosition = 0) + + assertEquals( + closest.zoneId, + recommendedPrintedWasteZone(listOf(closest, shorterQueue))?.zoneId, + ) + } + + @Test + fun recommendationKeepsQueueAwareChoiceAtOneHundredMs() { + val closest = zone("NP-CLOSE", pingMs = 95, queuePosition = 1_000) + val shorterQueue = zone("NP-SHORT-QUEUE", pingMs = 100, queuePosition = 0) + + assertEquals( + shorterQueue.zoneId, + recommendedPrintedWasteZone(listOf(closest, shorterQueue))?.zoneId, + ) + } + + @Test + fun closestRegionUsesQueueAndZoneIdAsStableTieBreakers() { + val longerQueue = zone("NP-A", pingMs = 120, queuePosition = 20) + val shorterQueue = zone("NP-B", pingMs = 120, queuePosition = 10) + + assertEquals( + shorterQueue.zoneId, + recommendedPrintedWasteZone(listOf(longerQueue, shorterQueue))?.zoneId, + ) + } + + private fun zone( + zoneId: String, + pingMs: Long, + queuePosition: Int, + ) = PrintedWasteZoneOption( + zoneId = zoneId, + zone = PrintedWasteZone( + QueuePosition = queuePosition, + Region = "TEST", + ), + routingUrl = "https://${zoneId.lowercase()}.example.test/", + pingMs = pingMs, + ) +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/PrintedWasteZonesTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/PrintedWasteZonesTest.kt new file mode 100644 index 000000000..7bae7ec61 --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/PrintedWasteZonesTest.kt @@ -0,0 +1,112 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class PrintedWasteZonesTest { + + private fun zone(id: String, queue: Int, ping: Long?, region: String = "US") = PrintedWasteZoneOption( + zoneId = id, + zone = PrintedWasteZone(QueuePosition = queue, Region = region), + routingUrl = printedWasteZoneUrl(id), + pingMs = ping, + ) + + private fun entry(title: String, region: String, gpu5080: Boolean = false, gpu4080: Boolean = false) = + PrintedWasteServerMappingEntry( + title = title, + region = region, + is4080Server = gpu4080, + is5080Server = gpu5080, + nuked = false, + ) + + @Test + fun alliancePartnerZonesAreNotQueueRoutable() { + assertTrue(isStandardPrintedWasteZone("NP-LAX-03")) + assertFalse(isStandardPrintedWasteZone("NPA-LON-01")) + assertFalse(isStandardPrintedWasteZone("EU-LON-01")) + } + + @Test + fun zoneUrlUsesTheLowercasedIdOnCloudMatch() { + assertEquals("https://np-lax-03.cloudmatchbeta.nvidiagrid.net/", printedWasteZoneUrl("NP-LAX-03")) + } + + @Test + fun serversSharingALocationCollapseIntoOneRow() { + val mapping = mapOf( + "NP-LAX-02" to entry("Southern California", "US Southwest", gpu4080 = true), + "NP-LAX-03" to entry("Southern California", "US Southwest", gpu5080 = true), + "NP-SJC6-04" to entry("Northern California", "US West"), + ) + val zones = listOf( + zone("NP-LAX-02", queue = 40, ping = 60), + zone("NP-LAX-03", queue = 2, ping = 22), + zone("NP-SJC6-04", queue = 1, ping = 90), + ) + + val locations = printedWasteLocations(zones, mapping) + + assertEquals(2, locations.size) + val socal = locations.first { it.title == "Southern California" } + // The row routes to the better of the two, not to whichever id sorted first. + assertEquals("NP-LAX-03", socal.primary.zoneId) + assertEquals(1, socal.alternateCount) + assertEquals("US Southwest", socal.region) + // The best GPU anywhere in the group is what the location can offer. + assertEquals(PrintedWasteGpuTier.Rtx5080, socal.gpuTier) + } + + @Test + fun anUnmappedZoneStaysSelectableUnderItsRawId() { + val zones = listOf(zone("NP-XYZ-01", queue = 3, ping = 30, region = "EU")) + + val locations = printedWasteLocations(zones, emptyMap()) + + assertEquals("NP-XYZ-01", locations.single().title) + // Falls back to the queue payload's continent code rather than showing nothing. + assertEquals("Europe", locations.single().region) + assertNull(locations.single().gpuTier) + } + + @Test + fun regionsAreOrderedByTheirStrongestLocation() { + val mapping = mapOf( + "NP-LAX-03" to entry("Southern California", "US Southwest"), + "NP-ASH-04" to entry("Virginia", "US East"), + "NP-NWK-04" to entry("New Jersey", "US Northeast"), + ) + val zones = listOf( + zone("NP-ASH-04", queue = 2, ping = 140), + zone("NP-LAX-03", queue = 1, ping = 18), + zone("NP-NWK-04", queue = 2, ping = 80), + ) + val maxPing = 140L + val maxQueue = 2 + + val groups = printedWasteRegionGroups(printedWasteLocations(zones, mapping), maxPing, maxQueue) + + assertEquals(listOf("US Southwest", "US Northeast", "US East"), groups.map { it.first }) + } + + @Test + fun theRecommendationPrefersLowPingOverAShorterQueue() { + val zones = listOf( + zone("NP-LAX-03", queue = 12, ping = 20), + zone("NP-ASH-04", queue = 1, ping = 180), + ) + + assertEquals("NP-LAX-03", recommendedPrintedWasteZone(zones)?.zoneId) + } + + @Test + fun waitTimesReadAsMinutesThenHours() { + assertEquals("1m", formatPrintedWasteWait(1_000L)) + assertEquals("5m", formatPrintedWasteWait(5L * 60_000L)) + assertEquals("1h 30m", formatPrintedWasteWait(90L * 60_000L)) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/ProcessCpuProfilerTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/ProcessCpuProfilerTest.kt new file mode 100644 index 000000000..1ef76d89b --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/ProcessCpuProfilerTest.kt @@ -0,0 +1,58 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class ProcessCpuProfilerTest { + @Test + fun reportsProcessAndDeviceNormalizedCpuFromDeltas() { + val cpuTimes = ArrayDeque(listOf(500L, 750L)) + val elapsedTimes = ArrayDeque(listOf(1_000L, 2_000L)) + val sampler = ProcessCpuSampler( + processCpuTimeMs = { cpuTimes.removeFirst() }, + elapsedRealtimeMs = { elapsedTimes.removeFirst() }, + logicalCoreCount = 8, + ) + + assertNull(sampler.sample()) + val sample = requireNotNull(sampler.sample()) + + assertEquals(1_000L, sample.windowMs) + assertEquals(25.0, sample.processCpuPercent, 0.0001) + assertEquals(3.125, sample.deviceCpuCapacityPercent, 0.0001) + assertEquals(8, sample.logicalCoreCount) + } + + @Test + fun rejectsInvalidClockDeltasAndUsesNextSampleAsNewBaseline() { + val cpuTimes = ArrayDeque(listOf(500L, 400L, 500L)) + val elapsedTimes = ArrayDeque(listOf(1_000L, 2_000L, 3_000L)) + val sampler = ProcessCpuSampler( + processCpuTimeMs = { cpuTimes.removeFirst() }, + elapsedRealtimeMs = { elapsedTimes.removeFirst() }, + logicalCoreCount = 4, + ) + + assertNull(sampler.sample()) + assertNull(sampler.sample()) + assertEquals(10.0, requireNotNull(sampler.sample()).processCpuPercent, 0.0001) + } + + @Test + fun profileBufferKeepsBoundedSamplesAndSummarizesThem() { + val profile = ProcessCpuProfileBuffer(maxSamples = 2) + profile.record(ProcessCpuUsageSample(1_000L, 1_000L, 20.0, 5.0, 4)) + profile.record(ProcessCpuUsageSample(2_000L, 1_000L, 40.0, 10.0, 4)) + profile.record(ProcessCpuUsageSample(3_000L, 1_000L, 60.0, 15.0, 4)) + + val snapshot = profile.snapshot() + + assertTrue(snapshot.contains("samples=2")) + assertTrue(snapshot.contains("processAvgPct=50.0")) + assertTrue(snapshot.contains("processPeakPct=60.0")) + assertTrue(snapshot.contains("cpu.1 uptimeMs=2000")) + assertTrue(snapshot.contains("cpu.2 uptimeMs=3000")) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/QueueLaunchStatusTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/QueueLaunchStatusTest.kt new file mode 100644 index 000000000..e0e54f8de --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/QueueLaunchStatusTest.kt @@ -0,0 +1,158 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class QueueLaunchStatusTest { + @Test + fun seatSetupStepFiveQueuePositionIsNotDisplayed() { + val session = session(queuePosition = 1, seatSetupStep = 5) + val state = OpenNowUiState( + streamStatus = "queue", + launchPhase = "Queue", + queuePosition = 1, + streamSession = session, + ) + + assertNull(queueDisplayPosition(session)) + assertNull(queueDisplayPosition(state)) + assertEquals("Starting session", queueLaunchStatusText(state)) + } + + @Test + fun seatSetupStepOneQueuePositionIsDisplayed() { + val session = session(queuePosition = 40, seatSetupStep = 1) + val state = OpenNowUiState( + streamStatus = "queue", + streamSession = session, + ) + + assertEquals(40, queueDisplayPosition(session)) + assertEquals(40, queueDisplayPosition(state)) + assertEquals("Queue position 40", queueLaunchStatusText(state)) + } + + @Test + fun queueReadyNotificationFiresOnceWhenObservedQueueStartsConnecting() { + val tracker = QueueReadyNotificationTracker() + val queuedSession = session(queuePosition = 12, seatSetupStep = 1) + val connectingSession = session(queuePosition = null, seatSetupStep = 5) + + assertFalse( + tracker.update( + OpenNowUiState( + streamStatus = "queue", + launchPhase = "Queue", + queuePosition = 12, + streamSession = queuedSession, + ), + ), + ) + assertTrue( + tracker.update( + OpenNowUiState( + streamStatus = "connecting", + launchPhase = "Connecting stream", + streamSession = connectingSession, + ), + ), + ) + assertFalse( + tracker.update( + OpenNowUiState( + streamStatus = "connecting", + launchPhase = "Connecting stream", + streamSession = connectingSession, + ), + ), + ) + } + + @Test + fun queueReadyNotificationDoesNotFireForLaunchWithoutObservedQueue() { + val tracker = QueueReadyNotificationTracker() + val launchSession = session(queuePosition = null, seatSetupStep = 5) + + assertFalse( + tracker.update( + OpenNowUiState( + streamStatus = "queue", + launchPhase = "Creating session", + streamSession = launchSession, + ), + ), + ) + assertFalse( + tracker.update( + OpenNowUiState( + streamStatus = "connecting", + launchPhase = "Connecting stream", + streamSession = launchSession, + ), + ), + ) + } + + @Test + fun queueReadyNotificationDoesNotLeakAcrossSessionsOrCancelledLaunches() { + val cancelledTracker = QueueReadyNotificationTracker() + + assertFalse( + cancelledTracker.update( + OpenNowUiState( + streamStatus = "queue", + launchPhase = "Queue", + streamSession = session(sessionId = "queued", queuePosition = 4, seatSetupStep = 1), + ), + ), + ) + assertFalse(cancelledTracker.update(OpenNowUiState(streamStatus = "idle"))) + assertFalse( + cancelledTracker.update( + OpenNowUiState( + streamStatus = "connecting", + launchPhase = "Connecting stream", + streamSession = session(sessionId = "different", queuePosition = null, seatSetupStep = 5), + ), + ), + ) + + val replacedSessionTracker = QueueReadyNotificationTracker() + assertFalse( + replacedSessionTracker.update( + OpenNowUiState( + streamStatus = "queue", + launchPhase = "Queue", + streamSession = session(sessionId = "queued", queuePosition = 4, seatSetupStep = 1), + ), + ), + ) + assertFalse( + replacedSessionTracker.update( + OpenNowUiState( + streamStatus = "connecting", + launchPhase = "Connecting stream", + streamSession = session(sessionId = "different", queuePosition = null, seatSetupStep = 5), + ), + ), + ) + } + + private fun session( + sessionId: String = "session", + queuePosition: Int?, + seatSetupStep: Int?, + ): SessionInfo = + SessionInfo( + sessionId = sessionId, + status = 1, + queuePosition = queuePosition, + seatSetupStep = seatSetupStep, + serverIp = "server", + signalingServer = "server:443", + signalingUrl = "wss://server:443/nvst/", + ) +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/RapidTapTrackerTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/RapidTapTrackerTest.kt new file mode 100644 index 000000000..1b4a1ab3a --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/RapidTapTrackerTest.kt @@ -0,0 +1,29 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class RapidTapTrackerTest { + @Test + fun triggersOnTenthTapWithinWindow() { + val tracker = RapidTapTracker() + + repeat(9) { index -> + assertFalse(tracker.recordTap(index * 500L)) + } + + assertTrue(tracker.recordTap(4_500L)) + assertFalse(tracker.recordTap(5_000L)) + } + + @Test + fun expiredTapsDoNotCountTowardSequence() { + val tracker = RapidTapTracker(requiredTapCount = 3, windowMs = 1_000L) + + assertFalse(tracker.recordTap(0L)) + assertFalse(tracker.recordTap(500L)) + assertFalse(tracker.recordTap(1_400L)) + assertTrue(tracker.recordTap(1_500L)) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/RendererSinkLifecycleTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/RendererSinkLifecycleTest.kt new file mode 100644 index 000000000..5a1085301 --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/RendererSinkLifecycleTest.kt @@ -0,0 +1,30 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class RendererSinkLifecycleTest { + @Test + fun repeatedSurfaceCallbacksQueueOnlyOneAttachAndDetach() { + val lifecycle = RendererSinkLifecycle() + + assertTrue(lifecycle.requestAttach()) + assertFalse(lifecycle.requestAttach()) + assertTrue(lifecycle.isAttachRequested()) + + assertTrue(lifecycle.requestDetach()) + assertFalse(lifecycle.requestDetach()) + assertFalse(lifecycle.isAttachRequested()) + } + + @Test + fun recreatedSurfaceCanAttachAfterDetach() { + val lifecycle = RendererSinkLifecycle() + + assertTrue(lifecycle.requestAttach()) + assertTrue(lifecycle.requestDetach()) + assertTrue(lifecycle.requestAttach()) + assertTrue(lifecycle.isAttachRequested()) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/SdpToolsBitrateTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/SdpToolsBitrateTest.kt new file mode 100644 index 000000000..00b2bdb21 --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/SdpToolsBitrateTest.kt @@ -0,0 +1,86 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Covers the live bitrate ceiling update: replacing b=AS in the video section of the local SDP + * without touching audio, idempotently, preserving line endings and section boundaries. + */ +class SdpToolsBitrateTest { + + private fun videoBitrate(sdp: String): Int? = + Regex("m=video[\\s\\S]*?b=AS:(\\d+)").find(sdp)?.groupValues?.get(1)?.toInt() + + private fun audioBitrate(sdp: String): Int? = + Regex("m=audio[\\s\\S]*?b=AS:(\\d+)").find(sdp)?.groupValues?.get(1)?.toInt() + + @Test + fun replacesVideoBitrateWithoutTouchingAudio() { + val sdp = "v=0\r\n" + + "m=audio 9 UDP/TLS/RTP/SAVPF 111\r\n" + + "b=AS:128\r\n" + + "a=fmtp:111 minptime=10\r\n" + + "m=video 9 UDP/TLS/RTP/SAVPF 96\r\n" + + "b=AS:75000\r\n" + + "a=rtpmap:96 VP8\r\n" + val out = SdpTools.replaceVideoBitrateInSdp(sdp, 50000) + assertEquals(50000, videoBitrate(out)) + assertEquals(128, audioBitrate(out)) + } + + @Test + fun isIdempotentAcrossRepeatedCalls() { + val sdp = "v=0\n" + + "m=video 9 UDP/TLS/RTP/SAVPF 96\n" + + "b=AS:75000\n" + + "m=audio 9 UDP/TLS/RTP/SAVPF 111\n" + + "b=AS:128\n" + val once = SdpTools.replaceVideoBitrateInSdp(sdp, 40000) + val twice = SdpTools.replaceVideoBitrateInSdp(once, 40000) + assertEquals(once, twice) + } + + @Test + fun replacesOnlyTheFirstVideoBitrateLine() { + val sdp = "v=0\n" + + "m=video 9 UDP/TLS/RTP/SAVPF 96\n" + + "b=AS:75000\n" + + "b=AS:99999\n" + val out = SdpTools.replaceVideoBitrateInSdp(sdp, 30000) + assertEquals("v=0\nm=video 9 UDP/TLS/RTP/SAVPF 96\nb=AS:30000\nb=AS:99999\n", out) + } + + @Test + fun preservesLineEndings() { + val sdp = "v=0\r\nm=video 9 UDP/TLS/RTP/SAVPF 96\r\nb=AS:75000\r\n" + assertTrue(SdpTools.replaceVideoBitrateInSdp(sdp, 60000).contains("\r\n")) + } + + @Test + fun leavesSdpWithoutVideoBitrateUntouched() { + val sdp = "v=0\nm=video 9 UDP/TLS/RTP/SAVPF 96\n" + assertEquals(sdp, SdpTools.replaceVideoBitrateInSdp(sdp, 60000)) + } + + @Test + fun updatesEveryVideoSectionButNeverAudio() { + val sdp = "v=0\n" + + "m=video 9 UDP/TLS/RTP/SAVPF 96\n" + + "b=AS:75000\n" + + "m=audio 9 UDP/TLS/RTP/SAVPF 111\n" + + "b=AS:128\n" + + "m=video 9 UDP/TLS/RTP/SAVPF 97\n" + + "b=AS:90000\n" + val out = SdpTools.replaceVideoBitrateInSdp(sdp, 20000) + // Every video section's b=AS is replaced (the flag resets per m= section), audio is untouched. + assertEquals("v=0\n" + + "m=video 9 UDP/TLS/RTP/SAVPF 96\n" + + "b=AS:20000\n" + + "m=audio 9 UDP/TLS/RTP/SAVPF 111\n" + + "b=AS:128\n" + + "m=video 9 UDP/TLS/RTP/SAVPF 97\n" + + "b=AS:20000\n", out) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/SdpToolsTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/SdpToolsTest.kt new file mode 100644 index 000000000..bfeb63c71 --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/SdpToolsTest.kt @@ -0,0 +1,375 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class SdpToolsTest { + @Test + fun partiallyReliableGamepadMaskDefaultsToAllControllerSlots() { + assertEquals(0x0f, SdpTools.parsePartiallyReliableGamepadMask("v=0\n")) + } + + @Test + fun partiallyReliableGamepadMaskParsesDecimalAndHexAttributes() { + assertEquals( + 0x03, + SdpTools.parsePartiallyReliableGamepadMask("a=ri.enablePartiallyReliableTransferGamepad:3\n"), + ) + assertEquals( + 0x0f, + SdpTools.parsePartiallyReliableGamepadMask("a=ri.enablePartiallyReliableTransferGamepad:0x0f\n"), + ) + } + + @Test + fun prefersEightBitH265ProfileForNonHdrAndroidStream() { + val munged = SdpTools.preferCodec(h265Offer(), StreamSettings(codec = VideoCodec.H265, colorQuality = ColorQuality.EightBit420)) + + assertEquals("m=video 9 UDP/TLS/RTP/SAVPF 97 96", munged.lineSequence().first()) + } + + @Test + fun prefersTenBitH265ProfileForHdrAndroidStream() { + val munged = SdpTools.preferCodec( + h265Offer(), + StreamSettings(codec = VideoCodec.H265, colorQuality = ColorQuality.TenBit420, hdrEnabled = true), + ) + + assertEquals("m=video 9 UDP/TLS/RTP/SAVPF 96 97", munged.lineSequence().first()) + } + + @Test + fun rewritesH265TierFlagAndClampsLevelByProfile() { + val offer = """ + m=video 9 UDP/TLS/RTP/SAVPF 96 97 + a=rtpmap:96 H265/90000 + a=fmtp:96 profile-id=1;tier-flag=1;level-id=186 + a=rtpmap:97 H265/90000 + a=fmtp:97 profile-id=2;tier-flag=1;level-id=255 + """.trimIndent() + + val tier = SdpTools.rewriteH265TierFlag(offer, 0) + val level = SdpTools.rewriteH265LevelIdByProfile(tier.sdp, mapOf(1 to 153, 2 to 186)) + + assertEquals(2, tier.replacements) + assertEquals(2, level.replacements) + assertTrue(level.sdp.contains("a=fmtp:96 profile-id=1;tier-flag=0;level-id=153")) + assertTrue(level.sdp.contains("a=fmtp:97 profile-id=2;tier-flag=0;level-id=186")) + } + + @Test + fun detectsNegotiatedVideoCodecInLocalAnswer() { + val answer = """ + m=audio 9 UDP/TLS/RTP/SAVPF 111 + a=rtpmap:111 opus/48000/2 + m=video 9 UDP/TLS/RTP/SAVPF 96 + a=rtpmap:96 HEVC/90000 + """.trimIndent() + + assertTrue(SdpTools.negotiatesCodec(answer, VideoCodec.H265)) + assertFalse(SdpTools.negotiatesCodec(answer, VideoCodec.AV1)) + } + + @Test + fun fixesPlaceholderCandidatesWithSignalingEndpointWhenMediaEndpointIsMissing() { + val offer = """ + v=0 + c=IN IP4 0.0.0.0 + m=video 47998 UDP/TLS/RTP/SAVPF 96 + a=candidate:1 1 udp 2122260223 0.0.0.0 47998 typ host generation 0 + a=rtpmap:96 H264/90000 + """.trimIndent() + + val fixed = SdpTools.fixServerIp( + offer, + serverIp = "66-22-131-132.cloudmatchbeta.nvidiagrid.net", + ) + + assertTrue(fixed.contains("c=IN IP4 66.22.131.132")) + assertTrue(fixed.contains("a=candidate:1 1 udp 2122260223 66.22.131.132 47998 typ host generation 0")) + } + + @Test + fun fixesPlaceholderCandidatesWithCloudMatchMediaEndpoint() { + val offer = """ + v=0 + c=IN IP4 0.0.0.0 + m=video 47998 UDP/TLS/RTP/SAVPF 96 + a=candidate:1 1 udp 2122260223 0.0.0.0 47998 typ host generation 0 + a=rtpmap:96 H264/90000 + """.trimIndent() + + val fixed = SdpTools.fixServerEndpoint( + offer, + serverIp = "183-78-14-231.yes.geforcenow.nvidiagrid.net", + mediaConnectionInfo = MediaConnectionInfo("183-78-14-231.yes.geforcenow.nvidiagrid.net", 19353), + ) + + assertTrue(fixed.contains("c=IN IP4 183.78.14.231")) + assertTrue(fixed.contains("a=candidate:1 1 udp 2122260223 183.78.14.231 19353 typ host generation 0")) + } + + @Test + fun leavesPrivateCandidatesWithoutCloudMatchMediaEndpoint() { + val offer = """ + v=0 + c=IN IP4 10.0.175.0 + m=video 47998 UDP/TLS/RTP/SAVPF 96 + a=candidate:1 1 udp 2122260223 10.0.175.0 47998 typ host generation 0 + a=rtpmap:96 H264/90000 + """.trimIndent() + + val fixed = SdpTools.fixServerIp( + offer, + serverIp = "183-78-14-231.yes.geforcenow.nvidiagrid.net", + ) + + assertEquals(offer, fixed) + } + + @Test + fun fixesPrivateCandidatesWithCloudMatchMediaEndpoint() { + val offer = """ + v=0 + c=IN IP4 10.0.175.0 + m=video 47998 UDP/TLS/RTP/SAVPF 96 + a=candidate:1 1 udp 2122260223 10.0.175.0 47998 typ host generation 0 + a=rtpmap:96 H264/90000 + """.trimIndent() + + val fixed = SdpTools.fixServerEndpoint( + offer, + serverIp = "183-78-14-231.yes.geforcenow.nvidiagrid.net", + mediaConnectionInfo = MediaConnectionInfo("183.78.14.231", 14317), + ) + + assertTrue(fixed.contains("c=IN IP4 183.78.14.231")) + assertTrue(fixed.contains("a=candidate:1 1 udp 2122260223 183.78.14.231 14317 typ host generation 0")) + } + + @Test + fun leavesResolvedCandidatesOnTheirAdvertisedEndpoint() { + val offer = """ + v=0 + c=IN IP4 203.0.113.10 + m=video 47998 UDP/TLS/RTP/SAVPF 96 + a=candidate:1 1 udp 2122260223 203.0.113.10 47998 typ host generation 0 + a=rtpmap:96 H264/90000 + """.trimIndent() + + val fixed = SdpTools.fixServerEndpoint( + offer, + serverIp = "183-78-14-231.yes.geforcenow.nvidiagrid.net", + mediaConnectionInfo = MediaConnectionInfo("183-78-14-231.yes.geforcenow.nvidiagrid.net", 19353), + ) + + assertEquals(offer, fixed) + } + + @Test + fun nvstSdpUsesConfiguredResolutionViewport() { + val nvst = SdpTools.buildNvstSdp( + offerSdp = "a=ri.partialReliableThresholdMs:42", + settings = StreamSettings(resolution = "1680x720", aspectRatio = "21:9", codec = VideoCodec.H265), + localAnswer = """ + a=ice-ufrag:testUfrag + a=ice-pwd:testPassword + a=fingerprint:sha-256 11:22:33 + """.trimIndent(), + ) + + assertTrue(nvst.contains("a=video.clientViewportWd:1680")) + assertTrue(nvst.contains("a=video.clientViewportHt:720")) + assertTrue(nvst.contains("a=vqos.dynamicStreamingMode:0")) + assertTrue(nvst.contains("a=vqos.drc.enable:0")) + assertTrue(nvst.contains("a=vqos.dfc.adjustResAndFps:0")) + assertTrue(nvst.contains("a=vqos.adjustStreamingFpsDuringOutOfFocus:0")) + assertFalse(nvst.contains("a=vqos.adjustStreamingFpsDuringOutOfFocus:1")) + assertTrue(nvst.contains("a=vqos.resControl.cpmRtc.enable:0")) + assertTrue(nvst.contains("a=vqos.resControl.cpmRtc.minResolutionPercent:100")) + assertTrue(nvst.contains("a=vqos.resControl.cpmRtc.resolutionChangeHoldonMs:999999")) + assertTrue(nvst.contains("a=vqos.grc.enable:0")) + assertTrue(nvst.contains("a=video.scalingFeature1:0")) + assertFalse(nvst.contains("a=video.clientViewportWd:1920")) + } + + @Test + fun nvstSdpHonorsConfiguredBitrateBelowTheNormalFourMbpsFloor() { + val nvst = buildNvstSdp(StreamSettings(maxBitrateMbps = 1)) + + assertTrue(nvst.contains("a=video.initialBitrateKbps:1000")) + assertTrue(nvst.contains("a=video.initialPeakBitrateKbps:1000")) + assertTrue(nvst.contains("a=vqos.bw.maximumBitrateKbps:1000")) + assertTrue(nvst.contains("a=vqos.bw.minimumBitrateKbps:1000")) + assertTrue(nvst.contains("a=vqos.bw.peakBitrateKbps:1000")) + assertTrue(nvst.contains("a=vqos.bw.serverPeakBitrateKbps:1000")) + } + + @Test + fun nvstSdpKeepsTheNormalFourMbpsMinimumForHigherBitrateProfiles() { + val nvst = buildNvstSdp(StreamSettings(maxBitrateMbps = 18)) + + assertTrue(nvst.contains("a=vqos.bw.maximumBitrateKbps:18000")) + assertTrue(nvst.contains("a=vqos.bw.minimumBitrateKbps:4000")) + } + + @Test + fun everyResolutionCodecAndSupportedFpsProducesFixedGeometrySdp() { + val modes = STREAM_RESOLUTION_OPTIONS.map { option -> + Triple(option.value, option.aspectRatio, parseResolutionPixels(option.value)) + } + + for ((resolution, aspectRatio, pixels) in modes) { + for (codec in VideoCodec.entries) { + for (fps in listOf(60, 120, 240, 360)) { + val settings = StreamSettings( + resolution = resolution, + aspectRatio = aspectRatio, + fps = fps, + codec = codec, + colorQuality = if (codec == VideoCodec.H264) ColorQuality.EightBit420 else ColorQuality.TenBit420, + ) + val preferred = SdpTools.preferCodec(allCodecOffer(), settings) + val nvst = SdpTools.buildNvstSdp( + offerSdp = preferred, + settings = settings, + localAnswer = """ + a=ice-ufrag:testUfrag + a=ice-pwd:testPassword + a=fingerprint:sha-256 11:22:33 + """.trimIndent(), + ) + + val case = "$resolution $codec ${fps}fps" + assertTrue("$case was not preferred", SdpTools.negotiatesCodec(preferred, codec)) + assertTrue("$case width missing", nvst.contains("a=video.clientViewportWd:${pixels.first}")) + assertTrue("$case height missing", nvst.contains("a=video.clientViewportHt:${pixels.second}")) + assertTrue("$case fps missing", nvst.contains("a=video.maxFPS:$fps")) + if (fps > 60) { + assertTrue("$case FPS estimate missing", nvst.contains("a=vqos.maxStreamFpsEstimate:$fps")) + } + assertTrue("$case scaling must remain disabled", nvst.contains("a=video.scalingFeature1:0")) + assertFalse("$case must not enable scaling", nvst.contains("a=video.scalingFeature1:1")) + } + } + } + } + + @Test + fun nvstSdpDisablesHdrForSdrStream() { + val nvst = buildNvstSdp(StreamSettings(hdrEnabled = false)) + + assertTrue(nvst.contains("a=video.dx9EnableHdr:0")) + assertFalse(nvst.contains("a=video.dx9EnableHdr:1")) + } + + @Test + fun nvstSdpEnablesHdrOnlyForHdrStream() { + val nvst = buildNvstSdp(StreamSettings(codec = VideoCodec.H265, hdrEnabled = true)) + + assertTrue(nvst.contains("a=video.dx9EnableHdr:1")) + assertFalse(nvst.contains("a=video.dx9EnableHdr:0")) + } + + @Test + fun nvstSdpCarriesRequested360FpsEstimate() { + val nvst = SdpTools.buildNvstSdp( + offerSdp = "a=ri.partialReliableThresholdMs:42", + settings = StreamSettings(resolution = "1920x1080", aspectRatio = "16:9", fps = 360, codec = VideoCodec.AV1), + localAnswer = """ + a=ice-ufrag:testUfrag + a=ice-pwd:testPassword + a=fingerprint:sha-256 11:22:33 + """.trimIndent(), + ) + + assertTrue(nvst.contains("a=video.maxFPS:360")) + assertTrue(nvst.contains("a=vqos.maxStreamFpsEstimate:360")) + assertTrue(nvst.contains("a=video.framePacing.mode:2")) + assertTrue(nvst.contains("a=video.framePacing.pid.minTargetFrameTimeUs:2638")) + assertTrue(nvst.contains("a=packetPacing.version:3")) + assertTrue(nvst.contains("a=packetPacing.enableAccurateSleep:1")) + assertTrue(nvst.contains("a=video.videoSplitEncodeStripsPerFrame:3")) + } + + @Test + fun nvstSdpCarriesRequested120FpsEstimate() { + val nvst = buildNvstSdp(StreamSettings(fps = 120, codec = VideoCodec.H265)) + + assertTrue(nvst.contains("a=video.maxFPS:120")) + assertTrue(nvst.contains("a=vqos.maxStreamFpsEstimate:120")) + } + + @Test + fun nvstSdpUsesWideSplitEncodeFor1440p240Av1Only() { + val av1 = buildNvstSdp( + StreamSettings(resolution = "2560x1440", aspectRatio = "16:9", fps = 240, codec = VideoCodec.AV1), + ) + val h265 = buildNvstSdp( + StreamSettings(resolution = "2560x1440", aspectRatio = "16:9", fps = 240, codec = VideoCodec.H265), + ) + + assertTrue(av1.contains("a=video.videoSplitEncodeStripsPerFrame:63")) + assertTrue(h265.contains("a=video.videoSplitEncodeStripsPerFrame:3")) + assertTrue(av1.contains("a=vqos.bllFec.enable:0")) + assertTrue(h265.contains("a=vqos.bllFec.enable:0")) + } + + @Test + fun partiallyReliableAbsoluteMouseRequiresBothNegotiatedHidMasks() { + val absoluteMouseMask = 1 shl InputEncoder.INPUT_MOUSE_ABS + + assertTrue( + SdpTools.supportsPartiallyReliableHidInput( + hidDeviceMask = absoluteMouseMask, + partiallyReliableHidMask = absoluteMouseMask, + inputType = InputEncoder.INPUT_MOUSE_ABS, + ), + ) + assertFalse( + SdpTools.supportsPartiallyReliableHidInput( + hidDeviceMask = absoluteMouseMask, + partiallyReliableHidMask = 0, + inputType = InputEncoder.INPUT_MOUSE_ABS, + ), + ) + } + + private fun buildNvstSdp(settings: StreamSettings): String = + SdpTools.buildNvstSdp( + offerSdp = "a=ri.partialReliableThresholdMs:42", + settings = settings, + localAnswer = """ + a=ice-ufrag:testUfrag + a=ice-pwd:testPassword + a=fingerprint:sha-256 11:22:33 + """.trimIndent(), + ) + + private fun h265Offer(): String = + """ + m=video 9 UDP/TLS/RTP/SAVPF 96 97 98 + a=rtpmap:96 H265/90000 + a=fmtp:96 profile-id=2 + a=rtpmap:97 H265/90000 + a=fmtp:97 profile-id=1 + a=rtpmap:98 H264/90000 + """.trimIndent() + + private fun allCodecOffer(): String = + """ + m=video 9 UDP/TLS/RTP/SAVPF 96 97 98 99 100 101 + a=rtpmap:96 H264/90000 + a=rtpmap:97 rtx/90000 + a=fmtp:97 apt=96 + a=rtpmap:98 H265/90000 + a=fmtp:98 profile-id=2;tier-flag=0;level-id=153 + a=rtpmap:99 rtx/90000 + a=fmtp:99 apt=98 + a=rtpmap:100 AV1/90000 + a=rtpmap:101 rtx/90000 + a=fmtp:101 apt=100 + """.trimIndent() +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/SessionAssignmentTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/SessionAssignmentTest.kt new file mode 100644 index 000000000..e649777d2 --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/SessionAssignmentTest.kt @@ -0,0 +1,67 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class SessionAssignmentTest { + @Test + fun `uses standard zone from assigned session control host`() { + assertEquals( + "NP-WAW-01", + assignedSessionZoneFromControlHost("np-waw-01.cloudmatchbeta.nvidiagrid.net"), + ) + } + + @Test + fun `uses alliance zone from assigned session control host`() { + assertEquals( + "NPA-TKC-IST-01", + assignedSessionZoneFromControlHost("npa-tkc-ist-01.tkc.geforcenow.nvidiagrid.net"), + ) + } + + @Test + fun `ignores transport and untrusted hosts`() { + assertNull(assignedSessionZoneFromControlHost("85-29-33-38.tkc.geforcenow.nvidiagrid.net")) + assertNull(assignedSessionZoneFromControlHost("np-waw-01.example.com")) + assertNull(assignedSessionZoneFromControlHost(null)) + } + + @Test + fun `reported server prefers assignment over request zone`() { + val session = SessionInfo( + sessionId = "session-1", + status = 2, + zone = "NP-LAX-03", + assignedZone = "NP-PDX-01", + serverIp = "203.0.113.10", + signalingServer = "203.0.113.10:443", + signalingUrl = "wss://203.0.113.10:443/nvst/", + ) + + assertEquals("NP-PDX-01", session.reportedServerZone()) + assertEquals("NP-LAX-03", session.copy(assignedZone = null).reportedServerZone()) + } + + @Test + fun `ready session update preserves an earlier assignment when provider omits it`() { + val previous = session(status = 1, assignedZone = "NP-PDX-01") + val readyWithoutAssignment = session(status = 2, assignedZone = null) + + assertEquals( + "NP-PDX-01", + mergeQueueSessionState(previous, readyWithoutAssignment).assignedZone, + ) + } + + private fun session(status: Int, assignedZone: String?): SessionInfo = SessionInfo( + sessionId = "session-1", + status = status, + zone = "NP-LAX-03", + assignedZone = assignedZone, + serverIp = "203.0.113.10", + signalingServer = "203.0.113.10:443", + signalingUrl = "wss://203.0.113.10:443/nvst/", + ) +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/SessionReportTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/SessionReportTest.kt new file mode 100644 index 000000000..fca614957 --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/SessionReportTest.kt @@ -0,0 +1,225 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class SessionReportTest { + @Test + fun healthySessionScoresOneHundred() { + assertEquals( + 100, + sessionQualityScore( + averagePingMs = 25, + packetLossPct = 0.05, + averageJitterMs = 3.0, + averageFps = 60.0, + targetFps = 60, + averageDecodeMs = 4.0, + ), + ) + assertEquals(SessionReportRating.Excellent, sessionReportRating(100)) + } + + @Test + fun poorNetworkProducesLowScore() { + val score = sessionQualityScore( + averagePingMs = 210, + packetLossPct = 6.0, + averageJitterMs = 55.0, + averageFps = 35.0, + targetFps = 60, + averageDecodeMs = 28.0, + ) + + assertTrue(score < 20) + assertEquals(SessionReportRating.Poor, sessionReportRating(score)) + } + + @Test + fun pipelinedDecoderAtTargetThroughputIsNotMarkedPoor() { + assertEquals(StreamQualityLevel.Fair, StreamQuality.decode(23.0, 60, 59.3)) + assertEquals(StreamQualityLevel.Fair, StreamQuality.decode(23.0, 60, 54.1)) + assertEquals(StreamQualityLevel.Poor, StreamQuality.decode(23.0, 60, 35.0)) + } + + @Test + fun accumulatorUsesPacketDeltasAndAddsContextualWifiAdvice() { + val settings = StreamSettings( + resolution = "1920x1080", + fps = 60, + maxBitrateMbps = 50, + codec = VideoCodec.H264, + colorQuality = ColorQuality.EightBit420, + ) + val accumulator = StreamSessionReportAccumulator( + launchProfile = StreamReportLaunchProfile( + gameTitle = "Test Game", + selectedSettings = settings, + eligibleSettings = settings, + initialSettings = settings, + ), + startedAtMs = 1_000L, + ) + + repeat(10) { + accumulator.record( + stats = StreamRuntimeStats( + bitrateKbps = 28_000, + pingMs = 95, + fps = 58, + resolution = "1920x1080", + codec = "H264", + decodeMs = 5.0, + jitterMs = 22.0, + packetLossPct = 99.0, + packetsLostDelta = 2, + packetsReceivedDelta = 98, + ), + network = AndroidRuntimeDiagnosticsSnapshot( + networkKind = AndroidNetworkKind.Wifi, + networkSignalBars = 3, + networkDownstreamKbps = 65_000, + wifiFrequencyMhz = 2_437, + wifiBand = AndroidWifiBand.TwoPointFourGhz, + ), + ) + } + + val report = accumulator.finish(11_000L) + assertNotNull(report) + report!! + assertEquals(10, report.sampleCount) + assertFalse(report.limitedData) + assertEquals(95, report.averagePingMs) + assertEquals(28_000, report.averageBitrateKbps) + assertEquals(2.0, report.packetLossPct ?: -1.0, 0.001) + assertEquals(AndroidWifiBand.TwoPointFourGhz, report.wifiBand) + assertTrue(report.recommendations.any { it.title == "Use 5 GHz or 6 GHz Wi-Fi" }) + assertTrue(report.recommendations.any { it.title == "Reduce packet loss" }) + } + + @Test + fun reportExplainsDeviceServerAndRecoveryDowngrades() { + val selected = StreamSettings( + resolution = "3840x2160", + fps = 120, + maxBitrateMbps = 75, + codec = VideoCodec.H265, + colorQuality = ColorQuality.TenBit420, + hdrEnabled = true, + ) + val eligible = selected.copy(resolution = "2560x1440", fps = 60, hdrEnabled = false) + val initial = eligible.copy(maxBitrateMbps = 35) + val safe = initial.copy(codec = VideoCodec.H264, colorQuality = ColorQuality.EightBit420) + val accumulator = StreamSessionReportAccumulator( + launchProfile = StreamReportLaunchProfile( + gameTitle = "Test Game", + selectedSettings = selected, + eligibleSettings = eligible, + initialSettings = initial, + ), + startedAtMs = 0L, + ) + accumulator.recordRecovery("H265 did not render a first frame", safe) + accumulator.recordActiveMode( + ActiveStreamModeStatus( + requestedResolution = "2560x1440", + displayedResolution = "1920x1080", + serverNegotiatedResolution = "1920x1080", + resolutionSource = StreamResolutionChangeSource.ServerNegotiatedFallback, + safeVideoRecoveryActive = true, + requestedProfile = initial.toActiveStreamTransportProfile(), + transportProfile = safe.toActiveStreamTransportProfile(), + ), + ) + accumulator.record( + StreamRuntimeStats( + bitrateKbps = 20_000, + pingMs = 30, + fps = 60, + resolution = "1920x1080", + codec = "H264", + decodeMs = 4.0, + jitterMs = 3.0, + packetLossPct = 0.0, + ), + ) + + val report = accumulator.finish(5_000L) + assertNotNull(report) + report!! + assertTrue(report.downgrades.any { it.title == "Account or session limit" }) + assertTrue(report.downgrades.any { it.title == "Device compatibility adjustment" }) + assertTrue(report.downgrades.any { it.title == "Safe video recovery" }) + assertTrue(report.downgrades.any { it.title == "Delivered resolution changed" }) + } + + @Test + fun sustainedReceivedVersusDecodedDeficitIsReportedAsDecoderFailure() { + val settings = StreamSettings(resolution = "1920x1080", fps = 60, codec = VideoCodec.H265) + val accumulator = StreamSessionReportAccumulator( + launchProfile = StreamReportLaunchProfile( + gameTitle = "Decoder Test", + selectedSettings = settings, + eligibleSettings = settings, + initialSettings = settings, + ), + startedAtMs = 0L, + ) + + repeat(3) { + accumulator.record( + StreamRuntimeStats( + fps = 34, + receivedFps = 60, + decodedFps = 34, + decodeMs = 38.0, + ), + ) + } + + val finding = requireNotNull( + requireNotNull(accumulator.finish(3_000L)) + .recommendations + .firstOrNull { it.title == "Decoder could not keep up" }, + ) + assertTrue(finding.detail.contains("60.0 FPS")) + assertTrue(finding.detail.contains("34.0 FPS")) + assertTrue(finding.detail.contains("device decode load")) + } + + @Test + fun decoderFailureRemainsVisibleWhenNetworkAlsoHasWarnings() { + val recommendations = buildSessionRecommendations( + averagePingMs = 180, + packetLossPct = 4.0, + averageJitterMs = 35.0, + averageFps = 34.0, + averageDecodeMs = 38.0, + targetFps = 60, + targetBitrateMbps = 75, + averageBitrateKbps = 8_000, + networkKind = AndroidNetworkKind.Wifi, + wifiBand = AndroidWifiBand.TwoPointFourGhz, + estimatedLinkDownstreamKbps = 12_000, + lowestNetworkBars = 1, + averageReceivedFps = 60.0, + averageDecodedFps = 34.0, + decoderOverloadDetected = true, + ) + + assertEquals(4, recommendations.size) + assertEquals("Decoder could not keep up", recommendations.first().title) + } + + @Test + fun wifiFrequencyIsClassifiedWithoutGuessing() { + assertEquals(AndroidWifiBand.TwoPointFourGhz, androidWifiBandForFrequency(2_412)) + assertEquals(AndroidWifiBand.FiveGhz, androidWifiBandForFrequency(5_220)) + assertEquals(AndroidWifiBand.SixGhz, androidWifiBandForFrequency(6_115)) + assertEquals(AndroidWifiBand.Unknown, androidWifiBandForFrequency(null)) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/SessionTimerAnchorStoreTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/SessionTimerAnchorStoreTest.kt new file mode 100644 index 000000000..a7c0e53c4 --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/SessionTimerAnchorStoreTest.kt @@ -0,0 +1,48 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertEquals +import org.junit.Test + +class SessionTimerAnchorStoreTest { + @Test + fun reconnectKeepsPersistedStartForSameSession() { + assertEquals( + 1_000L, + resolveSessionTimerStartedAtMs( + sessionId = "session-a", + persistedSessionId = "session-a", + persistedStartedAtMs = 1_000L, + preferredStartedAtMs = null, + nowMs = 2_000L, + ), + ) + } + + @Test + fun newSessionUsesCurrentTimeInsteadOfPreviousSessionAnchor() { + assertEquals( + 2_000L, + resolveSessionTimerStartedAtMs( + sessionId = "session-b", + persistedSessionId = "session-a", + persistedStartedAtMs = 1_000L, + preferredStartedAtMs = null, + nowMs = 2_000L, + ), + ) + } + + @Test + fun recoveryCanCarryAnExistingInMemoryAnchor() { + assertEquals( + 1_250L, + resolveSessionTimerStartedAtMs( + sessionId = "session-a", + persistedSessionId = null, + persistedStartedAtMs = 0L, + preferredStartedAtMs = 1_250L, + nowMs = 2_000L, + ), + ) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/SettingsPersistenceTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/SettingsPersistenceTest.kt new file mode 100644 index 000000000..a995e7aae --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/SettingsPersistenceTest.kt @@ -0,0 +1,78 @@ +package com.opencloudgaming.opennow + +import kotlinx.serialization.encodeToString +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Settings persistence moved off the caller's thread. The encode is the expensive half, so this + * pins that the payload really is large enough to be worth keeping off the main thread, and that + * the round trip is lossless now that the write is conflated. + */ +class SettingsPersistenceTest { + @Test + fun aRealisticSettingsObjectIsNotACheapEncode() { + val settings = AppSettings( + favoriteGameIds = (1..200).map { "game-$it" }, + localAppPackageNames = (1..40).map { "com.example.app$it" }, + defaultGameVariantIds = (1..200).associate { "game-$it" to "variant-$it" }, + ) + val encoded = OpenNowJson.encodeToString(settings) + // Every favourite tap used to serialize all of this on the main thread. + assertTrue("encoded ${encoded.length} chars", encoded.length > 10_000) + } + + @Test + fun conflatedWritesStillRoundTripTheLatestValue() { + // Only the newest value reaches disk; it must decode back to exactly what was set. + val latest = AppSettings( + favoriteGameIds = listOf("a", "b"), + localAppsCollapsed = true, + hapticsOutput = HapticsOutputPreference.Device, + androidTouch = AndroidTouchSettings( + touchControllerStyle = TouchControllerStyle.Neon, + touchButtonLabels = false, + faceButtonScale = 1.25f, + rightStickScale = 0.85f, + stickKnobScale = 0.58f, + visibleControlGroups = TouchControlGroup.entries.toSet() - TouchControlGroup.Dpad, + extraButtonActions = listOf( + TouchExtraButtonAction.Guide, + TouchExtraButtonAction.RightTrigger, + TouchExtraButtonAction.A, + TouchExtraButtonAction.None, + ), + extraButtonScale = 1.3f, + gyroscopeEnabled = true, + gyroscopeSensitivity = 1.4f, + gyroscopeInvertVertical = true, + ), + ) + val decoded = OpenNowJson.decodeFromString(OpenNowJson.encodeToString(latest)) + assertEquals(latest, decoded) + } + + @Test + fun normalizationIsStableSoRepeatedWritesDoNotOscillate() { + // update() normalizes before storing; a normalize that changed its own output would emit + // forever under a conflated collector. + val once = AppSettings().normalizedForAndroid() + assertEquals(once, once.normalizedForAndroid()) + } + + @Test + fun programmableButtonsNormalizeToFourSafeSlots() { + val normalized = AppSettings( + androidTouch = AndroidTouchSettings( + extraButtonActions = listOf(TouchExtraButtonAction.A), + extraButtonScale = Float.POSITIVE_INFINITY, + ), + ).normalizedForAndroid().androidTouch + + assertEquals(TOUCH_EXTRA_BUTTON_COUNT, normalized.extraButtonActions.size) + assertEquals(TouchExtraButtonAction.A, normalized.extraButtonAction(0)) + assertEquals(TouchExtraButtonAction.None, normalized.extraButtonAction(3)) + assertEquals(AndroidTouchSettings().extraButtonScale, normalized.extraButtonScale, 0.0001f) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/StoreRailTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/StoreRailTest.kt new file mode 100644 index 000000000..57c957b70 --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/StoreRailTest.kt @@ -0,0 +1,209 @@ +package com.opencloudgaming.opennow + +import androidx.compose.ui.unit.dp +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class StoreRailTest { + @Test + fun loadingStoreUsesTheKnownPersonalRailCount() { + val game = GameInfo(id = "game", title = "Game") + + assertEquals(0, StoreStartRailGroups(emptyList(), emptyList(), emptyList()).visibleGroupCount) + assertEquals(1, StoreStartRailGroups(listOf(game), emptyList(), emptyList()).visibleGroupCount) + assertEquals(3, StoreStartRailGroups(listOf(game), listOf(game), listOf(game)).visibleGroupCount) + } + + @Test + fun recommendationSkeletonUsesTheExactSettingsResolvedGridColumns() { + val threeColumnPhone = catalogGridMetrics( + availableWidthDp = 360f, + compact = false, + landscapeLayout = false, + posterSizeScale = 1f, + handheldLayout = true, + ) + val threeColumnCompactPhone = catalogGridMetrics( + availableWidthDp = 360f, + compact = true, + landscapeLayout = false, + posterSizeScale = 1f, + handheldLayout = true, + ) + val fourColumnCompactPhone = catalogGridMetrics( + availableWidthDp = 411f, + compact = true, + landscapeLayout = false, + posterSizeScale = 1f, + handheldLayout = true, + ) + val twoColumnLargeCards = catalogGridMetrics( + availableWidthDp = 411f, + compact = false, + landscapeLayout = false, + posterSizeScale = 1.4f, + handheldLayout = true, + ) + val sixColumnLandscape = catalogGridMetrics( + availableWidthDp = 800f, + compact = false, + landscapeLayout = true, + posterSizeScale = 1f, + handheldLayout = true, + ) + val fiveColumnTv = catalogGridMetrics( + availableWidthDp = 960f, + compact = false, + landscapeLayout = true, + posterSizeScale = 1f, + handheldLayout = false, + ) + + assertEquals(3, threeColumnPhone.columnCount) + assertEquals(3, threeColumnCompactPhone.columnCount) + assertEquals(4, fourColumnCompactPhone.columnCount) + assertEquals(2, twoColumnLargeCards.columnCount) + assertEquals(6, sixColumnLandscape.columnCount) + assertEquals(5, fiveColumnTv.columnCount) + assertEquals(12, catalogSkeletonPlaceholderCount(threeColumnPhone.columnCount, storeLayout = true)) + assertEquals(9, catalogSkeletonPlaceholderCount(threeColumnPhone.columnCount, storeLayout = false)) + } + + @Test + fun phoneLandscapeHeroDoesNotStackScreenPaddingBelowTopBar() { + assertEquals(0.dp, storeScreenTopPadding(controlsInTopBar = true, phoneLandscapeHero = true)) + assertEquals(4.dp, storeScreenTopPadding(controlsInTopBar = true, phoneLandscapeHero = false)) + assertEquals(12.dp, storeScreenTopPadding(controlsInTopBar = false, phoneLandscapeHero = true)) + } + + @Test + fun storeHeroUsesEveryDeviceLayoutAndHonorsHandheldLandscapeOptOut() { + assertTrue(shouldShowStoreHero(tvProfile = false, landscape = false)) + assertTrue(shouldShowStoreHero(tvProfile = false, landscape = true)) + assertFalse(shouldShowStoreHero(tvProfile = false, landscape = true, landscapeEnabled = false)) + assertTrue(shouldShowStoreHero(tvProfile = true, landscape = false)) + assertTrue(shouldShowStoreHero(tvProfile = true, landscape = true)) + assertTrue(shouldShowStoreHero(tvProfile = true, landscape = true, landscapeEnabled = false)) + } + + @Test + fun storeHeroPreservesTheNewGamesAddedProviderOrder() { + val newest = GameInfo(id = "newest", title = "Newest", imageUrl = "poster") + val next = GameInfo(id = "next", title = "Next", screenshotUrl = "wide") + + val result = newlyAddedStoreHeroGames(listOf(newest, next)) + + assertEquals(listOf("newest", "next"), result.map(GameInfo::id)) + } + + @Test + fun storeHeroDeduplicatesVariantsWithoutAddingOtherCatalogGames() { + val steam = GameInfo(id = "same", title = "Game", availableStores = listOf("Steam")) + val epic = steam.copy(availableStores = listOf("Epic Games Store")) + + val result = newlyAddedStoreHeroGames(listOf(steam, epic)) + + assertEquals(1, result.size) + assertEquals("same", result.single().id) + } + + @Test + fun storeHeroDoesNotRepeatGamesAlreadyInPersonalRails() { + val recent = GameInfo(id = "recent", title = "Recent") + val actuallyNew = GameInfo(id = "new", title = "Actually new") + + val result = newlyAddedStoreHeroGames( + games = listOf(recent, actuallyNew), + excludedGames = listOf(recent), + ) + + assertEquals(listOf("new"), result.map(GameInfo::id)) + } + + @Test + fun storeHeroFallsBackToProviderOrderWhenEveryNewGameIsPersonal() { + val newest = GameInfo(id = "newest", title = "Newest") + val next = GameInfo(id = "next", title = "Next") + + val result = newlyAddedStoreHeroGames( + games = listOf(newest, next), + excludedGames = listOf(newest, next), + ) + + assertEquals(listOf("newest", "next"), result.map(GameInfo::id)) + } + + @Test + fun storeHeroShowsSixWeeklyGames() { + val games = (1..7).map { index -> GameInfo(id = "game-$index", title = "Game $index") } + + assertEquals((1..6).map { "game-$it" }, newlyAddedStoreHeroGames(games).map(GameInfo::id)) + } + + @Test + fun catalogImageRequestsWaitDuringScrollUnlessTheImageIsAlreadyVisible() { + assertFalse(shouldStartCatalogImageRequest(requestsPaused = true, imageAlreadyLoaded = false)) + assertTrue(shouldStartCatalogImageRequest(requestsPaused = true, imageAlreadyLoaded = true)) + assertTrue(shouldStartCatalogImageRequest(requestsPaused = false, imageAlreadyLoaded = false)) + } + + @Test + fun storeHeroAnimationStopsWhileTheStoreIsMoving() { + assertTrue(shouldAnimateStoreHero(pageCount = 6, focused = false, reduceMotion = false, storeScrolling = false)) + assertFalse(shouldAnimateStoreHero(pageCount = 6, focused = false, reduceMotion = false, storeScrolling = true)) + assertFalse(shouldAnimateStoreHero(pageCount = 6, focused = true, reduceMotion = false, storeScrolling = false)) + assertFalse(shouldAnimateStoreHero(pageCount = 6, focused = false, reduceMotion = true, storeScrolling = false)) + } + + @Test + fun storeHeroSubtitleOmitsStoreNames() { + val game = GameInfo( + id = "game", + title = "Game", + publisherName = " Publisher ", + availableStores = listOf("Steam", "Epic Games Store"), + ) + val storeOnly = game.copy(publisherName = null) + + assertEquals("Publisher", storeHeroSubtitle(game)) + assertEquals(null, storeHeroSubtitle(storeOnly)) + } + + @Test + fun newGamesAddedFeedIsOnlyAnUnfilteredProviderSort() { + assertTrue(isNewlyAddedCatalogQuery("", NEWLY_ADDED_CATALOG_SORT_ID, emptyList())) + assertTrue(isNewlyAddedCatalogQuery("", "latest", emptyList())) + assertFalse(isNewlyAddedCatalogQuery("halo", NEWLY_ADDED_CATALOG_SORT_ID, emptyList())) + assertFalse(isNewlyAddedCatalogQuery("", NEWLY_ADDED_CATALOG_SORT_ID, listOf("genre-action"))) + } + + @Test + fun wideArtworkRequestsAreBoundedByTheDisplay() { + assertEquals(960, boundedWideImageRequestWidth(networkWidth = 1920, displayWidth = 720)) + assertEquals(1280, boundedWideImageRequestWidth(networkWidth = 1920, displayWidth = 1080)) + assertEquals(1920, boundedWideImageRequestWidth(networkWidth = 1920, displayWidth = 1920)) + } + + @Test + fun slowNetworkArtworkRequestsAreNotUpscaledToTheDisplay() { + assertEquals(960, boundedWideImageRequestWidth(networkWidth = 960, displayWidth = 1440)) + } + + @Test + fun phoneGridArtworkUsesDisplaySizedBuckets() { + assertEquals(256, catalogCardImageRequestWidth(cardWidthPx = 220, tvProfile = false)) + assertEquals(384, catalogCardImageRequestWidth(cardWidthPx = 300, tvProfile = false)) + assertEquals(512, catalogCardImageRequestWidth(cardWidthPx = 420, tvProfile = false)) + assertEquals(640, catalogCardImageRequestWidth(cardWidthPx = 540, tvProfile = false)) + assertEquals(272, catalogCardImageRequestWidth(cardWidthPx = 540, tvProfile = true)) + } + + @Test + fun storeCacheWindowGetsLeanerAsTheFrameDeadlineShrinks() { + assertEquals(0.33f to 0.17f, catalogCacheWindowFractions(60f)) + assertEquals(0.4f to 0.17f, catalogCacheWindowFractions(90f)) + assertEquals(0.25f to 0.08f, catalogCacheWindowFractions(120f)) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/StreamDecoderRecoveryGateTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/StreamDecoderRecoveryGateTest.kt new file mode 100644 index 000000000..656b1b110 --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/StreamDecoderRecoveryGateTest.kt @@ -0,0 +1,50 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class StreamDecoderRecoveryGateTest { + @Test + fun sustainedAdvancedCodecOverloadRequestsOneRecovery() { + val gate = StreamDecoderRecoveryGate(badSamplesBeforeRecovery = 3) + val overloaded = StreamRuntimeStats(receivedFps = 60, decodedFps = 34, decodeMs = 38.0) + + assertFalse(gate.observe(overloaded, requestedFps = 60, advancedCodecActive = true, recoveryEligible = true)) + assertFalse(gate.observe(overloaded, requestedFps = 60, advancedCodecActive = true, recoveryEligible = true)) + assertTrue(gate.observe(overloaded, requestedFps = 60, advancedCodecActive = true, recoveryEligible = true)) + assertFalse(gate.observe(overloaded, requestedFps = 60, advancedCodecActive = true, recoveryEligible = true)) + } + + @Test + fun networkLimitedInputDoesNotBlameDecoder() { + val gate = StreamDecoderRecoveryGate(badSamplesBeforeRecovery = 2) + val networkLimited = StreamRuntimeStats(receivedFps = 18, decodedFps = 18, decodeMs = 40.0) + + repeat(4) { + assertFalse(gate.observe(networkLimited, requestedFps = 60, advancedCodecActive = true, recoveryEligible = true)) + } + } + + @Test + fun rendererDetachmentAndH264NeverTriggerRecovery() { + val gate = StreamDecoderRecoveryGate(badSamplesBeforeRecovery = 2) + val overloaded = StreamRuntimeStats(receivedFps = 60, decodedFps = 30, decodeMs = 40.0) + + repeat(3) { + assertFalse(gate.observe(overloaded, requestedFps = 60, advancedCodecActive = true, recoveryEligible = false)) + assertFalse(gate.observe(overloaded, requestedFps = 60, advancedCodecActive = false, recoveryEligible = true)) + } + } + + @Test + fun healthySampleBreaksTheEvidenceChain() { + val gate = StreamDecoderRecoveryGate(badSamplesBeforeRecovery = 2) + val overloaded = StreamRuntimeStats(receivedFps = 60, decodedFps = 34, decodeMs = 38.0) + val healthy = StreamRuntimeStats(receivedFps = 60, decodedFps = 59, decodeMs = 14.0) + + assertFalse(gate.observe(overloaded, requestedFps = 60, advancedCodecActive = true, recoveryEligible = true)) + assertFalse(gate.observe(healthy, requestedFps = 60, advancedCodecActive = true, recoveryEligible = true)) + assertFalse(gate.observe(overloaded, requestedFps = 60, advancedCodecActive = true, recoveryEligible = true)) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/StreamInputModeChoiceTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/StreamInputModeChoiceTest.kt new file mode 100644 index 000000000..613855b63 --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/StreamInputModeChoiceTest.kt @@ -0,0 +1,68 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class StreamInputModeChoiceTest { + @Test + fun connectedKeyboardOrMouseWinsOnlyAtStreamStart() { + assertEquals( + StreamInputMode.KeyboardMouse, + streamInputModeAtStart(nativeTouchAvailable = true, keyboardMouseConnected = true), + ) + assertEquals( + StreamInputMode.NativeTouch, + streamInputModeAtStart(nativeTouchAvailable = true, keyboardMouseConnected = false), + ) + } + + @Test + fun hotPlugAsksBeforeLeavingNativeTouch() { + assertEquals( + StreamInputModePrompt.SwitchToKeyboardMouse, + streamInputModePromptForConnectionChange( + currentMode = StreamInputMode.NativeTouch, + keyboardMouseConnected = true, + nativeTouchProvisionedForSession = true, + ), + ) + } + + @Test + fun disconnectAsksBeforeReturningToProvisionedNativeTouch() { + assertEquals( + StreamInputModePrompt.SwitchToNativeTouch, + streamInputModePromptForConnectionChange( + currentMode = StreamInputMode.KeyboardMouse, + keyboardMouseConnected = false, + nativeTouchProvisionedForSession = true, + ), + ) + assertNull( + streamInputModePromptForConnectionChange( + currentMode = StreamInputMode.KeyboardMouse, + keyboardMouseConnected = false, + nativeTouchProvisionedForSession = false, + ), + ) + } + + @Test + fun unchangedModeNeedsNoPrompt() { + assertNull( + streamInputModePromptForConnectionChange( + currentMode = StreamInputMode.NativeTouch, + keyboardMouseConnected = false, + nativeTouchProvisionedForSession = true, + ), + ) + assertNull( + streamInputModePromptForConnectionChange( + currentMode = StreamInputMode.KeyboardMouse, + keyboardMouseConnected = true, + nativeTouchProvisionedForSession = true, + ), + ) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/StreamKeyboardBehaviorTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/StreamKeyboardBehaviorTest.kt new file mode 100644 index 000000000..7ac7ec7a1 --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/StreamKeyboardBehaviorTest.kt @@ -0,0 +1,57 @@ +package com.opencloudgaming.opennow + +import android.view.KeyEvent +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class StreamKeyboardBehaviorTest { + @Test + fun emptyNewDraftHasNothingToType() { + assertEquals(StreamKeyboardEdit.None, streamKeyboardEdit(null, "")) + } + + @Test + fun newDraftIsAppendedToTheRemoteField() { + assertEquals(StreamKeyboardEdit.Append("hello"), streamKeyboardEdit(null, "hello")) + } + + @Test + fun unchangedMirroredTextIsNotDuplicated() { + assertEquals(StreamKeyboardEdit.None, streamKeyboardEdit("hello", "hello")) + } + + @Test + fun typingAtTheEndOnlyAppendsTheNewSuffix() { + assertEquals(StreamKeyboardEdit.Append(" there"), streamKeyboardEdit("hello", "hello there")) + } + + @Test + fun deletingAtTheEndUsesBackspace() { + assertEquals(StreamKeyboardEdit.Backspace(2), streamKeyboardEdit("hello", "hel")) + } + + @Test + fun deletingAnEmojiUsesOneRemoteBackspace() { + assertEquals(StreamKeyboardEdit.Backspace(1), streamKeyboardEdit("hello 🙂", "hello ")) + } + + @Test + fun editingInTheMiddleReplacesTheRemoteField() { + assertEquals(StreamKeyboardEdit.Replace("hallo"), streamKeyboardEdit("hello", "hallo")) + } + + @Test + fun hardwareKeyboardRepeatsAreSuppressedAfterInitialKeyDown() { + assertFalse(shouldSuppressHardwareKeyboardRepeat(true, KeyEvent.ACTION_DOWN, repeatCount = 0)) + assertTrue(shouldSuppressHardwareKeyboardRepeat(true, KeyEvent.ACTION_DOWN, repeatCount = 1)) + assertTrue(shouldSuppressHardwareKeyboardRepeat(true, KeyEvent.ACTION_DOWN, repeatCount = 200)) + } + + @Test + fun keyUpAndNonHardwareSourcesAreNotSuppressed() { + assertFalse(shouldSuppressHardwareKeyboardRepeat(true, KeyEvent.ACTION_UP, repeatCount = 1)) + assertFalse(shouldSuppressHardwareKeyboardRepeat(false, KeyEvent.ACTION_DOWN, repeatCount = 1)) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/StreamLivenessWatchdogTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/StreamLivenessWatchdogTest.kt new file mode 100644 index 000000000..f3f50b233 --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/StreamLivenessWatchdogTest.kt @@ -0,0 +1,254 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class StreamLivenessWatchdogTest { + @Test + fun decodedResolutionTrackerAcceptsInitialAndRuntimeModeChanges() { + val tracker = DecodedResolutionTracker() + + val initial = tracker.observe(width = 2560, height = 1440) + val duplicate = tracker.observe(width = 2560, height = 1440) + val uplayModeChange = tracker.observe(width = 1280, height = 720) + + assertEquals(true, initial?.isInitial) + assertEquals(null, duplicate) + assertEquals(false, uplayModeChange?.isInitial) + assertEquals(2560, uplayModeChange?.previousWidth) + assertEquals(1440, uplayModeChange?.previousHeight) + assertEquals(1280, uplayModeChange?.width) + assertEquals(720, uplayModeChange?.height) + } + + @Test + fun decodedResolutionTrackerIgnoresInvalidSizesWithoutLosingCurrentMode() { + val tracker = DecodedResolutionTracker() + tracker.observe(width = 1920, height = 1080) + + assertEquals(null, tracker.observe(width = 0, height = 720)) + val change = tracker.observe(width = 1600, height = 900) + + assertEquals(1920, change?.previousWidth) + assertEquals(1080, change?.previousHeight) + } + + @Test + fun advancedCodecRestartWaitsForDecoderReleaseAfterStablePlayback() { + assertEquals(180L, advancedCodecRestartSettleDelayMs(VideoCodec.AV1, hadStableMedia = true)) + assertEquals(180L, advancedCodecRestartSettleDelayMs(VideoCodec.H265, hadStableMedia = true)) + assertEquals(0L, advancedCodecRestartSettleDelayMs(VideoCodec.H264, hadStableMedia = true)) + assertEquals(0L, advancedCodecRestartSettleDelayMs(VideoCodec.AV1, hadStableMedia = false)) + } + + @Test + fun androidTvAllowsSlowHardwareDecoderStartupBeforeRetry() { + val tv = streamRecoveryTiming(androidTvProfile = true) + val mobile = streamRecoveryTiming(androidTvProfile = false) + + assertEquals(5_000L, tv.keyframeAfterMs) + assertEquals(2_500L, tv.keyframeIntervalMs) + assertEquals(14_000L, tv.restartAfterMs) + assertEquals(5_000L, mobile.keyframeAfterMs) + assertEquals(2_500L, mobile.keyframeIntervalMs) + assertEquals(10_000L, mobile.restartAfterMs) + assertEquals(14_000L, firstVideoFrameRecoveryTimeoutMs(androidTvProfile = true)) + assertEquals(10_000L, firstVideoFrameRecoveryTimeoutMs(androidTvProfile = false)) + } + + @Test + fun firstFrameRecoveryRetriesTheSelectedProfileWithoutChangingIt() { + assertEquals( + FirstFrameRecoveryStep.RetryRequestedProfile, + firstFrameRecoveryStep( + transportHasStableMedia = false, + reconnectAttempts = 0, + selectedProfileRetryApplied = false, + ), + ) + assertEquals( + FirstFrameRecoveryStep.RetrySelectedProfile, + firstFrameRecoveryStep( + transportHasStableMedia = false, + reconnectAttempts = 1, + selectedProfileRetryApplied = false, + ), + ) + assertEquals( + FirstFrameRecoveryStep.ContinueBoundedTransportRecovery, + firstFrameRecoveryStep( + transportHasStableMedia = false, + reconnectAttempts = 2, + selectedProfileRetryApplied = true, + ), + ) + } + + @Test + fun networkTransportRetriesPreserveTheRequestedCodec() { + assertFalse( + transportRestartShouldRetrySelectedProfile( + videoFailure = false, + reconnectAttempts = 1, + transportHasStableMedia = false, + ), + ) + assertTrue( + transportRestartShouldRetrySelectedProfile( + videoFailure = true, + reconnectAttempts = 1, + transportHasStableMedia = false, + ), + ) + } + + @Test + fun repeatedStableAdvancedCodecStallsRetrySelectedProfileOnlyOnTv() { + assertFalse( + repeatedStableMediaStallShouldRetrySelectedProfile( + androidTvProfile = true, + transportCodec = VideoCodec.AV1, + completedStableMediaStallRestarts = 1, + selectedProfileRetryApplied = false, + ), + ) + assertTrue( + repeatedStableMediaStallShouldRetrySelectedProfile( + androidTvProfile = true, + transportCodec = VideoCodec.AV1, + completedStableMediaStallRestarts = 2, + selectedProfileRetryApplied = false, + ), + ) + assertFalse( + repeatedStableMediaStallShouldRetrySelectedProfile( + androidTvProfile = false, + transportCodec = VideoCodec.AV1, + completedStableMediaStallRestarts = 2, + selectedProfileRetryApplied = false, + ), + ) + assertFalse( + repeatedStableMediaStallShouldRetrySelectedProfile( + androidTvProfile = true, + transportCodec = VideoCodec.H264, + completedStableMediaStallRestarts = 2, + selectedProfileRetryApplied = false, + ), + ) + } + + @Test + fun requestsKeyframesBeforeRestartingStalledMedia() { + val watchdog = StreamLivenessWatchdog( + keyframeAfterMs = 1_000L, + keyframeIntervalMs = 500L, + restartAfterMs = 3_000L, + ) + + watchdog.markConnected(0L) + + assertEquals(StreamLivenessAction.None, watchdog.observe(0L, bytesReceived = 10L, framesDecoded = 1L, connected = true)) + + val first = watchdog.observe(1_000L, bytesReceived = 10L, framesDecoded = 1L, connected = true) + assertTrue(first is StreamLivenessAction.RequestKeyframe) + assertEquals(1, (first as StreamLivenessAction.RequestKeyframe).attempt) + + assertEquals(StreamLivenessAction.None, watchdog.observe(1_200L, bytesReceived = 10L, framesDecoded = 1L, connected = true)) + + val second = watchdog.observe(1_500L, bytesReceived = 10L, framesDecoded = 1L, connected = true) + assertTrue(second is StreamLivenessAction.RequestKeyframe) + assertEquals(2, (second as StreamLivenessAction.RequestKeyframe).attempt) + + val restart = watchdog.observe(3_000L, bytesReceived = 10L, framesDecoded = 1L, connected = true) + assertTrue(restart is StreamLivenessAction.RestartTransport) + } + + @Test + fun progressClearsPendingStallRecovery() { + val watchdog = StreamLivenessWatchdog( + keyframeAfterMs = 1_000L, + keyframeIntervalMs = 500L, + restartAfterMs = 3_000L, + ) + + watchdog.markConnected(0L) + assertEquals(StreamLivenessAction.None, watchdog.observe(0L, bytesReceived = 10L, framesDecoded = 1L, connected = true)) + assertTrue(watchdog.observe(1_000L, bytesReceived = 10L, framesDecoded = 1L, connected = true) is StreamLivenessAction.RequestKeyframe) + assertEquals(StreamLivenessAction.None, watchdog.observe(1_200L, bytesReceived = 11L, framesDecoded = 2L, connected = true)) + assertEquals(StreamLivenessAction.None, watchdog.observe(1_900L, bytesReceived = 11L, framesDecoded = 2L, connected = true)) + } + + @Test + fun slowButProgressingPlaybackDoesNotRestartTheTransport() { + val watchdog = StreamLivenessWatchdog( + keyframeAfterMs = 1_000L, + keyframeIntervalMs = 500L, + restartAfterMs = 3_000L, + ) + + watchdog.markConnected(0L) + repeat(10) { sample -> + assertEquals( + StreamLivenessAction.None, + watchdog.observe( + nowMs = sample * 1_000L, + bytesReceived = (sample + 1) * 1_000L, + framesDecoded = (sample + 1).toLong(), + connected = true, + ), + ) + } + } + + @Test + fun incomingBytesDoNotHideDecoderFrameStall() { + val watchdog = StreamLivenessWatchdog( + keyframeAfterMs = 1_000L, + keyframeIntervalMs = 500L, + restartAfterMs = 3_000L, + ) + + watchdog.markConnected(0L) + assertEquals(StreamLivenessAction.None, watchdog.observe(100L, bytesReceived = 10L, framesDecoded = 0L, connected = true)) + assertEquals(StreamLivenessAction.None, watchdog.observe(900L, bytesReceived = 100L, framesDecoded = 0L, connected = true)) + + val first = watchdog.observe(1_000L, bytesReceived = 200L, framesDecoded = 0L, connected = true) + assertTrue(first is StreamLivenessAction.RequestKeyframe) + } + + @Test + fun fallsBackToBytesWhenFrameCounterIsMissing() { + val watchdog = StreamLivenessWatchdog( + keyframeAfterMs = 1_000L, + keyframeIntervalMs = 500L, + restartAfterMs = 3_000L, + ) + + watchdog.markConnected(0L) + assertEquals(StreamLivenessAction.None, watchdog.observe(900L, bytesReceived = 10L, framesDecoded = null, connected = true)) + assertEquals(StreamLivenessAction.None, watchdog.observe(1_700L, bytesReceived = 20L, framesDecoded = null, connected = true)) + assertEquals(StreamLivenessAction.None, watchdog.observe(2_500L, bytesReceived = 30L, framesDecoded = null, connected = true)) + } + + @Test + fun reportsMediaProgressSeparatelyFromTransportConnectivity() { + val watchdog = StreamLivenessWatchdog( + keyframeAfterMs = 1_000L, + keyframeIntervalMs = 500L, + restartAfterMs = 3_000L, + ) + + watchdog.markConnected(0L) + watchdog.observe(100L, bytesReceived = 10L, framesDecoded = 0L, connected = true) + assertEquals(false, watchdog.latestObservationProgressed) + + watchdog.observe(200L, bytesReceived = 20L, framesDecoded = 1L, connected = true) + assertEquals(true, watchdog.latestObservationProgressed) + + watchdog.observe(300L, bytesReceived = 30L, framesDecoded = 1L, connected = true) + assertEquals(false, watchdog.latestObservationProgressed) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/StreamNetworkWarningTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/StreamNetworkWarningTest.kt new file mode 100644 index 000000000..1c861dc97 --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/StreamNetworkWarningTest.kt @@ -0,0 +1,83 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class StreamNetworkWarningTest { + @Test + fun warnsFromMeasuredPacketDeltasWithConciseInternetMessage() { + val warning = streamNetworkWarning( + stats = StreamRuntimeStats( + receivedFps = 70, + packetLossPct = 3.25, + packetsLostDelta = 13, + packetsReceivedDelta = 387, + ), + ) + + requireNotNull(warning) + assertEquals("loss", warning.key) + assertEquals( + "3.25% packet loss. You may experience lag due to your internet connection.", + warning.message, + ) + } + + @Test + fun ignoresLossPercentageWithoutUsableCounterDelta() { + assertNull( + streamNetworkWarning( + stats = StreamRuntimeStats(packetLossPct = 9.0), + ), + ) + } + + @Test + fun latencyUsesTheSameConciseInternetMessage() { + val warning = streamNetworkWarning( + stats = StreamRuntimeStats(pingMs = 150), + ) + + requireNotNull(warning) + assertEquals( + "150 ms latency. You may experience lag due to your internet connection.", + warning.message, + ) + } + + @Test + fun decoderOnlySlowdownDoesNotBlameTheConnection() { + assertNull( + streamNetworkWarning( + stats = StreamRuntimeStats(receivedFps = 120, decodedFps = 30, decodeMs = 35.0), + ), + ) + } + + @Test + fun sustainedPoorMeasurementsShowOnlyOncePerStreamSession() { + val gate = StreamNetworkWarningGate(minimumConsecutiveSamples = 3) + val warning = StreamNetworkWarning("latency", "150 ms latency") + + assertNull(gate.update(warning)) + assertNull(gate.update(warning)) + assertEquals(warning, gate.update(warning)) + assertNull(gate.update(warning)) + assertNull(gate.update(null)) + assertNull(gate.update(warning)) + assertNull(gate.update(warning)) + assertNull(gate.update(warning)) + } + + @Test + fun healthySampleResetsConsecutiveWarningCount() { + val gate = StreamNetworkWarningGate(minimumConsecutiveSamples = 2) + val warning = StreamNetworkWarning("jitter", "40 ms jitter") + + assertNull(gate.update(warning)) + assertNull(gate.update(null)) + assertNull(gate.update(warning)) + assertEquals(warning, gate.update(warning)) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/StreamPacketLossRecoveryGateTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/StreamPacketLossRecoveryGateTest.kt new file mode 100644 index 000000000..c7e1890fa --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/StreamPacketLossRecoveryGateTest.kt @@ -0,0 +1,54 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class StreamPacketLossRecoveryGateTest { + @Test + fun misleadingDisplayedSpikeDoesNotArmRecovery() { + val gate = StreamPacketLossRecoveryGate(badSamplesBeforeArmed = 2) + val misleading = stats(lost = 2, received = 141, displayedLoss = 11.221449851042701) + + repeat(4) { assertFalse(gate.observe(misleading, recoveryEligible = true)) } + assertFalse(gate.observe(stats(lost = 0, received = 500), recoveryEligible = true)) + } + + @Test + fun transientLossDoesNotRequestKeyframe() { + val gate = StreamPacketLossRecoveryGate(badSamplesBeforeArmed = 2) + + assertFalse(gate.observe(stats(lost = 60, received = 940), recoveryEligible = true)) + assertFalse(gate.observe(stats(lost = 0, received = 1_000), recoveryEligible = true)) + } + + @Test + fun sustainedLossRequestsOneKeyframeAfterPathRecovers() { + val gate = StreamPacketLossRecoveryGate(badSamplesBeforeArmed = 2, cooldownSamples = 3) + val bad = stats(lost = 60, received = 940) + val good = stats(lost = 0, received = 1_000) + + assertFalse(gate.observe(bad, recoveryEligible = true)) + assertFalse(gate.observe(bad, recoveryEligible = true)) + assertTrue(gate.observe(good, recoveryEligible = true)) + assertFalse(gate.observe(good, recoveryEligible = true)) + } + + @Test + fun ineligibleTransportClearsArmedRecovery() { + val gate = StreamPacketLossRecoveryGate(badSamplesBeforeArmed = 2) + val bad = stats(lost = 60, received = 940) + + assertFalse(gate.observe(bad, recoveryEligible = true)) + assertFalse(gate.observe(bad, recoveryEligible = true)) + assertFalse(gate.observe(bad, recoveryEligible = false)) + assertFalse(gate.observe(stats(lost = 0, received = 1_000), recoveryEligible = true)) + } + + private fun stats(lost: Long, received: Long, displayedLoss: Double = 0.0): StreamRuntimeStats = + StreamRuntimeStats( + packetLossPct = displayedLoss, + packetsLostDelta = lost, + packetsReceivedDelta = received, + ) +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/StreamPacketLossTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/StreamPacketLossTest.kt new file mode 100644 index 000000000..41e996c28 --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/StreamPacketLossTest.kt @@ -0,0 +1,50 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class StreamPacketLossTest { + @Test + fun delayedStatsCallbacksAreRejected() { + assertEquals(true, isNewerStreamStatsSample(currentTimestampMs = 2_000.0, previousTimestampMs = 1_000.0)) + assertEquals(false, isNewerStreamStatsSample(currentTimestampMs = 1_000.0, previousTimestampMs = 1_000.0)) + assertEquals(false, isNewerStreamStatsSample(currentTimestampMs = 999.0, previousTimestampMs = 1_000.0)) + assertEquals(false, isNewerStreamStatsSample(currentTimestampMs = Double.NaN, previousTimestampMs = 1_000.0)) + } + + @Test + fun counterResetDoesNotBecomeAFalseLossSample() { + assertNull( + streamPacketDelta( + currentLost = 0, + currentReceived = 20, + previousLost = 4, + previousReceived = 1_000, + ), + ) + } + + @Test + fun rollingWindowDoesNotPublishAOneSampleFiftyPercentSpike() { + val window = StreamPacketLossWindow(maximumSamples = 5, minimumSamples = 3) + + assertNull(window.add(StreamPacketDelta(lost = 1, received = 1))) + assertNull(window.add(StreamPacketDelta(lost = 0, received = 600))) + assertEquals( + 1.0 / 1_202.0 * 100.0, + window.add(StreamPacketDelta(lost = 0, received = 600)) ?: -1.0, + 0.0001, + ) + } + + @Test + fun rollingWindowKeepsOnlyRecentSamples() { + val window = StreamPacketLossWindow(maximumSamples = 3, minimumSamples = 1) + + window.add(StreamPacketDelta(lost = 3, received = 0)) + window.add(StreamPacketDelta(lost = 0, received = 100)) + window.add(StreamPacketDelta(lost = 0, received = 100)) + assertEquals(0.0, window.add(StreamPacketDelta(lost = 0, received = 100)) ?: -1.0, 0.0001) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/StreamPointForTouchTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/StreamPointForTouchTest.kt new file mode 100644 index 000000000..1404d416d --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/StreamPointForTouchTest.kt @@ -0,0 +1,224 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Test + +/** + * Direct click has no absolute-positioning packet to lean on — the wire format only carries + * relative motion — so it maps a touch into stream space and sends the delta from a shadow copy of + * the host's cursor. That makes [streamPointForTouch] the whole feature's correctness surface, and + * one property in particular has already regressed once in this file's history: a view resize (PiP, + * rotation, minimise) must change nothing about where a proportionally-equivalent touch lands, + * because the host's cursor does not move when our window does. + */ +class StreamPointForTouchTest { + + private fun map( + touchX: Float, + touchY: Float, + viewWidth: Int, + viewHeight: Int, + streamWidth: Int = 1920, + streamHeight: Int = 1080, + stretchToFit: Boolean = false, + renderingAspectRatio: Float = 0f, + presentationZoomScale: Float = 1f, + presentationTranslationX: Float = 0f, + presentationTranslationY: Float = 0f, + ) = streamPointForTouch( + touchX = touchX, + touchY = touchY, + viewWidth = viewWidth, + viewHeight = viewHeight, + streamWidth = streamWidth, + streamHeight = streamHeight, + stretchToFit = stretchToFit, + renderingAspectRatio = renderingAspectRatio, + presentationZoomScale = presentationZoomScale, + presentationTranslationX = presentationTranslationX, + presentationTranslationY = presentationTranslationY, + ) + + @Test + fun matchingAspectRatioMapsProportionally() { + // 16:9 view onto a 16:9 stream — no bars, so it is a straight scale. + val point = map(touchX = 480f, touchY = 270f, viewWidth = 1280, viewHeight = 720) + assertEquals(720f, point.x, 0.01f) + assertEquals(405f, point.y, 0.01f) + } + + @Test + fun pillarboxedViewDiscountsTheSideBars() { + // 2:1 view, 16:9 stream: the video is 1280x720 centred in 1440x720, bars 80px each side. + val centre = map(touchX = 720f, touchY = 360f, viewWidth = 1440, viewHeight = 720) + assertEquals(960f, centre.x, 0.01f) + assertEquals(540f, centre.y, 0.01f) + + // The left edge of the *video*, not of the view. + val videoLeftEdge = map(touchX = 80f, touchY = 360f, viewWidth = 1440, viewHeight = 720) + assertEquals(0f, videoLeftEdge.x, 0.01f) + } + + @Test + fun letterboxedViewDiscountsTheTopAndBottomBars() { + // 4:3 view, 16:9 stream: video is 960x540 centred in 960x720, bars 90px top and bottom. + val centre = map(touchX = 480f, touchY = 360f, viewWidth = 960, viewHeight = 720) + assertEquals(960f, centre.x, 0.01f) + assertEquals(540f, centre.y, 0.01f) + + val videoTopEdge = map(touchX = 480f, touchY = 90f, viewWidth = 960, viewHeight = 720) + assertEquals(0f, videoTopEdge.y, 0.01f) + } + + @Test + fun touchesOnTheBarsClampIntoTheStream() { + val onLeftBar = map(touchX = 10f, touchY = 360f, viewWidth = 1440, viewHeight = 720) + assertEquals(0f, onLeftBar.x, 0.01f) + + val onRightBar = map(touchX = 1430f, touchY = 360f, viewWidth = 1440, viewHeight = 720) + assertEquals(1920f, onRightBar.x, 0.01f) + } + + @Test + fun stretchToFitIgnoresAspectRatioEntirely() { + // Same 4:3 view as the letterbox case, but stretched: no bars to discount. + val point = map(touchX = 480f, touchY = 360f, viewWidth = 960, viewHeight = 720, stretchToFit = true) + assertEquals(960f, point.x, 0.01f) + assertEquals(540f, point.y, 0.01f) + + val topEdge = map(touchX = 480f, touchY = 0f, viewWidth = 960, viewHeight = 720, stretchToFit = true) + assertEquals(0f, topEdge.y, 0.01f) + } + + @Test + fun renderingAspectRatioOverridesTheResolutionRatio() { + // An ultrawide 21:9 render inside a 16:9 stream buffer: the bars follow what is actually + // rendered, not what the resolution string implies. + val point = map( + touchX = 640f, + touchY = 360f, + viewWidth = 1280, + viewHeight = 720, + renderingAspectRatio = 21f / 9f, + ) + // Video is 1280x548.57 centred in 1280x720, so the view centre sits at the video centre. + assertEquals(960f, point.x, 0.01f) + assertEquals(540f, point.y, 0.5f) + } + + @Test + fun changedUltrawideDecodedFrameRemainsCenteredInsideTheViewport() { + // A provider can change the decoded 1376x640 frame to 1376x590 without changing the + // selected viewport. The real content is still centred and input follows its actual bars. + val decodedAspectRatio = 1376f / 590f + val videoHeight = 1920f / decodedAspectRatio + val videoTop = (1080f - videoHeight) / 2f + + val topCentre = map( + touchX = 960f, + touchY = videoTop, + viewWidth = 1920, + viewHeight = 1080, + streamWidth = 1376, + streamHeight = 590, + renderingAspectRatio = decodedAspectRatio, + ) + val centre = map( + touchX = 960f, + touchY = 540f, + viewWidth = 1920, + viewHeight = 1080, + streamWidth = 1376, + streamHeight = 590, + renderingAspectRatio = decodedAspectRatio, + ) + + assertEquals(688f, topCentre.x, 0.01f) + assertEquals(0f, topCentre.y, 0.01f) + assertEquals(688f, centre.x, 0.01f) + assertEquals(295f, centre.y, 0.01f) + } + + @Test + fun pinchZoomAndPanAreInvertedBeforeStreamMapping() { + // The unzoomed view point (480, 270) is displayed at (420, 140) after a 2x zoom plus + // translation (100, -40). Tapping that displayed point must still target the same pixel. + val point = map( + touchX = 420f, + touchY = 140f, + viewWidth = 1280, + viewHeight = 720, + presentationZoomScale = 2f, + presentationTranslationX = 100f, + presentationTranslationY = -40f, + ) + + assertEquals(720f, point.x, 0.01f) + assertEquals(405f, point.y, 0.01f) + } + + @Test + fun stretchAndZoomMapTheVisibleFilledSurface() { + val point = map( + touchX = 240f, + touchY = 180f, + viewWidth = 960, + viewHeight = 720, + stretchToFit = true, + presentationZoomScale = 2f, + ) + + assertEquals(720f, point.x, 0.01f) + assertEquals(405f, point.y, 0.01f) + } + + /** + * The PiP regression, stated as a property. A window resize must not move where an equivalent + * touch lands — that is what lets the cursor shadow survive PiP untouched. + */ + @Test + fun resizingTheViewDoesNotMoveWhereAnEquivalentTouchLands() { + val fullscreen = map(touchX = 1152f, touchY = 576f, viewWidth = 1280, viewHeight = 720) + // Same fraction of a PiP-sized window: 90% across, 80% down. + val pip = map(touchX = 288f, touchY = 144f, viewWidth = 320, viewHeight = 180) + + assertEquals(fullscreen.x, pip.x, 0.01f) + assertEquals(fullscreen.y, pip.y, 0.01f) + } + + @Test + fun degenerateSizesDoNotDivideByZero() { + assertEquals(StreamPoint(0f, 0f), map(touchX = 10f, touchY = 10f, viewWidth = 0, viewHeight = 720)) + assertEquals(StreamPoint(0f, 0f), map(touchX = 10f, touchY = 10f, viewWidth = 1280, viewHeight = 0)) + assertEquals( + StreamPoint(0f, 0f), + map(touchX = 10f, touchY = 10f, viewWidth = 1280, viewHeight = 720, streamWidth = 0), + ) + } + + @Test + fun nonFiniteMotionEventsAreRejectedBeforeCursorRounding() { + val nanX = map(touchX = Float.NaN, touchY = 100f, viewWidth = 1280, viewHeight = 720) + val infiniteY = map(touchX = 100f, touchY = Float.POSITIVE_INFINITY, viewWidth = 1280, viewHeight = 720) + + assertFalse(nanX.x.isFinite()) + assertFalse(nanX.y.isFinite()) + assertFalse(infiniteY.x.isFinite()) + assertFalse(infiniteY.y.isFinite()) + } + + @Test + fun nonFiniteRendererAspectFallsBackToTheStreamDimensions() { + val normal = map(touchX = 640f, touchY = 360f, viewWidth = 1280, viewHeight = 720) + val invalid = map( + touchX = 640f, + touchY = 360f, + viewWidth = 1280, + viewHeight = 720, + renderingAspectRatio = Float.POSITIVE_INFINITY, + ) + + assertEquals(normal, invalid) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/StreamResolutionTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/StreamResolutionTest.kt new file mode 100644 index 000000000..33135f9cd --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/StreamResolutionTest.kt @@ -0,0 +1,966 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class StreamResolutionTest { + @Test + fun smartSessionLimitsMatchMembershipTier() { + val free = smartSessionLimitFor(SubscriptionInfo(membershipTier = "FREE"), null) + val performance = smartSessionLimitFor(SubscriptionInfo(membershipTier = "PERFORMANCE"), null) + val ultimate = smartSessionLimitFor(SubscriptionInfo(membershipTier = "ULTIMATE"), null) + + assertEquals(1, free.limitHours) + assertEquals(SessionTimerMode.Countdown, free.mode) + assertEquals(6, performance.limitHours) + assertEquals(SessionTimerMode.Stopwatch, performance.mode) + assertEquals(8, ultimate.limitHours) + assertEquals(SessionTimerMode.Stopwatch, ultimate.mode) + } + + @Test + fun paidMonthlyUsageFallsBackToHundredHourLimit() { + val subscription = SubscriptionInfo(membershipTier = "ULTIMATE", usedHours = 37.25) + + assertEquals(100.0, monthlyHourLimitFor(subscription, null) ?: 0.0, 0.001) + assertEquals(62.75, monthlyHoursRemainingFor(subscription, null) ?: 0.0, 0.001) + } + + @Test + fun sessionWarningsFireAtMostRelevantCrossedThreshold() { + assertEquals(null, sessionWarningThresholdCrossed(null, 30 * 60)) + assertEquals(30 * 60, sessionWarningThresholdCrossed(30 * 60 + 1, 30 * 60)) + assertEquals(5 * 60, sessionWarningThresholdCrossed(10 * 60 + 1, 5 * 60 - 1)) + assertEquals(null, sessionWarningThresholdCrossed(5 * 60, 5 * 60 - 1)) + } + + @Test + fun streamResolutionPixelsKeepsSelected1080pFor16By9() { + val settings = StreamSettings(resolution = "1920x1080", aspectRatio = "16:9") + + assertEquals(1920 to 1080, streamResolutionPixels(settings)) + } + + @Test + fun runtimeResolutionMismatchIsDiagnosticForServerFallbackModes() { + assertEquals( + StreamResolutionMismatch(actualResolution = "1152x720", expectedResolution = "1280x720"), + streamRuntimeResolutionMismatch( + StreamSettings(resolution = "1280x720", aspectRatio = "16:9"), + "1152x720", + ), + ) + assertEquals( + StreamResolutionMismatch(actualResolution = "1366x768", expectedResolution = "1680x720"), + streamRuntimeResolutionMismatch( + StreamSettings(resolution = "1680x720", aspectRatio = "21:9"), + "1366x768", + ), + ) + } + + @Test + fun runtimeGameResolutionChangeRemainsDiagnosticOnly() { + val mismatch = streamRuntimeResolutionMismatch( + StreamSettings(resolution = "1920x1080", aspectRatio = "16:9"), + actualResolution = "1280x720", + serverNegotiatedResolution = "1920x1080", + ) + + assertEquals( + StreamResolutionMismatch( + actualResolution = "1280x720", + expectedResolution = "1920x1080", + ), + mismatch, + ) + assertEquals(false, mismatch?.isServerNegotiatedFallback) + } + + @Test + fun runtimeResolutionMismatchMarksServerNegotiatedFallback() { + val mismatch = streamRuntimeResolutionMismatch( + StreamSettings(resolution = "1680x720", aspectRatio = "21:9"), + actualResolution = "1366x768", + serverNegotiatedResolution = "1366x768", + ) + + assertEquals( + StreamResolutionMismatch( + actualResolution = "1366x768", + expectedResolution = "1680x720", + serverNegotiatedResolution = "1366x768", + ), + mismatch, + ) + assertEquals(true, mismatch?.isServerNegotiatedFallback) + } + + @Test + fun runtimeResolutionMismatchIgnoresExactAndMissingStats() { + val settings = StreamSettings(resolution = "1680x720", aspectRatio = "21:9") + + assertEquals(null, streamRuntimeResolutionMismatch(settings, null)) + assertEquals(null, streamRuntimeResolutionMismatch(settings, "1680x720")) + assertEquals(null, streamRuntimeResolutionMismatch(settings, "2x2")) + } + + @Test + fun activeStreamModeSeparatesServerFallbackFromLaterProviderOrGameChange() { + val requested = StreamSettings( + resolution = "1680x720", + aspectRatio = "21:9", + codec = VideoCodec.AV1, + ) + + val serverFallback = activeStreamModeStatus( + requestedSettings = requested, + transportSettings = requested, + decodedResolution = "1366x768", + serverNegotiatedResolution = "1366x768", + ) + val laterModeChange = activeStreamModeStatus( + requestedSettings = requested, + transportSettings = requested, + decodedResolution = "1230x768", + serverNegotiatedResolution = "1366x768", + ) + val finalServerMode = activeStreamModeStatus( + requestedSettings = requested, + transportSettings = requested, + decodedResolution = "1230x768", + serverNegotiatedResolution = "1366x768", + serverFinalSelectedResolution = "1230x768", + ) + + assertEquals(StreamResolutionChangeSource.ServerNegotiatedFallback, serverFallback?.resolutionSource) + assertEquals("1366x768", serverFallback?.displayedResolution) + assertEquals(false, serverFallback?.safeVideoRecoveryActive) + assertEquals( + ActiveStreamModeDisplayChange("Resolution", "1680x720", "1366x768", ActiveStreamModeChangeKind.Resolution), + serverFallback?.let(::activeStreamModeDisplayChanges)?.first(), + ) + assertEquals(StreamResolutionChangeSource.ProviderOrGameModeChange, laterModeChange?.resolutionSource) + assertEquals("1230x768", laterModeChange?.displayedResolution) + assertEquals(false, laterModeChange?.safeVideoRecoveryActive) + assertEquals(StreamResolutionChangeSource.ServerNegotiatedFallback, finalServerMode?.resolutionSource) + assertEquals("1230x768", finalServerMode?.serverFinalSelectedResolution) + } + + @Test + fun activeStreamModeWaitsForDecodedVideoBeforeReportingProvisionalServerFallback() { + val requested = StreamSettings(resolution = "1376x590", aspectRatio = "21:9") + + assertEquals( + null, + activeStreamModeStatus( + requestedSettings = requested, + transportSettings = requested, + decodedResolution = null, + serverNegotiatedResolution = "1680x720", + ), + ) + assertEquals( + null, + activeStreamModeStatus( + requestedSettings = requested, + transportSettings = requested, + decodedResolution = "1376x590", + serverNegotiatedResolution = "1680x720", + ), + ) + } + + @Test + fun activeStreamModeSurfacesClientSafeRecoveryWithoutInventingResolutionChange() { + val requested = StreamSettings( + resolution = "3840x2160", + aspectRatio = "16:9", + fps = 120, + maxBitrateMbps = 150, + codec = VideoCodec.AV1, + colorQuality = ColorQuality.TenBit420, + ) + val recovery = requested.androidSafeVideoFallback() + + val status = activeStreamModeStatus( + requestedSettings = requested, + transportSettings = recovery, + decodedResolution = "3840x2160", + serverNegotiatedResolution = "3840x2160", + ) + + assertEquals(null, status?.resolutionSource) + assertEquals(true, status?.safeVideoRecoveryActive) + assertEquals(VideoCodec.H264, status?.transportCodec) + assertEquals("3840x2160", status?.requestedResolution) + assertEquals("3840x2160", status?.displayedResolution) + assertEquals( + listOf("Codec", "FPS", "Color"), + status?.let(::activeStreamModeDisplayChanges)?.map { it.label }, + ) + assertEquals( + ActiveStreamModeDisplayChange("Codec", "AV1", "H264", ActiveStreamModeChangeKind.Codec), + status?.let(::activeStreamModeDisplayChanges)?.first(), + ) + assertFalse(status?.let(::activeStreamModeDisplayChanges).orEmpty().any { it.label == "Resolution" }) + val reason = "AV1 was requested but WebRTC did not negotiate it; restarting with safe H264 profile" + assertEquals( + "WebRTC could not negotiate the requested AV1 codec for this connection, so OpenNOW retried the local video transport with H264.", + status?.let { activeStreamModeCauseAssessment(it, reason).summary }, + ) + val report = status?.let { activeStreamModeDeveloperReport(it, reason) } + assertEquals("Automatic stream change: Codec AV1 to H264", report?.title) + assertTrue(report?.description.orEmpty().contains("- Codec: AV1 -> H264")) + assertTrue(report?.description.orEmpty().contains("Recorded recovery event:")) + } + + @Test + fun activeStreamModeExplainsServerSelectedResolution() { + val requested = StreamSettings(resolution = "1680x720", aspectRatio = "21:9") + val status = requireNotNull( + activeStreamModeStatus( + requestedSettings = requested, + transportSettings = requested, + decodedResolution = "1366x768", + serverNegotiatedResolution = "1366x768", + ), + ) + + assertEquals( + "The cloud server selected 1366x768 instead of the requested 1680x720. This was a server/session negotiation decision, not a change to your saved setting.", + activeStreamModeCauseAssessment(status, null).summary, + ) + } + + @Test + fun streamRendererAspectRatioUsesSelectedResolution() { + val settings = StreamSettings(resolution = "1680x720", aspectRatio = "21:9") + + assertEquals(1680f / 720f, streamRendererAspectRatio(settings), 0.0001f) + } + + @Test + fun fixedSizeSurfaceIsDisabledWhenDecodedFrameReachesViewportBoundary() { + assertFalse( + shouldUseFixedSizeStreamSurface( + videoWidth = 2560, + videoHeight = 1440, + rotation = 0, + viewWidth = 2994, + viewHeight = 1440, + ), + ) + assertFalse( + shouldUseFixedSizeStreamSurface( + videoWidth = 2560, + videoHeight = 1440, + rotation = 0, + viewWidth = 2340, + viewHeight = 1080, + ), + ) + } + + @Test + fun streamRendererUsesAuthoritativeServerFallbackAspectRatio() { + val settings = StreamSettings(resolution = "5120x2160", aspectRatio = "21:9") + + assertEquals( + 1920f / 1080f, + streamRendererAspectRatio( + settings = settings, + decodedResolution = "1920x1080", + serverNegotiatedResolution = "1920x1080", + ), + 0.0001f, + ) + } + + @Test + fun fixedSizeSurfaceRemainsEnabledForSmallerDecodedFrames() { + assertTrue( + shouldUseFixedSizeStreamSurface( + videoWidth = 1920, + videoHeight = 1080, + rotation = 0, + viewWidth = 2560, + viewHeight = 1440, + ), + ) + assertTrue( + shouldUseFixedSizeStreamSurface( + videoWidth = 1080, + videoHeight = 1920, + rotation = 90, + viewWidth = 2560, + viewHeight = 1440, + ), + ) + } + + @Test + fun transientDecodedGeometryDoesNotResizeSelectedViewport() { + val settings = StreamSettings(resolution = "5120x2160", aspectRatio = "21:9") + + assertEquals( + 5120f / 2160f, + streamRendererAspectRatio( + settings = settings, + decodedResolution = "1920x1080", + serverNegotiatedResolution = "2560x1080", + ), + 0.0001f, + ) + } + + @Test + fun a56NegotiatedUltrawideFallbackKeepsItsSourceAspectRatio() { + val settings = StreamSettings(resolution = "5120x2160", aspectRatio = "21:9") + + assertEquals( + 2560f / 1080f, + streamRendererAspectRatio( + settings = settings, + decodedResolution = "2560x1080", + serverNegotiatedResolution = "2560x1080", + ), + 0.0001f, + ) + } + + @Test + fun widePhoneStretchScalesOnlyWidthWithoutCropping() { + val scale = streamStretchScale( + enabled = true, + viewportAspectRatio = 2400f / 1080f, + streamAspectRatio = 1280f / 720f, + ) + + assertEquals(1.25f, scale.first, 0.0001f) + assertEquals(1f, scale.second, 0.0001f) + } + + @Test + fun stretchUsesDecodedAspectWhenGameChangesItsOutputMode() { + val selectedAspect = 1920f / 1080f + val decodedAspect = streamStretchContentAspectRatio( + selectedAspectRatio = selectedAspect, + decodedResolution = "1728x1080", + ) + val scale = streamStretchScale( + enabled = true, + viewportAspectRatio = selectedAspect, + streamAspectRatio = decodedAspect, + ) + + assertEquals(1.6f, decodedAspect, 0.0001f) + assertEquals(10f / 9f, scale.first, 0.0001f) + assertEquals(1f, scale.second, 0.0001f) + } + + @Test + fun stretchFallsBackToSelectedAspectUntilDecodedModeIsKnown() { + val selectedAspect = 16f / 9f + + assertEquals( + selectedAspect, + streamStretchContentAspectRatio(selectedAspect, decodedResolution = null), + 0.0001f, + ) + assertEquals( + selectedAspect, + streamStretchContentAspectRatio(selectedAspect, decodedResolution = "invalid"), + 0.0001f, + ) + } + + @Test + fun pinchZoomIsDisabledWhileTouchControllerIsVisible() { + assertFalse( + streamPinchZoomEnabled( + touchMouseEnabled = true, + touchControllerVisible = true, + ), + ) + assertTrue( + streamPinchZoomEnabled( + touchMouseEnabled = true, + touchControllerVisible = false, + ), + ) + assertFalse( + streamPinchZoomEnabled( + touchMouseEnabled = false, + touchControllerVisible = false, + ), + ) + } + + @Test + fun streamResolutionPixelsMaps1080pTierToUltrawideMode() { + val settings = StreamSettings(resolution = "1920x1080", aspectRatio = "21:9") + + assertEquals(2560 to 1080, streamResolutionPixels(settings)) + } + + @Test + fun streamResolutionPixelsMaps1080pTierToNineteenPointFiveByNinePhoneMode() { + val settings = StreamSettings(resolution = "1920x1080", aspectRatio = "19.5:9") + + assertEquals(2340 to 1080, streamResolutionPixels(settings)) + assertEquals( + "2340x1080", + normalizeStreamResolutionForAspect("1920x1080", "19.5:9"), + ) + } + + @Test + fun persistedUnsupportedAspectFallsBackToSupportedSixteenByNineMode() { + val adjusted = StreamSettings(resolution = "1600x720", aspectRatio = "20:9") + .withResolutionAllowed(SubscriptionInfo(membershipTier = "FREE"), null) + + assertEquals("16:9", adjusted.aspectRatio) + assertEquals("1280x720", adjusted.resolution) + } + + @Test + fun freePlanResolutionNormalizationKeepsFreeUltrawide() { + val freeSubscription = SubscriptionInfo(membershipTier = "FREE") + + assertEquals( + "1680x720", + normalizeStreamResolutionForAspectAndPlan("1920x1080", "21:9", freeSubscription, null), + ) + val adjusted = StreamSettings(resolution = "2560x1080", aspectRatio = "21:9") + .withResolutionAllowed(freeSubscription, null) + assertEquals("1680x720", adjusted.resolution) + assertEquals("21:9", adjusted.aspectRatio) + + val selectedWhd = StreamSettings(resolution = "1680x720", aspectRatio = "21:9") + .withResolutionAllowed(freeSubscription, null) + assertEquals("1680x720", selectedWhd.resolution) + assertEquals("21:9", selectedWhd.aspectRatio) + + val selectedLegacyPortalMode = StreamSettings(resolution = "1376x640", aspectRatio = "19.5:9") + .withResolutionAllowed(freeSubscription, null) + assertEquals("1376x590", selectedLegacyPortalMode.resolution) + assertEquals("21:9", selectedLegacyPortalMode.aspectRatio) + } + + @Test + fun customResolutionPixelsArePreservedForLaunch() { + val settings = StreamSettings(resolution = "1728x720", aspectRatio = "21:9") + + assertEquals(1728 to 720, streamResolutionPixels(settings)) + } + + @Test + fun freePlanPreservesCustomUltrawideResolutionInsidePixelBounds() { + val adjusted = StreamSettings(resolution = "1728x720", aspectRatio = "21:9") + .withResolutionAllowed(SubscriptionInfo(membershipTier = "FREE"), null) + + assertEquals("1728x720", adjusted.resolution) + assertEquals("21:9", adjusted.aspectRatio) + } + + @Test + fun freePlanClampsUltimateUltrawideToFreeUltrawide() { + val adjusted = StreamSettings(resolution = "5120x2160", aspectRatio = "21:9") + .withResolutionAllowed(SubscriptionInfo(membershipTier = "FREE"), null) + + assertEquals("1680x720", adjusted.resolution) + assertEquals("21:9", adjusted.aspectRatio) + } + + @Test + fun freePlanResolutionGuardFallsBackWhenAspectHasNoAvailableMode() { + val adjusted = StreamSettings(resolution = "3840x1080", aspectRatio = "32:9") + .withResolutionAllowed(SubscriptionInfo(membershipTier = "FREE"), null) + + assertEquals("1920x1080", adjusted.resolution) + assertEquals("16:9", adjusted.aspectRatio) + } + + @Test + fun streamResolutionPixelsMaps1440pTierToUltrawideMode() { + val settings = StreamSettings(resolution = "2560x1440", aspectRatio = "21:9") + + assertEquals(3440 to 1440, streamResolutionPixels(settings)) + } + + @Test + fun streamResolutionPixelsKeepsSelected4kFor16By9() { + val settings = StreamSettings(resolution = "3840x2160", aspectRatio = "16:9") + + assertEquals(3840 to 2160, streamResolutionPixels(settings)) + } + + @Test + fun streamResolutionPixelsMapsSelectedTierForTallerAspectRatio() { + val settings = StreamSettings(resolution = "1920x1080", aspectRatio = "16:10") + + assertEquals(1920 to 1200, streamResolutionPixels(settings)) + } + + @Test + fun streamResolutionPixelsKeepsExactStoredUltrawideMode() { + val settings = StreamSettings(resolution = "3440x1440", aspectRatio = "21:9") + + assertEquals(3440 to 1440, streamResolutionPixels(settings)) + } + + @Test + fun legacyPortalGeometryMigratesToProviderCompatibleTwentyOneByNineMode() { + val settings = StreamSettings(resolution = "1376x640", aspectRatio = "19.5:9") + + val migrated = settings.withAndroidSettingsAvailability() + assertEquals("1376x590", migrated.resolution) + assertEquals("21:9", migrated.aspectRatio) + assertEquals(1376 to 590, streamResolutionPixels(migrated)) + } + + @Test + fun streamResolutionOptionsIncludeAndroidSupportedModes() { + assertEquals( + listOf("1280x720", "1366x768", "1600x900", "1920x1080", "2560x1440", "3840x2160", "5120x2880"), + streamResolutionOptionsForAspect("16:9"), + ) + assertEquals(listOf("1024x768", "1112x834", "1600x1200"), streamResolutionOptionsForAspect("4:3")) + assertEquals(listOf("1280x1024"), streamResolutionOptionsForAspect("5:4")) + assertEquals(listOf("2340x1080"), streamResolutionOptionsForAspect("19.5:9")) + assertEquals(emptyList(), streamResolutionOptionsForAspect("20:9")) + assertEquals(listOf("1376x590", "1680x720", "2560x1080", "3440x1440", "5120x2160"), streamResolutionOptionsForAspect("21:9")) + assertEquals(listOf("3840x1080", "5120x1440"), streamResolutionOptionsForAspect("32:9")) + } + + @Test + fun streamResolutionPixelsMaps4kTierToSupported16By10Mode() { + val settings = StreamSettings(resolution = "3840x2160", aspectRatio = "16:10") + + assertEquals(3456 to 2160, streamResolutionPixels(settings)) + } + + @Test + fun streamResolutionChoicesGatePriorityAndUltimateModes() { + val freeSubscription = SubscriptionInfo(membershipTier = "FREE") + val prioritySubscription = SubscriptionInfo(membershipTier = "PRIORITY") + val ultimateSubscription = SubscriptionInfo(membershipTier = "ULTIMATE") + val fhd = streamResolutionChoicesForAspect("16:9").first { it.value == "1920x1080" } + val phoneFhd = streamResolutionChoicesForAspect("19.5:9").single() + val lowUltrawide = streamResolutionChoicesForAspect("21:9").first { it.value == "1376x590" } + val whd = streamResolutionChoicesForAspect("21:9").first { it.value == "1680x720" } + val wfhd = streamResolutionChoicesForAspect("21:9").first { it.value == "2560x1080" } + val qhd = streamResolutionChoicesForAspect("16:9").first { it.value == "2560x1440" } + val fourK = streamResolutionChoicesForAspect("16:9").first { it.value == "3840x2160" } + val fiveK = streamResolutionChoicesForAspect("16:9").first { it.value == "5120x2880" } + + assertEquals(true, fhd.isAvailableFor(freeSubscription, null)) + assertEquals(false, phoneFhd.isAvailableFor(freeSubscription, null)) + assertEquals(true, lowUltrawide.isAvailableFor(freeSubscription, null)) + assertEquals(true, whd.isAvailableFor(freeSubscription, null)) + assertEquals(false, wfhd.isAvailableFor(freeSubscription, null)) + assertEquals(false, qhd.isAvailableFor(freeSubscription, null)) + assertEquals(true, fhd.isAvailableFor(prioritySubscription, null)) + assertEquals(true, phoneFhd.isAvailableFor(prioritySubscription, null)) + assertEquals(true, lowUltrawide.isAvailableFor(prioritySubscription, null)) + assertEquals(true, whd.isAvailableFor(prioritySubscription, null)) + assertEquals(true, wfhd.isAvailableFor(prioritySubscription, null)) + assertEquals(true, qhd.isAvailableFor(prioritySubscription, null)) + assertEquals(false, fourK.isAvailableFor(prioritySubscription, null)) + assertEquals(false, fiveK.isAvailableFor(prioritySubscription, null)) + assertEquals(true, fourK.isAvailableFor(ultimateSubscription, null)) + assertEquals(true, fiveK.isAvailableFor(ultimateSubscription, null)) + } + + @Test + fun streamFpsCapsFollowMembershipPlan() { + val requested = StreamSettings(fps = 360) + + assertEquals(60, requested.withFpsAllowed(SubscriptionInfo(membershipTier = "FREE"), null).fps) + assertEquals(60, requested.withFpsAllowed(SubscriptionInfo(membershipTier = "PERFORMANCE"), null).fps) + assertEquals(360, requested.withFpsAllowed(SubscriptionInfo(membershipTier = "ULTIMATE"), null).fps) + assertEquals(360, maxStreamFpsFor(null, "ULTIMATE")) + } + + @Test + fun fpsPlanCapsDoNotChangeSelectedResolution() { + val freeRequested = StreamSettings(resolution = "1920x1200", aspectRatio = "16:10", fps = 240) + val ultimateRequested = StreamSettings(resolution = "3840x2160", aspectRatio = "16:9", fps = 360) + + assertEquals( + freeRequested.copy(fps = 60), + freeRequested.withFpsAllowed(SubscriptionInfo(membershipTier = "FREE"), null), + ) + assertEquals( + ultimateRequested, + ultimateRequested.withFpsAllowed(SubscriptionInfo(membershipTier = "ULTIMATE"), null), + ) + } + + @Test + fun fpsPlanCapsPreserveEveryKnownResolutionAndCodec() { + val freeSubscription = SubscriptionInfo(membershipTier = "FREE") + val ultimateSubscription = SubscriptionInfo(membershipTier = "ULTIMATE") + + STREAM_RESOLUTION_OPTIONS.forEach { option -> + VideoCodec.entries.forEach { codec -> + val requested = StreamSettings( + resolution = option.value, + aspectRatio = option.aspectRatio, + fps = 360, + codec = codec, + ) + + assertEquals( + "Free ${option.value} $codec", + requested.copy(fps = 60), + requested.withFpsAllowed(freeSubscription, null), + ) + assertEquals( + "Ultimate ${option.value} $codec", + requested, + requested.withFpsAllowed(ultimateSubscription, null), + ) + } + } + } + + @Test + fun hdrIsAvailableForPerformanceAndUltimatePlans() { + val requested = StreamSettings(codec = VideoCodec.H265, hdrEnabled = true) + + assertEquals(false, requested.withHdrAllowed(SubscriptionInfo(membershipTier = "FREE"), null).hdrEnabled) + assertEquals(true, requested.withHdrAllowed(SubscriptionInfo(membershipTier = "PERFORMANCE"), null).hdrEnabled) + assertEquals(true, requested.withHdrAllowed(SubscriptionInfo(membershipTier = "PRIORITY"), null).hdrEnabled) + assertEquals(true, requested.withHdrAllowed(SubscriptionInfo(membershipTier = "ULTIMATE"), null).hdrEnabled) + assertEquals(true, hasHdrStreamingPlan(null, "PERFORMANCE")) + } + + @Test + fun androidHandheldDisablesHdrButPreservesTenBitSdr() { + val adjusted = StreamSettings( + resolution = "1920x1080", + fps = 60, + codec = VideoCodec.H265, + colorQuality = ColorQuality.TenBit420, + hdrEnabled = true, + ).withAndroidHdrCompatibility(androidTvProfile = false) + + assertEquals(false, adjusted.hdrEnabled) + assertEquals(ColorQuality.TenBit420, adjusted.colorQuality) + } + + @Test + fun androidTvHdrRequiresShieldClassH265Mode() { + val supported = StreamSettings( + resolution = "3840x2160", + fps = 60, + codec = VideoCodec.H265, + colorQuality = ColorQuality.TenBit420, + hdrEnabled = true, + ) + + assertEquals(true, supported.hdrAvailableForAndroid(androidTvProfile = true)) + assertEquals(false, supported.copy(fps = 120).hdrAvailableForAndroid(androidTvProfile = true)) + assertEquals(false, supported.copy(resolution = "5120x2880").hdrAvailableForAndroid(androidTvProfile = true)) + assertEquals(false, supported.copy(codec = VideoCodec.H264).hdrAvailableForAndroid(androidTvProfile = true)) + } + + @Test + fun authenticatedUltimateTierWinsWhenSubscriptionPayloadDefaultsToFree() { + val incompleteSubscription = SubscriptionInfo(membershipTier = "FREE") + val fiveK = streamResolutionChoicesForAspect("16:9").first { it.value == "5120x2880" } + val requested = StreamSettings(resolution = "5120x2880", aspectRatio = "16:9", fps = 360) + + assertEquals(true, fiveK.isAvailableFor(incompleteSubscription, "ULTIMATE")) + assertEquals(360, maxStreamFpsFor(incompleteSubscription, "ULTIMATE")) + assertEquals( + requested, + requested + .withResolutionAllowed(incompleteSubscription, "ULTIMATE") + .withFpsAllowed(incompleteSubscription, "ULTIMATE"), + ) + } + + @Test + fun entitledResolutionDoesNotBypassMembershipPlanGate() { + val subscription = SubscriptionInfo( + membershipTier = "FREE", + entitledResolutions = listOf(EntitledResolution(width = 3840, height = 2160, fps = 60)), + ) + val fourK = streamResolutionChoicesForAspect("16:9").first { it.value == "3840x2160" } + + assertEquals(false, fourK.isAvailableFor(subscription, null)) + } + + @Test + fun activeSessionRejectsUnexpectedResolutionBeforeReuse() { + val settings = StreamSettings(resolution = "1680x720", aspectRatio = "21:9", fps = 60) + val stale = activeSession( + resolution = "1680x1050", + fps = 60, + settingsSignature = streamSettingsSessionSignature(settings), + ) + + assertEquals(false, stale.matchesStreamSettings(settings)) + } + + @Test + fun activeSessionRejectsServerFallbackResolutionBeforeReuse() { + val ultrawide = StreamSettings(resolution = "1680x720", aspectRatio = "21:9", fps = 60) + val hd = StreamSettings(resolution = "1280x720", aspectRatio = "16:9", fps = 60) + + assertEquals( + false, + activeSession( + resolution = "1366x768", + fps = 60, + settingsSignature = streamSettingsSessionSignature(ultrawide), + ).matchesStreamSettings(ultrawide), + ) + assertEquals( + false, + activeSession( + resolution = "1280x720", + fps = 60, + settingsSignature = streamSettingsSessionSignature(ultrawide), + ).matchesStreamSettings(ultrawide), + ) + assertEquals( + false, + activeSession( + resolution = "1152x720", + fps = 60, + settingsSignature = streamSettingsSessionSignature(hd), + ).matchesStreamSettings(hd), + ) + } + + @Test + fun activeSessionMatchesRequestedUltrawideResolutionBeforeReuse() { + val settings = StreamSettings(resolution = "1680x720", aspectRatio = "21:9", fps = 60) + val active = activeSession( + resolution = "1680x720", + fps = 60, + settingsSignature = streamSettingsSessionSignature(settings), + ) + + assertEquals(true, active.matchesStreamSettings(settings)) + } + + @Test + fun activeSessionWithoutOpenNowSettingsSignatureIsNotReusedForLaunch() { + val settings = StreamSettings(resolution = "1680x720", aspectRatio = "21:9", fps = 60) + val active = activeSession(resolution = "1680x720", fps = 60) + + assertEquals(false, active.matchesStreamSettings(settings)) + } + + @Test + fun activeSessionWithDifferentOpenNowSettingsSignatureIsNotReusedForLaunch() { + val settings = StreamSettings(resolution = "1680x720", aspectRatio = "21:9", fps = 60, codec = VideoCodec.H265, maxBitrateMbps = 150) + val otherSettings = settings.copy(codec = VideoCodec.H264, maxBitrateMbps = 75) + val active = activeSession( + resolution = "1680x720", + fps = 60, + settingsSignature = streamSettingsSessionSignature(otherSettings), + ) + + assertEquals(false, active.matchesStreamSettings(settings)) + } + + @Test + fun recoveryReclaimsExactUltrawideSessionAfterLocalCodecFallback() { + val original = StreamSettings( + resolution = "1680x720", + aspectRatio = "21:9", + fps = 60, + codec = VideoCodec.AV1, + ) + val safeFallback = original.androidSafeVideoFallback() + val running = activeSession( + sessionId = "running-session", + resolution = "1680x720", + fps = 60, + settingsSignature = streamSettingsSessionSignature(original), + ) + + assertEquals(false, running.matchesStreamSettings(safeFallback)) + assertEquals( + "running-session", + activeSessionRecoveryCandidate( + sessions = listOf(running), + previousSessionId = "running-session", + launchAppId = running.appId, + settings = safeFallback, + )?.sessionId, + ) + } + + @Test + fun recoveryReconstructsKnownFreshSessionWithoutCachedActiveGeometry() { + val settings = StreamSettings( + resolution = "1376x590", + aspectRatio = "21:9", + fps = 60, + maxBitrateMbps = 7, + ) + val running = SessionInfo( + sessionId = "running-session", + status = 2, + streamingBaseUrl = "https://alliance.example", + serverIp = "streamer.example", + signalingServer = "streamer.example:443", + signalingUrl = "wss://streamer.example:443/nvst/", + ) + + val candidate = knownSessionRecoveryCandidate( + session = running, + appId = 101808711, + fallbackActive = null, + settings = settings, + ) + + assertEquals("running-session", candidate?.sessionId) + assertEquals("1376x590", candidate?.resolution) + assertEquals(60, candidate?.fps) + assertEquals(streamSettingsSessionSignature(settings), candidate?.settingsSignature) + assertTrue(candidate?.matchesStreamGeometry(settings) == true) + } + + @Test + fun recoveryDoesNotReclaimExactSessionWithDifferentGeometry() { + val settings = StreamSettings(resolution = "1680x720", aspectRatio = "21:9", fps = 60) + val stale = activeSession( + sessionId = "running-session", + resolution = "1920x1080", + fps = 60, + settingsSignature = streamSettingsSessionSignature(settings), + ) + + assertEquals( + null, + activeSessionRecoveryCandidate( + sessions = listOf(stale), + previousSessionId = "running-session", + launchAppId = null, + settings = settings, + ), + ) + } + + @Test + fun recoveryKeepsStrictSignatureMatchingForOtherSessions() { + val original = StreamSettings( + resolution = "1680x720", + aspectRatio = "21:9", + fps = 60, + codec = VideoCodec.AV1, + ) + val safeFallback = original.androidSafeVideoFallback() + val otherSession = activeSession( + sessionId = "other-session", + resolution = "1680x720", + fps = 60, + settingsSignature = streamSettingsSessionSignature(original), + ) + + assertEquals( + null, + activeSessionRecoveryCandidate( + sessions = listOf(otherSession), + previousSessionId = "previous-session", + launchAppId = otherSession.appId, + settings = safeFallback, + ), + ) + } + + @Test + fun activeSessionWithUnknownMonitorModeIsNotReusedForLaunch() { + val settings = StreamSettings(resolution = "1680x720", aspectRatio = "21:9", fps = 60) + val active = activeSession( + resolution = null, + fps = null, + settingsSignature = streamSettingsSessionSignature(settings), + ) + + assertEquals(false, active.matchesStreamSettings(settings)) + } + + @Test + fun activeSessionWithUnknownRefreshRateIsNotReusedForLaunch() { + val settings = StreamSettings(resolution = "1680x720", aspectRatio = "21:9", fps = 60) + val active = activeSession( + resolution = "1680x720", + fps = null, + settingsSignature = streamSettingsSessionSignature(settings), + ) + + assertEquals(false, active.matchesStreamSettings(settings)) + } + + @Test + fun activeSessionConflictPrefersSameRequestedAppBeforePolling() { + val settings = StreamSettings(resolution = "1680x720", aspectRatio = "21:9", fps = 60) + val otherReady = activeSession( + sessionId = "other-ready", + appId = 200, + resolution = "1680x720", + fps = 60, + settingsSignature = streamSettingsSessionSignature(settings), + ) + val sameAppLaunching = activeSession( + sessionId = "same-launching", + appId = 100, + status = 1, + resolution = "1680x720", + fps = 60, + settingsSignature = streamSettingsSessionSignature(settings), + ) + + assertEquals( + "same-launching", + activeSessionLaunchConflict(listOf(otherReady, sameAppLaunching), launchAppId = 100, settings = settings)?.sessionId, + ) + } + + @Test + fun activeSessionConflictStillReturnsMismatchedExistingSessionForUserChoice() { + val settings = StreamSettings(resolution = "1680x720", aspectRatio = "21:9", fps = 60) + val mismatched = activeSession( + sessionId = "mismatched", + appId = 100, + resolution = "1920x1080", + fps = 60, + settingsSignature = streamSettingsSessionSignature(StreamSettings(resolution = "1920x1080", aspectRatio = "16:9", fps = 60)), + ) + + assertEquals( + "mismatched", + activeSessionLaunchConflict(listOf(mismatched), launchAppId = 100, settings = settings)?.sessionId, + ) + } + + private fun activeSession( + sessionId: String = "session", + appId: Int = 100, + status: Int = 2, + resolution: String?, + fps: Int?, + settingsSignature: String? = null, + ): ActiveSessionInfo = + ActiveSessionInfo( + sessionId = sessionId, + appId = appId, + status = status, + serverIp = "127.0.0.1", + signalingUrl = "wss://127.0.0.1/nvst/", + resolution = resolution, + fps = fps, + settingsSignature = settingsSignature, + ) +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/StreamSessionRecoveryTrackerTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/StreamSessionRecoveryTrackerTest.kt new file mode 100644 index 000000000..c633da326 --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/StreamSessionRecoveryTrackerTest.kt @@ -0,0 +1,125 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class StreamSessionRecoveryTrackerTest { + @Test + fun recoveryAttemptsAreTrackedPerSession() { + val tracker = StreamSessionRecoveryTracker() + + assertEquals(1, tracker.nextAttempt("session-a")) + assertEquals(2, tracker.nextAttempt("session-a")) + assertEquals(1, tracker.nextAttempt("session-b")) + assertEquals(2, tracker.nextAttempt("session-b")) + } + + @Test + fun resetStartsRecoveryBudgetOver() { + val tracker = StreamSessionRecoveryTracker() + + tracker.nextAttempt("session-a") + tracker.reset() + + assertEquals(1, tracker.nextAttempt("session-a")) + } + + @Test + fun repeatedRecoveryNeverAuthorizesAReplacementSession() { + assertEquals( + StreamSessionRecoveryDisposition.ReclaimAllocatedSession, + streamSessionRecoveryDisposition(recoveryAttempt = 1, probedStatus = null), + ) + assertEquals( + StreamSessionRecoveryDisposition.ReclaimAllocatedSession, + streamSessionRecoveryDisposition(recoveryAttempt = 2, probedStatus = 2), + ) + assertEquals( + StreamSessionRecoveryDisposition.ReclaimAllocatedSession, + streamSessionRecoveryDisposition(recoveryAttempt = 20, probedStatus = 6), + ) + } + + @Test + fun providerEndedSessionIsReportedInsteadOfAutomaticallyReplaced() { + assertEquals( + StreamSessionRecoveryDisposition.ReportEndedSession, + streamSessionRecoveryDisposition(recoveryAttempt = 1, probedStatus = 7), + ) + assertEquals( + StreamSessionRecoveryDisposition.ReportEndedSession, + streamSessionRecoveryDisposition(recoveryAttempt = 5, probedStatus = 4), + ) + } + + @Test + fun directSessionHostsAreNotReusedForSessionCreation() { + assertTrue(isLikelyDirectSessionServerUrl("https://66.22.139.37")) + assertTrue(isLikelyDirectSessionServerUrl("https://66-22-139-37.cloudmatchbeta.nvidiagrid.net")) + assertFalse(isLikelyDirectSessionServerUrl("https://np-bom-01.cloudmatchbeta.nvidiagrid.net")) + assertFalse(isLikelyDirectSessionServerUrl("https://prod.cloudmatchbeta.nvidiagrid.net")) + } + + @Test + fun statusSevenIsTerminalWhileCleanupStatusCanStillProgress() { + assertFalse(isTerminalSessionStatus(0)) + assertFalse(isTerminalSessionStatus(1)) + assertFalse(isTerminalSessionStatus(2)) + assertFalse(isTerminalSessionStatus(3)) + assertFalse(isTerminalSessionStatus(6)) + assertTrue(isTerminalSessionStatus(4)) + assertTrue(isTerminalSessionStatus(5)) + assertTrue(isTerminalSessionStatus(7)) + } + + @Test + fun exactSessionProbePreservesProviderTerminationCodesInDebugEvents() { + val summary = recoverySessionProbeDebugSummary( + GfnSessionDiagnosticResponse( + operation = "session.recovery.probe", + method = "GET", + url = "https://streamer.example/v2/session/1234567890abcdef", + statusCode = 200, + requestBody = "", + responseBody = """ + { + "session": { "status": 7, "errorCode": 42 }, + "requestStatus": { + "statusCode": 1, + "statusDescription": "SUCCESS_STATUS", + "unifiedErrorCode": -1970536448 + } + } + """.trimIndent(), + ), + ) + + assertEquals( + "Old session GET session=123456...cdef source=session.recovery.probe http=200 requestStatus=1 " + + "description=SUCCESS_STATUS unifiedError=-1970536448 sessionStatus=7 sessionError=42", + summary, + ) + } + + @Test + fun exactSessionProbeStillRecordsAnHttpFailureWithoutJson() { + val summary = recoverySessionProbeDebugSummary( + GfnSessionDiagnosticResponse( + operation = "session.recovery.probe", + method = "GET", + url = "https://streamer.example/v2/session/session-a", + statusCode = 404, + requestBody = "", + responseBody = "Not Found", + ), + ) + + assertEquals( + "Old session GET session=session-a source=session.recovery.probe http=404 requestStatus=unknown description=unknown " + + "unifiedError=unknown sessionStatus=unknown sessionError=unknown", + summary, + ) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/StreamSettingsDeviceAdjustmentTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/StreamSettingsDeviceAdjustmentTest.kt new file mode 100644 index 000000000..e68b681dc --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/StreamSettingsDeviceAdjustmentTest.kt @@ -0,0 +1,1094 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class StreamSettingsDeviceAdjustmentTest { + @Test + fun preservesSelectedH265WhenWebRtcHardwareDecoderExists() { + val adjusted = StreamSettings(codec = VideoCodec.H265, colorQuality = ColorQuality.TenBit420) + .adjustedForDevice( + codecReport( + VideoCodec.H265, + hardwareDecoder = true, + realtimeSafe = true, + webRtcDecoderAvailable = true, + webRtcHardwareDecoderAvailable = true, + ), + ) + + assertEquals(VideoCodec.H265, adjusted.codec) + assertEquals(ColorQuality.EightBit420, adjusted.colorQuality) + } + + @Test + fun preservesSelectedAv1WhenWebRtcHardwareDecoderExists() { + val adjusted = StreamSettings(codec = VideoCodec.AV1, colorQuality = ColorQuality.TenBit420) + .adjustedForDevice( + codecReport( + VideoCodec.AV1, + hardwareDecoder = true, + realtimeSafe = true, + webRtcDecoderAvailable = true, + webRtcHardwareDecoderAvailable = true, + ), + ) + + assertEquals(VideoCodec.AV1, adjusted.codec) + assertEquals(ColorQuality.EightBit420, adjusted.colorQuality) + } + + @Test + fun knownAmlogicAv1DecoderUsesH265For1440p() { + val av1 = codecReport( + VideoCodec.AV1, + hardwareDecoder = true, + realtimeSafe = true, + webRtcDecoderAvailable = true, + webRtcHardwareDecoderAvailable = true, + webRtcDecoderName = "OMX.amlogic.av1.decoder.awesome", + maxSupportedWidth = 3840, + maxSupportedHeight = 2160, + ).capabilities.single() + val h265 = codecReport( + VideoCodec.H265, + hardwareDecoder = true, + realtimeSafe = true, + webRtcDecoderAvailable = true, + webRtcHardwareDecoderAvailable = true, + maxSupportedWidth = 3840, + maxSupportedHeight = 2160, + ).capabilities.single() + val report = RuntimeCodecReport( + capabilities = listOf(av1, h265), + nativeRuntimeSummary = "{}", + androidTvProfile = true, + lowPowerGpuProfile = false, + ) + + val adjusted = StreamSettings( + resolution = "2560x1440", + aspectRatio = "16:9", + codec = VideoCodec.AV1, + ).adjustedForDevice(report) + + assertEquals("2560x1440", adjusted.resolution) + assertEquals(VideoCodec.H265, adjusted.codec) + } + + @Test + fun knownAmlogicAv1DecoderRemainsAvailableAt1080p() { + val adjusted = StreamSettings( + resolution = "1920x1080", + aspectRatio = "16:9", + codec = VideoCodec.AV1, + ).adjustedForDevice( + codecReport( + VideoCodec.AV1, + hardwareDecoder = true, + realtimeSafe = true, + webRtcDecoderAvailable = true, + webRtcHardwareDecoderAvailable = true, + webRtcDecoderName = "OMX.amlogic.av1.decoder.awesome", + maxSupportedWidth = 3840, + maxSupportedHeight = 2160, + ), + ) + + assertEquals(VideoCodec.AV1, adjusted.codec) + } + + @Test + fun av1DropsChroma444BeforeLaunch() { + val adjusted = StreamSettings(codec = VideoCodec.AV1, colorQuality = ColorQuality.EightBit444) + .adjustedForDevice( + codecReport( + VideoCodec.AV1, + hardwareDecoder = true, + realtimeSafe = true, + webRtcDecoderAvailable = true, + webRtcHardwareDecoderAvailable = true, + ), + ) + + assertEquals(VideoCodec.AV1, adjusted.codec) + assertEquals(ColorQuality.EightBit420, adjusted.colorQuality) + } + + @Test + fun av1DowngradesTenBitAndDisablesHdrBeforeLaunch() { + val adjusted = StreamSettings(codec = VideoCodec.AV1, colorQuality = ColorQuality.TenBit444, hdrEnabled = true) + .adjustedForDevice( + codecReport( + VideoCodec.AV1, + hardwareDecoder = true, + realtimeSafe = true, + webRtcDecoderAvailable = true, + webRtcHardwareDecoderAvailable = true, + ), + ) + + assertEquals(VideoCodec.AV1, adjusted.codec) + assertEquals(ColorQuality.EightBit420, adjusted.colorQuality) + assertFalse(adjusted.hdrEnabled) + } + + @Test + fun androidSettingsAvailabilityAllowsCodecsAndWithholdsChroma444() { + assertTrue(VideoCodec.AV1.availableForAndroidSettings()) + assertTrue(VideoCodec.H264.availableForAndroidSettings()) + assertTrue(VideoCodec.H265.availableForAndroidSettings()) + assertFalse(ColorQuality.EightBit444.availableForCodec(VideoCodec.H265)) + assertFalse(ColorQuality.TenBit444.availableForCodec(VideoCodec.H265)) + assertTrue(ColorQuality.EightBit420.availableForCodec(VideoCodec.H265)) + assertTrue(ColorQuality.TenBit420.availableForCodec(VideoCodec.H265)) + assertTrue(ColorQuality.EightBit420.availableForCodec(VideoCodec.AV1)) + assertFalse(ColorQuality.TenBit420.availableForCodec(VideoCodec.AV1)) + } + + @Test + fun chroma444SettingsNormalizeTo420ForAndroid() { + val adjusted = StreamSettings(codec = VideoCodec.H265, colorQuality = ColorQuality.TenBit444) + .withCodecColorCompatibility() + + assertEquals(VideoCodec.H265, adjusted.codec) + assertEquals(ColorQuality.TenBit420, adjusted.colorQuality) + } + + @Test + fun av1SettingsNormalizePersistedTenBitHdrToEightBitSdr() { + val adjusted = StreamSettings( + codec = VideoCodec.AV1, + colorQuality = ColorQuality.TenBit420, + hdrEnabled = true, + ).withCodecColorCompatibility() + + assertEquals(VideoCodec.AV1, adjusted.codec) + assertEquals(ColorQuality.EightBit420, adjusted.colorQuality) + assertFalse(adjusted.hdrEnabled) + assertFalse(adjusted.usesTenBitStreamProfile()) + } + + @Test + fun streamPresetsApplyExpectedAndroidProfiles() { + val base = StreamSettings( + aspectRatio = "21:9", + resolution = "1680x720", + codec = VideoCodec.AV1, + colorQuality = ColorQuality.EightBit444, + enableL4S = true, + ) + + val custom = base.applyingStreamPreset(StreamPreset.Custom) + assertTrue(custom.enableL4S) + + val low = base.applyingStreamPreset(StreamPreset.LowDataSaver) + assertEquals("1680x720", low.resolution) + assertEquals("21:9", low.aspectRatio) + assertEquals(30, low.fps) + assertEquals(12, low.maxBitrateMbps) + assertEquals(VideoCodec.AV1, low.codec) + assertEquals(ColorQuality.EightBit420, low.colorQuality) + assertFalse(low.enableL4S) + + val medium = base.applyingStreamPreset(StreamPreset.Medium) + assertEquals("2560x1080", medium.resolution) + assertEquals(60, medium.fps) + assertEquals(35, medium.maxBitrateMbps) + assertFalse(medium.enableL4S) + + val high = base.applyingStreamPreset(StreamPreset.High) + assertEquals("3440x1440", high.resolution) + assertEquals(360, high.fps) + assertEquals(75, high.maxBitrateMbps) + assertFalse(high.enableL4S) + + val recommended = base.applyingStreamPreset(StreamPreset.Recommended) + assertFalse(recommended.enableL4S) + } + + @Test + fun preservesUltimate360FpsAt5kForStableAndroidProfile() { + val adjusted = StreamSettings(resolution = "5120x2880", aspectRatio = "16:9", fps = 360, codec = VideoCodec.AV1) + .adjustedForDevice( + codecReport( + VideoCodec.AV1, + hardwareDecoder = true, + realtimeSafe = true, + webRtcDecoderAvailable = true, + webRtcHardwareDecoderAvailable = true, + ), + ) + + assertEquals("5120x2880", adjusted.resolution) + assertEquals(360, adjusted.fps) + } + + @Test + fun preservesTenBitWhenHdrIsEnabled() { + val adjusted = StreamSettings(codec = VideoCodec.H265, colorQuality = ColorQuality.TenBit420, hdrEnabled = true) + .adjustedForDevice( + codecReport( + VideoCodec.H265, + hardwareDecoder = true, + realtimeSafe = true, + webRtcDecoderAvailable = true, + webRtcHardwareDecoderAvailable = true, + ), + ) + + assertEquals(VideoCodec.H265, adjusted.codec) + assertEquals(ColorQuality.TenBit420, adjusted.colorQuality) + } + + @Test + fun fallsBackToH264WhenSelectedDecoderHasNoHardwarePath() { + val adjusted = StreamSettings(codec = VideoCodec.AV1, colorQuality = ColorQuality.TenBit420, maxBitrateMbps = 90) + .adjustedForDevice(codecReport(VideoCodec.AV1, hardwareDecoder = false, realtimeSafe = false)) + + assertEquals(VideoCodec.H264, adjusted.codec) + assertEquals(ColorQuality.EightBit420, adjusted.colorQuality) + assertEquals(90, adjusted.maxBitrateMbps) + } + + @Test + fun fallsBackToH264WhenH265WebRtcDecoderIsSoftwareOnly() { + val adjusted = StreamSettings(codec = VideoCodec.H265, colorQuality = ColorQuality.TenBit420, maxBitrateMbps = 90) + .adjustedForDevice( + codecReport( + VideoCodec.H265, + hardwareDecoder = true, + realtimeSafe = true, + webRtcDecoderAvailable = true, + webRtcHardwareDecoderAvailable = false, + ), + ) + + assertEquals(VideoCodec.H264, adjusted.codec) + assertEquals(ColorQuality.EightBit420, adjusted.colorQuality) + assertEquals(90, adjusted.maxBitrateMbps) + } + + @Test + fun preservesSelectedResolutionWhenFallingBackToH264() { + val adjusted = StreamSettings( + resolution = "1680x720", + aspectRatio = "21:9", + codec = VideoCodec.H265, + colorQuality = ColorQuality.TenBit420, + maxBitrateMbps = 90, + ) + .adjustedForDevice( + codecReport( + VideoCodec.H265, + hardwareDecoder = true, + realtimeSafe = true, + webRtcDecoderAvailable = true, + webRtcHardwareDecoderAvailable = false, + ), + ) + + assertEquals(VideoCodec.H264, adjusted.codec) + assertEquals(ColorQuality.EightBit420, adjusted.colorQuality) + assertEquals("1680x720", adjusted.resolution) + assertEquals("21:9", adjusted.aspectRatio) + assertEquals(90, adjusted.maxBitrateMbps) + } + + @Test + fun fallsBackToH264WhenH265OnlyHasPlatformHardwareDecoder() { + val adjusted = StreamSettings(codec = VideoCodec.H265, colorQuality = ColorQuality.TenBit420, maxBitrateMbps = 90) + .adjustedForDevice(codecReport(VideoCodec.H265, hardwareDecoder = true, realtimeSafe = true)) + + assertEquals(VideoCodec.H264, adjusted.codec) + assertEquals(ColorQuality.EightBit420, adjusted.colorQuality) + assertEquals(90, adjusted.maxBitrateMbps) + } + + @Test + fun preservesH265WhenWebRtcHardwareDecoderWorksButNativeProbeFails() { + val adjusted = StreamSettings(codec = VideoCodec.H265, colorQuality = ColorQuality.TenBit420, maxBitrateMbps = 90) + .adjustedForDevice( + codecReport( + VideoCodec.H265, + hardwareDecoder = true, + realtimeSafe = true, + nativeDecoderAvailable = false, + webRtcDecoderAvailable = true, + webRtcHardwareDecoderAvailable = true, + ), + ) + + assertEquals(VideoCodec.H265, adjusted.codec) + assertEquals(ColorQuality.EightBit420, adjusted.colorQuality) + assertEquals(90, adjusted.maxBitrateMbps) + } + + @Test + fun changingResolutionDoesNotForceAWebRtcHardwareCodecBackToH264() { + val report = codecReport( + VideoCodec.H265, + hardwareDecoder = true, + realtimeSafe = true, + nativeDecoderAvailable = false, + webRtcDecoderAvailable = true, + webRtcHardwareDecoderAvailable = true, + ) + + listOf("1280x720", "1920x1080", "2560x1440", "3840x2160").forEach { resolution -> + val adjusted = StreamSettings( + resolution = resolution, + aspectRatio = "16:9", + codec = VideoCodec.H265, + ).adjustedForDevice(report) + + assertEquals("$resolution should retain the selected codec", VideoCodec.H265, adjusted.codec) + } + } + + @Test + fun selectsAv1WhenNativeAndWebRtcHardwarePathsExist() { + val adjusted = StreamSettings(codec = VideoCodec.AV1, colorQuality = ColorQuality.TenBit420) + .adjustedForDevice( + codecReport( + VideoCodec.AV1, + hardwareDecoder = true, + realtimeSafe = true, + nativeDecoderAvailable = true, + webRtcDecoderAvailable = true, + webRtcHardwareDecoderAvailable = true, + ), + ) + + assertEquals(VideoCodec.AV1, adjusted.codec) + assertEquals(ColorQuality.EightBit420, adjusted.colorQuality) + } + + @Test + fun keepsSelectedCodecOnLowPowerDevicesWhenWebRtcHardwareDecoderExists() { + val adjusted = StreamSettings(codec = VideoCodec.H265, colorQuality = ColorQuality.TenBit420, maxBitrateMbps = 90) + .adjustedForDevice( + codecReport( + VideoCodec.H265, + hardwareDecoder = true, + realtimeSafe = true, + lowPower = true, + webRtcDecoderAvailable = true, + webRtcHardwareDecoderAvailable = true, + ), + ) + + assertEquals(VideoCodec.H265, adjusted.codec) + assertEquals(ColorQuality.EightBit420, adjusted.colorQuality) + assertEquals(90, adjusted.maxBitrateMbps) + } + + @Test + fun preservesHighRefreshRateForSupportedAndroidStreams() { + val adjusted = StreamSettings(codec = VideoCodec.AV1, fps = 120, maxBitrateMbps = 90) + .adjustedForDevice( + codecReport( + VideoCodec.AV1, + hardwareDecoder = true, + realtimeSafe = true, + webRtcDecoderAvailable = true, + webRtcHardwareDecoderAvailable = true, + ), + ) + + assertEquals(VideoCodec.AV1, adjusted.codec) + assertEquals(120, adjusted.fps) + assertEquals(90, adjusted.maxBitrateMbps) + } + + @Test + fun preservesSelectedH264BitrateCeiling() { + val adjusted = StreamSettings(codec = VideoCodec.H264, maxBitrateMbps = 150) + .adjustedForDevice(codecReport(VideoCodec.H264, hardwareDecoder = true, realtimeSafe = true)) + + assertEquals(VideoCodec.H264, adjusted.codec) + assertEquals(150, adjusted.maxBitrateMbps) + } + + @Test + fun stabilizesExtremeH264CloudMatchProfileBeforeLaunch() { + val adjusted = StreamSettings( + resolution = "5120x1440", + aspectRatio = "32:9", + fps = 240, + maxBitrateMbps = 150, + codec = VideoCodec.H264, + colorQuality = ColorQuality.TenBit444, + hdrEnabled = true, + enableL4S = true, + streamSharpeningEnabled = true, + ).adjustedForDevice(codecReport(VideoCodec.H264, hardwareDecoder = true, realtimeSafe = true)) + + assertEquals("5120x1440", adjusted.resolution) + assertEquals("32:9", adjusted.aspectRatio) + assertEquals(240, adjusted.fps) + assertEquals(150, adjusted.maxBitrateMbps) + assertEquals(VideoCodec.H264, adjusted.codec) + assertEquals(ColorQuality.EightBit420, adjusted.colorQuality) + assertEquals(false, adjusted.hdrEnabled) + assertEquals(true, adjusted.enableL4S) + } + + @Test + fun stabilizesExtremeH265CloudMatchProfileWithoutDroppingAdvancedFeatures() { + val adjusted = StreamSettings( + resolution = "5120x2160", + aspectRatio = "21:9", + fps = 240, + maxBitrateMbps = 150, + codec = VideoCodec.H265, + colorQuality = ColorQuality.TenBit420, + hdrEnabled = true, + enableL4S = true, + streamSharpeningEnabled = true, + ).adjustedForDevice( + codecReport( + VideoCodec.H265, + hardwareDecoder = true, + realtimeSafe = true, + nativeDecoderAvailable = true, + webRtcDecoderAvailable = true, + webRtcHardwareDecoderAvailable = true, + ), + ) + + assertEquals("5120x2160", adjusted.resolution) + assertEquals("21:9", adjusted.aspectRatio) + assertEquals(240, adjusted.fps) + assertEquals(150, adjusted.maxBitrateMbps) + assertEquals(VideoCodec.H265, adjusted.codec) + assertEquals(ColorQuality.TenBit420, adjusted.colorQuality) + assertEquals(true, adjusted.hdrEnabled) + assertEquals(true, adjusted.enableL4S) + } + + @Test + fun safeVideoFallbackChangesCodecWithoutChangingRequested4kGeometry() { + val fallback = StreamSettings( + resolution = "3840x2160", + aspectRatio = "16:9", + fps = 120, + maxBitrateMbps = 150, + codec = VideoCodec.H265, + colorQuality = ColorQuality.TenBit420, + hdrEnabled = true, + streamSharpeningEnabled = true, + ).androidSafeVideoFallback() + + assertEquals("3840x2160", fallback.resolution) + assertEquals("16:9", fallback.aspectRatio) + assertEquals(60, fallback.fps) + assertEquals(150, fallback.maxBitrateMbps) + assertEquals(VideoCodec.H264, fallback.codec) + assertEquals(ColorQuality.EightBit420, fallback.colorQuality) + assertEquals(false, fallback.hdrEnabled) + assertEquals(false, fallback.streamSharpeningEnabled) + } + + @Test + fun safeVideoFallbackPreservesLaunchResolutionInsideH264Bounds() { + val fallback = StreamSettings( + resolution = "1680x720", + aspectRatio = "21:9", + fps = 60, + maxBitrateMbps = 75, + codec = VideoCodec.H265, + colorQuality = ColorQuality.TenBit420, + hdrEnabled = true, + streamSharpeningEnabled = true, + ).androidSafeVideoFallback() + + assertEquals("1680x720", fallback.resolution) + assertEquals("21:9", fallback.aspectRatio) + assertEquals(60, fallback.fps) + assertEquals(75, fallback.maxBitrateMbps) + assertEquals(VideoCodec.H264, fallback.codec) + assertEquals(ColorQuality.EightBit420, fallback.colorQuality) + } + + @Test + fun safeVideoFallbackPreservesEveryKnownResolutionAndAspect() { + STREAM_RESOLUTION_OPTIONS.forEach { option -> + val fallback = StreamSettings( + resolution = option.value, + aspectRatio = option.aspectRatio, + fps = 240, + maxBitrateMbps = 150, + codec = VideoCodec.AV1, + colorQuality = ColorQuality.TenBit420, + ).androidSafeVideoFallback() + + assertEquals(option.value, fallback.resolution) + assertEquals(option.aspectRatio, fallback.aspectRatio) + assertEquals(VideoCodec.H264, fallback.codec) + } + } + + @Test + fun constrainedTvFirstFrameRecoveryChangesCodecOnceWithoutReducing1440p() { + val launch = StreamSettings( + resolution = "2560x1440", + aspectRatio = "16:9", + fps = 60, + maxBitrateMbps = 75, + codec = VideoCodec.H265, + colorQuality = ColorQuality.TenBit420, + ).adjustedForDevice( + codecReport( + VideoCodec.H265, + hardwareDecoder = true, + realtimeSafe = true, + lowPower = true, + tv = true, + constrainedRuntime = true, + webRtcDecoderAvailable = true, + webRtcHardwareDecoderAvailable = true, + ), + ) + val recovery = launch.androidSafeVideoFallback() + + assertEquals("2560x1440", launch.resolution) + assertEquals(VideoCodec.H265, launch.codec) + assertEquals("2560x1440", recovery.resolution) + assertEquals("16:9", recovery.aspectRatio) + assertEquals(VideoCodec.H264, recovery.codec) + assertEquals(recovery, recovery.androidSafeVideoFallback()) + } + + @Test + fun usesSafeH264ProfileForLowPowerAndroidTv() { + val adjusted = StreamSettings( + resolution = "3840x2160", + aspectRatio = "16:9", + fps = 120, + maxBitrateMbps = 90, + codec = VideoCodec.H265, + colorQuality = ColorQuality.TenBit420, + streamSharpeningEnabled = true, + ).adjustedForDevice(codecReport(VideoCodec.H265, hardwareDecoder = true, realtimeSafe = true, lowPower = true, tv = true)) + + assertEquals(VideoCodec.H264, adjusted.codec) + assertEquals(ColorQuality.EightBit420, adjusted.colorQuality) + assertEquals("3840x2160", adjusted.resolution) + assertEquals("16:9", adjusted.aspectRatio) + assertEquals(60, adjusted.fps) + assertEquals(90, adjusted.maxBitrateMbps) + assertEquals(false, adjusted.streamSharpeningEnabled) + } + + @Test + fun preservesHighCustomProfileOn32BitPhone() { + val adjusted = StreamSettings( + resolution = "3840x2160", + aspectRatio = "16:9", + fps = 120, + maxBitrateMbps = 75, + codec = VideoCodec.H265, + colorQuality = ColorQuality.TenBit420, + hdrEnabled = true, + streamSharpeningEnabled = true, + ).adjustedForDevice( + codecReport( + VideoCodec.H265, + hardwareDecoder = true, + realtimeSafe = true, + lowPower = true, + constrainedRuntime = true, + webRtcDecoderAvailable = true, + webRtcHardwareDecoderAvailable = true, + maxSupportedWidth = 3840, + maxSupportedHeight = 2160, + ), + ) + + assertEquals("3840x2160", adjusted.resolution) + assertEquals("16:9", adjusted.aspectRatio) + assertEquals(120, adjusted.fps) + assertEquals(75, adjusted.maxBitrateMbps) + assertEquals(VideoCodec.H265, adjusted.codec) + assertEquals(ColorQuality.TenBit420, adjusted.colorQuality) + assertEquals(true, adjusted.hdrEnabled) + assertEquals(true, adjusted.streamSharpeningEnabled) + } + + @Test + fun preservesHighCustomProfileOnMemoryConstrainedTv() { + val adjusted = StreamSettings( + resolution = "2560x1440", + aspectRatio = "16:9", + fps = 60, + maxBitrateMbps = 75, + codec = VideoCodec.H265, + colorQuality = ColorQuality.TenBit420, + hdrEnabled = true, + streamSharpeningEnabled = true, + ).adjustedForDevice( + codecReport( + VideoCodec.H265, + hardwareDecoder = true, + realtimeSafe = true, + lowPower = true, + tv = true, + constrainedRuntime = true, + webRtcDecoderAvailable = true, + webRtcHardwareDecoderAvailable = true, + maxSupportedWidth = 3840, + maxSupportedHeight = 2160, + ), + ) + + assertEquals("2560x1440", adjusted.resolution) + assertEquals(60, adjusted.fps) + assertEquals(75, adjusted.maxBitrateMbps) + assertEquals(VideoCodec.H265, adjusted.codec) + assertEquals(ColorQuality.TenBit420, adjusted.colorQuality) + assertEquals(true, adjusted.hdrEnabled) + assertEquals(true, adjusted.streamSharpeningEnabled) + } + + @Test + fun warnsAboutDemandingSettingsWithoutChangingThem() { + val settings = StreamSettings( + resolution = "1920x1080", + aspectRatio = "16:9", + fps = 60, + maxBitrateMbps = 35, + hdrEnabled = true, + streamSharpeningEnabled = true, + ) + val report = codecReport( + VideoCodec.H264, + hardwareDecoder = true, + realtimeSafe = true, + lowPower = true, + constrainedRuntime = true, + ) + + assertEquals( + listOf( + "1920x1080 resolution", + "60 FPS", + "35 Mbps bitrate", + "HDR", + "stream sharpening", + ), + settings.lowPowerPerformanceWarningReasons(report), + ) + } + + @Test + fun recommendedLowPowerProfileDoesNotWarn() { + val settings = StreamSettings( + resolution = "1280x720", + aspectRatio = "16:9", + fps = 30, + maxBitrateMbps = 12, + ) + val report = codecReport( + VideoCodec.H264, + hardwareDecoder = true, + realtimeSafe = true, + lowPower = true, + constrainedRuntime = true, + ) + + assertTrue(settings.lowPowerPerformanceWarningReasons(report).isEmpty()) + } + + @Test + fun preservesHardwareH265ForLowPowerAndroidTvInsideSafeLimits() { + val adjusted = StreamSettings( + resolution = "3840x2160", + aspectRatio = "16:9", + fps = 120, + maxBitrateMbps = 90, + codec = VideoCodec.H265, + colorQuality = ColorQuality.TenBit420, + hdrEnabled = true, + streamSharpeningEnabled = true, + ).adjustedForDevice( + codecReport( + VideoCodec.H265, + hardwareDecoder = true, + realtimeSafe = true, + lowPower = true, + tv = true, + nativeDecoderAvailable = true, + webRtcDecoderAvailable = true, + webRtcHardwareDecoderAvailable = true, + ), + ) + + assertEquals(VideoCodec.H265, adjusted.codec) + assertEquals(ColorQuality.EightBit420, adjusted.colorQuality) + assertEquals("3840x2160", adjusted.resolution) + assertEquals(60, adjusted.fps) + assertEquals(90, adjusted.maxBitrateMbps) + assertEquals(false, adjusted.hdrEnabled) + assertEquals(false, adjusted.streamSharpeningEnabled) + } + + @Test + fun lowPowerAndroidTvKeeps1440pWhenHardwareDecoderExplicitlySupportsIt() { + val adjusted = StreamSettings( + resolution = "2560x1440", + aspectRatio = "16:9", + codec = VideoCodec.H265, + colorQuality = ColorQuality.TenBit420, + maxBitrateMbps = 75, + fps = 120, + streamSharpeningEnabled = true, + ).adjustedForDevice( + codecReport( + VideoCodec.H265, + hardwareDecoder = true, + realtimeSafe = true, + lowPower = true, + tv = true, + nativeDecoderAvailable = true, + webRtcDecoderAvailable = true, + webRtcHardwareDecoderAvailable = true, + maxSupportedWidth = 3840, + maxSupportedHeight = 2160, + ), + ) + + assertEquals("2560x1440", adjusted.resolution) + assertEquals("16:9", adjusted.aspectRatio) + assertEquals(VideoCodec.H265, adjusted.codec) + assertEquals(60, adjusted.fps) + assertEquals(75, adjusted.maxBitrateMbps) + assertEquals(false, adjusted.streamSharpeningEnabled) + } + + @Test + fun lowPowerAndroidTvKeeps1440pWhenHardwareDecoderLimitsAreMissing() { + val adjusted = StreamSettings( + resolution = "2560x1440", + aspectRatio = "16:9", + codec = VideoCodec.H265, + colorQuality = ColorQuality.TenBit420, + maxBitrateMbps = 75, + fps = 60, + ).adjustedForDevice( + codecReport( + VideoCodec.H265, + hardwareDecoder = true, + realtimeSafe = true, + lowPower = true, + tv = true, + webRtcDecoderAvailable = true, + webRtcHardwareDecoderAvailable = true, + ), + ) + + assertEquals("2560x1440", adjusted.resolution) + assertEquals("16:9", adjusted.aspectRatio) + assertEquals(VideoCodec.H265, adjusted.codec) + assertEquals(60, adjusted.fps) + assertEquals(75, adjusted.maxBitrateMbps) + } + + @Test + fun lowPowerAndroidTvKeeps1440pWhenHardwareDecoderProbeUnderreportsLimits() { + val adjusted = StreamSettings( + resolution = "2560x1440", + aspectRatio = "16:9", + codec = VideoCodec.H265, + colorQuality = ColorQuality.TenBit420, + maxBitrateMbps = 75, + fps = 60, + ).adjustedForDevice( + codecReport( + VideoCodec.H265, + hardwareDecoder = true, + realtimeSafe = true, + lowPower = true, + tv = true, + webRtcDecoderAvailable = true, + webRtcHardwareDecoderAvailable = true, + maxSupportedWidth = 1920, + maxSupportedHeight = 1080, + ), + ) + + assertEquals("2560x1440", adjusted.resolution) + assertEquals("16:9", adjusted.aspectRatio) + assertEquals(VideoCodec.H265, adjusted.codec) + } + + @Test + fun keepsSelectedLowPowerAndroidTvUltrawideGeometry() { + val adjusted = StreamSettings( + resolution = "3440x1440", + aspectRatio = "21:9", + codec = VideoCodec.H265, + colorQuality = ColorQuality.TenBit420, + ).adjustedForDevice(codecReport(VideoCodec.H265, hardwareDecoder = true, realtimeSafe = true, lowPower = true, tv = true)) + + assertEquals(VideoCodec.H264, adjusted.codec) + assertEquals(ColorQuality.EightBit420, adjusted.colorQuality) + assertEquals("3440x1440", adjusted.resolution) + assertEquals("21:9", adjusted.aspectRatio) + } + + @Test + fun disablesRendererSharpeningForAndroidTvLaunchProfiles() { + val adjusted = StreamSettings( + codec = VideoCodec.AV1, + maxBitrateMbps = 75, + streamSharpeningEnabled = true, + ).adjustedForDevice( + codecReport( + VideoCodec.AV1, + hardwareDecoder = true, + realtimeSafe = true, + tv = true, + webRtcDecoderAvailable = true, + webRtcHardwareDecoderAvailable = true, + ), + ) + + assertEquals(VideoCodec.AV1, adjusted.codec) + assertEquals(75, adjusted.maxBitrateMbps) + assertEquals(false, adjusted.streamSharpeningEnabled) + } + + @Test + fun preservesAv1WhenWebRtcHardwarePathExistsAndPlatformProbeMissesIt() { + val adjusted = StreamSettings(codec = VideoCodec.AV1, colorQuality = ColorQuality.TenBit420) + .adjustedForDevice( + codecReport( + VideoCodec.AV1, + decoderAvailable = false, + hardwareDecoder = false, + realtimeSafe = false, + webRtcDecoderAvailable = true, + webRtcHardwareDecoderAvailable = true, + ), + ) + + assertEquals(VideoCodec.AV1, adjusted.codec) + assertEquals(ColorQuality.EightBit420, adjusted.colorQuality) + } + + @Test + fun usesAv1HardwareFallbackWhenH264ProbeFails() { + val report = RuntimeCodecReport( + capabilities = listOf( + CodecCapability( + codec = VideoCodec.H264, + decoderAvailable = true, + encoderAvailable = false, + hardwareDecoder = true, + hardwareEncoder = false, + realtimeSafe = true, + webRtcDecoderAvailable = false, + ), + CodecCapability( + codec = VideoCodec.AV1, + decoderAvailable = false, + encoderAvailable = false, + hardwareDecoder = false, + hardwareEncoder = false, + realtimeSafe = false, + webRtcDecoderAvailable = true, + webRtcHardwareDecoderAvailable = true, + ), + ), + nativeRuntimeSummary = "{}", + androidTvProfile = false, + lowPowerGpuProfile = false, + ) + + val adjusted = StreamSettings(codec = VideoCodec.H264, colorQuality = ColorQuality.EightBit420) + .adjustedForDevice(report) + + assertEquals(VideoCodec.AV1, adjusted.codec) + } + + @Test + fun preservesResolutionWhenDecoderCapabilitiesAreConservative() { + val report = RuntimeCodecReport( + capabilities = listOf( + CodecCapability( + codec = VideoCodec.H264, + decoderAvailable = true, + encoderAvailable = false, + hardwareDecoder = true, + hardwareEncoder = false, + realtimeSafe = true, + maxSupportedWidth = 1920, + maxSupportedHeight = 1080, + ) + ), + nativeRuntimeSummary = "{}", + androidTvProfile = false, + lowPowerGpuProfile = false, + ) + + val settings = StreamSettings( + resolution = "3440x1440", + aspectRatio = "21:9", + codec = VideoCodec.H264, + ) + + val adjusted = settings.adjustedForDevice(report) + assertEquals("3440x1440", adjusted.resolution) + } + + @Test + fun legacyPortalGeometryUsesProviderTwentyOneByNineSixtyFpsProfile() { + val adjusted = StreamSettings( + resolution = "1376x640", + aspectRatio = "19.5:9", + fps = 120, + codec = VideoCodec.H265, + ).adjustedForDevice(report = null) + + assertEquals("1376x590", adjusted.resolution) + assertEquals("21:9", adjusted.aspectRatio) + assertEquals(60, adjusted.fps) + assertEquals(VideoCodec.H265, adjusted.codec) + } + + @Test + fun phoneFullHdGeometryPreservesHardwareH265() { + val report = codecReport( + codec = VideoCodec.H265, + hardwareDecoder = true, + realtimeSafe = true, + webRtcDecoderAvailable = true, + webRtcHardwareDecoderAvailable = true, + maxSupportedWidth = 1920, + maxSupportedHeight = 1080, + ) + val adjusted = StreamSettings( + resolution = "2340x1080", + aspectRatio = "19.5:9", + fps = 60, + codec = VideoCodec.H265, + ).adjustedForDevice(report) + + assertEquals("2340x1080", adjusted.resolution) + assertEquals("19.5:9", adjusted.aspectRatio) + assertEquals(VideoCodec.H265, adjusted.codec) + } + + @Test + fun preserves1440pByUsingAnotherHardwareCodecBeforeReducingResolution() { + val report = RuntimeCodecReport( + capabilities = listOf( + CodecCapability( + codec = VideoCodec.H264, + decoderAvailable = true, + encoderAvailable = false, + hardwareDecoder = true, + hardwareEncoder = false, + realtimeSafe = true, + webRtcDecoderAvailable = true, + webRtcHardwareDecoderAvailable = true, + maxSupportedWidth = 1920, + maxSupportedHeight = 1080, + ), + CodecCapability( + codec = VideoCodec.H265, + decoderAvailable = true, + encoderAvailable = false, + hardwareDecoder = true, + hardwareEncoder = false, + realtimeSafe = true, + webRtcDecoderAvailable = true, + webRtcHardwareDecoderAvailable = true, + maxSupportedWidth = 3840, + maxSupportedHeight = 2160, + ), + ), + nativeRuntimeSummary = "{}", + androidTvProfile = false, + lowPowerGpuProfile = false, + ) + + val adjusted = StreamSettings( + resolution = "2560x1440", + aspectRatio = "16:9", + codec = VideoCodec.H264, + colorQuality = ColorQuality.EightBit420, + ).adjustedForDevice(report) + + assertEquals("2560x1440", adjusted.resolution) + assertEquals("16:9", adjusted.aspectRatio) + assertEquals(VideoCodec.H265, adjusted.codec) + } + + @Test + fun testAdjustedForDevicePreservesRequestedFpsAtEverySupportedResolution() { + val report = codecReport( + codec = VideoCodec.H265, + hardwareDecoder = true, + realtimeSafe = true, + webRtcDecoderAvailable = true, + webRtcHardwareDecoderAvailable = true, + maxSupportedWidth = 3840, + maxSupportedHeight = 2160, + ) + + listOf("1920x1080", "2560x1440", "3840x2160").forEach { resolution -> + val settings = StreamSettings( + resolution = resolution, + aspectRatio = "16:9", + fps = 120, + codec = VideoCodec.H265, + ) + + val adjusted = settings.adjustedForDevice(report) + assertEquals(resolution, adjusted.resolution) + assertEquals("$resolution should preserve the selected FPS", 120, adjusted.fps) + } + } + + private fun codecReport( + codec: VideoCodec, + decoderAvailable: Boolean = true, + hardwareDecoder: Boolean, + realtimeSafe: Boolean, + lowPower: Boolean = false, + tv: Boolean = false, + constrainedRuntime: Boolean = false, + nativeDecoderAvailable: Boolean? = null, + webRtcDecoderAvailable: Boolean? = null, + webRtcHardwareDecoderAvailable: Boolean? = null, + webRtcDecoderName: String? = null, + maxSupportedWidth: Int? = null, + maxSupportedHeight: Int? = null, + ): RuntimeCodecReport = + RuntimeCodecReport( + capabilities = listOf( + CodecCapability( + codec = codec, + decoderAvailable = decoderAvailable, + encoderAvailable = false, + hardwareDecoder = hardwareDecoder, + hardwareEncoder = false, + realtimeSafe = realtimeSafe, + nativeDecoderAvailable = nativeDecoderAvailable, + webRtcDecoderAvailable = webRtcDecoderAvailable, + webRtcHardwareDecoderAvailable = webRtcHardwareDecoderAvailable, + webRtcDecoderName = webRtcDecoderName, + maxSupportedWidth = maxSupportedWidth, + maxSupportedHeight = maxSupportedHeight, + ), + ), + nativeRuntimeSummary = "{}", + androidTvProfile = tv, + lowPowerGpuProfile = lowPower, + constrainedRuntimeProfile = constrainedRuntime, + ) +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/StreamSignalingFailureTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/StreamSignalingFailureTest.kt new file mode 100644 index 000000000..d71677eb3 --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/StreamSignalingFailureTest.kt @@ -0,0 +1,171 @@ +package com.opencloudgaming.opennow + +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.webrtc.PeerConnection + +class StreamSignalingFailureTest { + @Test + fun staleSignalingEndpointRequestsSessionRecovery() { + assertEquals( + SignalingFailureDisposition.RecoverSession, + signalingFailureDisposition("Expected HTTP 101 response but was '404 Not Found' http=404"), + ) + } + + @Test + fun onlyExplicitProviderGoneResponseIsTerminal() { + assertEquals( + SignalingFailureDisposition.SessionEnded, + signalingFailureDisposition("http=410 Gone"), + ) + assertEquals(SignalingFailureDisposition.RetryTransport, signalingFailureDisposition("code=1000")) + } + + @Test + fun normalServerCloseAlwaysRetriesInsteadOfEndingSession() { + assertEquals( + SignalingFailureDisposition.RetryTransport, + signalingFailureDisposition("code=1000"), + ) + } + + @Test + fun transientSignalingFailureRetriesTransport() { + assertEquals( + SignalingFailureDisposition.RetryTransport, + signalingFailureDisposition("socket timeout"), + ) + } + + @Test + fun serviceUnavailableUsesSeparateBoundedSignalingBackoff() { + assertEquals( + SignalingFailureDisposition.RetrySignaling, + signalingFailureDisposition( + "ProtocolException: Expected HTTP 101 response but was '503 Service Unavailable' http=503", + ), + ) + assertEquals(SignalingFailureDisposition.RetrySignaling, signalingFailureDisposition("http=429")) + assertEquals(1_000L, transientSignalingRetryDelayMs(1)) + assertEquals(2_000L, transientSignalingRetryDelayMs(2)) + assertEquals(4_000L, transientSignalingRetryDelayMs(3)) + assertNull(transientSignalingRetryDelayMs(4)) + } + + @Test + fun bitrateChangesAreNormalizedBeforeBeingQueuedForTheNextOffer() { + assertEquals(1_000, normalizedLiveBitrateKbps(0)) + assertEquals(1_000, normalizedLiveBitrateKbps(1_499)) + assertEquals(2_000, normalizedLiveBitrateKbps(1_500)) + assertEquals(75_000, normalizedLiveBitrateKbps(75_000)) + } + + @Test + fun serverHeartbeatGetsImmediateProtocolReply() { + assertEquals( + """{"hb":1}""", + signalingHeartbeatReply(buildJsonObject { put("hb", 1) }), + ) + assertNull(signalingHeartbeatReply(buildJsonObject { put("ack", 1) })) + } + + @Test + fun activeIceTransportSurvivesTransientSignalingFailure() { + val transient = SignalingFailureDisposition.RetryTransport + + assertTrue(shouldPreserveMediaAfterSignalingFailure(transient, PeerConnection.IceConnectionState.CHECKING)) + assertTrue(shouldPreserveMediaAfterSignalingFailure(transient, PeerConnection.IceConnectionState.CONNECTED)) + assertTrue(shouldPreserveMediaAfterSignalingFailure(transient, PeerConnection.IceConnectionState.COMPLETED)) + assertTrue( + shouldPreserveMediaAfterSignalingFailure( + SignalingFailureDisposition.RetrySignaling, + PeerConnection.IceConnectionState.CONNECTED, + ), + ) + assertFalse(shouldPreserveMediaAfterSignalingFailure(transient, PeerConnection.IceConnectionState.DISCONNECTED)) + assertFalse(shouldPreserveMediaAfterSignalingFailure(transient, PeerConnection.IceConnectionState.FAILED)) + assertFalse(shouldPreserveMediaAfterSignalingFailure(transient, null)) + } + + @Test + fun terminalAndStaleSignalingFailuresStillTakeTheirNormalRecoveryPaths() { + assertFalse( + shouldPreserveMediaAfterSignalingFailure( + SignalingFailureDisposition.SessionEnded, + PeerConnection.IceConnectionState.CONNECTED, + ), + ) + assertFalse( + shouldPreserveMediaAfterSignalingFailure( + SignalingFailureDisposition.RecoverSession, + PeerConnection.IceConnectionState.CONNECTED, + ), + ) + } + + @Test + fun peerNativeOperationsRequireTheCurrentGenerationAndPeerIdentity() { + val currentPeer = Any() + + assertTrue( + isCurrentPeerOperation( + operationGeneration = 7, + currentGeneration = 7, + expectedPeer = currentPeer, + activePeer = currentPeer, + ), + ) + assertFalse( + isCurrentPeerOperation( + operationGeneration = 6, + currentGeneration = 7, + expectedPeer = currentPeer, + activePeer = currentPeer, + ), + ) + assertFalse( + isCurrentPeerOperation( + operationGeneration = 7, + currentGeneration = 7, + expectedPeer = Any(), + activePeer = currentPeer, + ), + ) + assertFalse( + isCurrentPeerOperation( + operationGeneration = 7, + currentGeneration = 7, + expectedPeer = currentPeer, + activePeer = null, + ), + ) + } + + @Test + fun unboundPeerOperationCanAcquireOnlyTheCurrentActivePeer() { + val currentPeer = Any() + + assertTrue( + isCurrentPeerOperation( + operationGeneration = 12, + currentGeneration = 12, + expectedPeer = null, + activePeer = currentPeer, + ), + ) + assertFalse( + isCurrentPeerOperation( + operationGeneration = 11, + currentGeneration = 12, + expectedPeer = null, + activePeer = currentPeer, + ), + ) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/StreamStatusBatteryTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/StreamStatusBatteryTest.kt new file mode 100644 index 000000000..52e7176a4 --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/StreamStatusBatteryTest.kt @@ -0,0 +1,36 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertEquals +import org.junit.Test + +class StreamStatusBatteryTest { + + @Test + fun unknownBatteryUsesTheUnknownIcon() { + assertEquals(StreamBatteryLevel.Unknown, streamBatteryLevel(null)) + } + + @Test + fun batteryIconFillTracksTheReportedPercentage() { + val expected = listOf( + 0 to StreamBatteryLevel.Empty, + 10 to StreamBatteryLevel.One, + 25 to StreamBatteryLevel.Two, + 40 to StreamBatteryLevel.Three, + 55 to StreamBatteryLevel.Four, + 70 to StreamBatteryLevel.Five, + 90 to StreamBatteryLevel.Six, + 100 to StreamBatteryLevel.Full, + ) + + expected.forEach { (percent, level) -> + assertEquals("battery at $percent%", level, streamBatteryLevel(percent)) + } + } + + @Test + fun outOfRangeReadingsAreClamped() { + assertEquals(StreamBatteryLevel.Empty, streamBatteryLevel(-10)) + assertEquals(StreamBatteryLevel.Full, streamBatteryLevel(150)) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/StreamSystemUiTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/StreamSystemUiTest.kt new file mode 100644 index 000000000..c85e7ca4f --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/StreamSystemUiTest.kt @@ -0,0 +1,47 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class StreamSystemUiTest { + @Test + fun leavesTransientThreeButtonNavigationVisibleLongEnoughToUse() { + assertFalse( + shouldPeriodicallyEnforceStreamSystemUi( + streamActive = true, + navigationBarsVisible = true, + pointerLockEnabled = false, + ), + ) + } + + @Test + fun mouseLockForcesImmersiveModeWhenSystemNavigationAppears() { + assertTrue( + shouldPeriodicallyEnforceStreamSystemUi( + streamActive = true, + navigationBarsVisible = true, + pointerLockEnabled = true, + ), + ) + } + + @Test + fun keepsFullscreenEnforcementForHiddenNavigationBars() { + assertTrue( + shouldPeriodicallyEnforceStreamSystemUi( + streamActive = true, + navigationBarsVisible = false, + pointerLockEnabled = false, + ), + ) + assertFalse( + shouldPeriodicallyEnforceStreamSystemUi( + streamActive = false, + navigationBarsVisible = false, + pointerLockEnabled = true, + ), + ) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/TouchControllerSkinTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/TouchControllerSkinTest.kt new file mode 100644 index 000000000..580242c9c --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/TouchControllerSkinTest.kt @@ -0,0 +1,138 @@ +package com.opencloudgaming.opennow + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class TouchControllerSkinTest { + @Test + fun everySkinDistinguishesPressedFromResting() { + TouchControllerStyle.entries.forEach { style -> + val skin = touchSkinColors(style, opacity = 1f, accent = Color(0xff42c9ff)) + assertNotEquals("$style fill", skin.fill, skin.pressedFill) + } + } + + @Test + fun opacityScalesEverySkinDown() { + TouchControllerStyle.entries.forEach { style -> + val full = touchSkinColors(style, opacity = 1f, accent = Color.White) + val faded = touchSkinColors(style, opacity = 0.3f, accent = Color.White) + assertTrue("$style border", faded.border.alpha <= full.border.alpha) + assertTrue("$style glyph", faded.glyph.alpha <= full.glyph.alpha) + } + } + + @Test + fun accentReachesTheSkinsThatUseOne() { + val red = Color(0xffff0000) + val neon = touchSkinColors(TouchControllerStyle.Neon, opacity = 1f, accent = red) + assertEquals(red.red, neon.border.red, 0.001f) + assertEquals(0f, neon.border.green, 0.001f) + } + + @Test + fun cyclingVisitsEverySkinAndReturnsToTheStart() { + var style = TouchControllerStyle.V1 + val seen = mutableListOf(style) + repeat(TouchControllerStyle.entries.size - 1) { + style = nextTouchControllerStyle(style) + seen += style + } + assertEquals(TouchControllerStyle.entries.toSet(), seen.toSet()) + assertEquals(TouchControllerStyle.V1, nextTouchControllerStyle(style)) + } + + @Test + fun everySkinHasItsOwnSilhouette() { + // The whole point of a skin: two of them may never differ by colour alone. + val silhouettes = TouchControllerStyle.entries.associateWith { touchSkinForm(it).silhouette } + assertEquals( + "$silhouettes", + TouchControllerStyle.entries.size, + silhouettes.values.toSet().size, + ) + } + + @Test + fun theClassicSkinKeepsTheShapesItAlwaysHad() { + val form = touchSkinForm(TouchControllerStyle.V1) + + assertEquals(TouchCapShape.Circle, form.capShape) + assertEquals(TouchDpadShape.Cross, form.dpadShape) + assertEquals(TouchStickShape.Ring, form.stickShape) + assertEquals(TouchShoulderShape.Pill, form.shoulderShape) + // No dome, no bloom, no travel: this is the layout people already have muscle memory for. + assertEquals(1f, form.pressScale, 0f) + assertEquals(0f, form.gloss, 0f) + assertEquals(0.dp, form.glow) + } + + @Test + fun onlyABladeDpadGoesWithoutArrowheads() { + TouchControllerStyle.entries.forEach { style -> + val form = touchSkinForm(style) + if (form.dpadArrow == TouchDpadArrow.None) { + assertEquals("$style", TouchDpadShape.Blades, form.dpadShape) + } + } + } + + @Test + fun opacityFadesTheShadingASkinAddsOnTopOfItsPalette() { + val full = touchSkinColors(TouchControllerStyle.Arcade, opacity = 1f, accent = Color.White) + val faded = touchSkinColors(TouchControllerStyle.Arcade, opacity = 0.25f, accent = Color.White) + + assertTrue(faded.sheen(0.5f).alpha < full.sheen(0.5f).alpha) + } + + @Test + fun theDpadIsAlwaysWideEnoughForItsFourArms() { + // Three arms across plus the gaps the arrowheads sit in. + assertEquals(62f, touchDpadBoxSize(20.dp).value, 0.001f) + } + + @Test + fun tintPresetsRoundTripThroughTheirIds() { + TOUCH_SKIN_TINTS.forEach { option -> + assertEquals(option.id, touchSkinTintId(option.rgb)) + assertEquals(option.rgb, touchSkinTintForId(option.id)) + } + } + + @Test + fun unknownTintFallsBackToTheSkinDefault() { + assertEquals(TOUCH_SKIN_TINT_DEFAULT_ID, touchSkinTintId(ControllerThemeRgb(1, 2, 3))) + assertNull(touchSkinTintForId("no-such-tint")) + } + + @Test + fun removedWarmTintsMigrateToMagenta() { + val magenta = ControllerThemeRgb(255, 92, 190) + + assertEquals(magenta, ControllerThemeRgb(255, 176, 32).withoutRemovedWarmTint()) + assertEquals(magenta, ControllerThemeRgb(255, 106, 43).withoutRemovedWarmTint()) + } + + @Test + fun unsetTintUsesTheSkinsOwnAccent() { + val settings = AndroidTouchSettings(touchControllerStyle = TouchControllerStyle.Retro, touchSkinTint = null) + assertEquals(defaultTouchSkinAccent(TouchControllerStyle.Retro), touchSkinAccent(settings)) + } + + @Test + fun tintCyclingVisitsEveryPresetAndReturnsToDefault() { + var tint: ControllerThemeRgb? = null + val seen = mutableSetOf() + repeat(TOUCH_SKIN_TINTS.size) { + seen += tint + tint = nextTouchSkinTint(tint) + } + assertEquals(TOUCH_SKIN_TINTS.map { it.rgb }.toSet(), seen) + assertNull(tint) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/TouchOverlayLayoutTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/TouchOverlayLayoutTest.kt new file mode 100644 index 000000000..8a9ead1d3 --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/TouchOverlayLayoutTest.kt @@ -0,0 +1,118 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class TouchOverlayLayoutTest { + @Test + fun keepsLandscapeTopControlsBelowPhoneTopInformationBand() { + val clearance = landscapeTouchTopControlClearanceDp(viewportHeightDp = 390f, controlScale = 1f) + + assertTrue(clearance >= 40f) + } + + @Test + fun scalesLandscapeTopClearanceForLargerControlsWithoutRunningAway() { + val normal = landscapeTouchTopControlClearanceDp(viewportHeightDp = 430f, controlScale = 1f) + val large = landscapeTouchTopControlClearanceDp(viewportHeightDp = 800f, controlScale = 1.5f) + + assertTrue(large > normal) + assertEquals(76f, large, 0.001f) + } + + @Test + fun keepsTinyLandscapeScreensUsable() { + val clearance = landscapeTouchTopControlClearanceDp(viewportHeightDp = 300f, controlScale = 0.6f) + + assertEquals(30f, clearance, 0.001f) + } + + @Test + fun touchJoystickDeadZoneKeepsCenterStableAndPreservesFullRange() { + assertEquals(0f, applyTouchJoystickDeadZone(0.05f, 0.08f), 0.0001f) + assertEquals(0f, applyTouchJoystickDeadZone(-0.05f, 0.08f), 0.0001f) + assertEquals(1f, applyTouchJoystickDeadZone(1f, 0.08f), 0.0001f) + assertEquals(-1f, applyTouchJoystickDeadZone(-1f, 0.08f), 0.0001f) + } + + @Test + fun touchJoystickDeadZoneRescalesInputBeyondCenter() { + assertEquals(0.5f, applyTouchJoystickDeadZone(0.54f, 0.08f), 0.0001f) + assertEquals(-0.5f, applyTouchJoystickDeadZone(-0.54f, 0.08f), 0.0001f) + } + + @Test + fun touchAimZoneMapsFingerTravelToRightStickRange() { + val halfTravel = touchStickValue(deltaX = 36f, deltaY = -36f, maxTravel = 72f, deadZone = 0f) + val beyondZone = touchStickValue(deltaX = 144f, deltaY = 0f, maxTravel = 72f, deadZone = 0f) + + assertEquals(0.5f, halfTravel.x, 0.0001f) + assertEquals(-0.5f, halfTravel.y, 0.0001f) + assertEquals(1f, beyondZone.x, 0.0001f) + assertEquals(0f, beyondZone.y, 0.0001f) + } + + @Test + fun touchAimZoneSensitivityChangesRequiredFingerTravel() { + val moreSensitive = touchStickValue( + deltaX = 36f, + deltaY = 0f, + maxTravel = 72f, + deadZone = 0f, + sensitivity = 2f, + ) + val lessSensitive = touchStickValue( + deltaX = 36f, + deltaY = 0f, + maxTravel = 72f, + deadZone = 0f, + sensitivity = 0.5f, + ) + + assertEquals(1f, moreSensitive.x, 0.0001f) + assertEquals(0.25f, lessSensitive.x, 0.0001f) + } + + @Test + fun touchAimZoneScaleChangesAndBoundsTheInteractiveFootprint() { + assertEquals(0.36f, scaledAimZoneFraction(0.48f, 0.75f), 0.0001f) + assertEquals(0.72f, scaledAimZoneFraction(0.48f, 1.5f), 0.0001f) + assertEquals(1f, scaledAimZoneFraction(0.72f, 1.5f), 0.0001f) + } + + @Test + fun touchAimZoneUsesTheConfiguredDeadZoneAndRejectsInvalidGeometry() { + val insideDeadZone = touchStickValue(deltaX = 4f, deltaY = 0f, maxTravel = 72f, deadZone = 0.08f) + val invalid = touchStickValue(deltaX = 20f, deltaY = 0f, maxTravel = 0f, deadZone = 0f) + + assertEquals(0f, insideDeadZone.x, 0.0001f) + assertEquals(0f, insideDeadZone.y, 0.0001f) + assertEquals(0f, invalid.x, 0.0001f) + assertEquals(0f, invalid.y, 0.0001f) + } + + @Test + fun builtInControlVisibilityIsIndependent() { + val customized = AndroidTouchSettings() + .withControlVisible(TouchControlGroup.Dpad, false) + .withControlVisible(TouchControlGroup.RightStick, false) + + assertTrue(customized.isControlVisible(TouchControlGroup.FaceButtons)) + assertEquals(false, customized.isControlVisible(TouchControlGroup.Dpad)) + assertEquals(false, customized.isControlVisible(TouchControlGroup.RightStick)) + } + + @Test + fun programmableSlotsAreFixedWidthAndCycleThroughOff() { + val customized = AndroidTouchSettings() + .withExtraButtonAction(3, TouchExtraButtonAction.RightTrigger) + + assertEquals(TouchExtraButtonAction.RightTrigger, customized.extraButtonAction(3)) + assertEquals(TouchExtraButtonAction.None, nextTouchExtraButtonAction(TouchExtraButtonAction.Select)) + assertEquals( + TouchExtraButtonAction.RightTrigger, + customized.withExtraButtonAction(9, TouchExtraButtonAction.A).extraButtonAction(3), + ) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/TypographyUnitsTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/TypographyUnitsTest.kt new file mode 100644 index 000000000..e701ac77c --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/TypographyUnitsTest.kt @@ -0,0 +1,86 @@ +package com.opencloudgaming.opennow + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.TextUnitType +import androidx.compose.ui.unit.isSpecified +import androidx.compose.ui.unit.lerp +import com.opencloudgaming.opennow.ui.theme.OpenNowTypography +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Guards the one thing about [OpenNowTypography] that fails at runtime rather than at compile time. + * + * `TextUnit` arithmetic throws `IllegalArgumentException: Cannot perform operation for Sp and Em` + * when its operands use different units, and Material components lerp between typography styles — + * `OutlinedTextField` interpolates its label between `bodyLarge` and `bodySmall`. A revision that + * expressed tracking in `em` while leaving two styles on Material's `sp` defaults crashed the + * Stream settings page as soon as a labelled text field was composed. Nothing in the type system + * catches that, so it is checked here. + */ +class TypographyUnitsTest { + + private fun allStyles(typography: Typography): List> = listOf( + "displayLarge" to typography.displayLarge, + "displayMedium" to typography.displayMedium, + "displaySmall" to typography.displaySmall, + "headlineLarge" to typography.headlineLarge, + "headlineMedium" to typography.headlineMedium, + "headlineSmall" to typography.headlineSmall, + "titleLarge" to typography.titleLarge, + "titleMedium" to typography.titleMedium, + "titleSmall" to typography.titleSmall, + "bodyLarge" to typography.bodyLarge, + "bodyMedium" to typography.bodyMedium, + "bodySmall" to typography.bodySmall, + "labelLarge" to typography.labelLarge, + "labelMedium" to typography.labelMedium, + "labelSmall" to typography.labelSmall, + ) + + @Test + fun everyStyleDeclaresLetterSpacingInSp() { + allStyles(OpenNowTypography).forEach { (name, style) -> + assertTrue( + "$name has no explicit letterSpacing; it would inherit a unit we do not control", + style.letterSpacing.isSpecified, + ) + assertEquals( + "$name must express letterSpacing in sp — mixing sp and em crashes TextUnit arithmetic", + TextUnitType.Sp, + style.letterSpacing.type, + ) + } + } + + @Test + fun everyStylePairCanBeInterpolated() { + // Exactly what OutlinedTextField does to its label, across every pair so a future edit to + // any single style cannot reintroduce the crash. + val styles = allStyles(OpenNowTypography) + styles.forEach { (fromName, from) -> + styles.forEach { (toName, to) -> + runCatching { lerp(from.letterSpacing, to.letterSpacing, 0.5f) } + .onFailure { error -> + throw AssertionError("lerp($fromName, $toName) failed: ${error.message}") + } + } + } + } + + @Test + fun interpolatingAgainstMaterialDefaultsIsSafe() { + // Material lerps against its own hardcoded styles too, not only against ours. + val defaults = allStyles(Typography()) + allStyles(OpenNowTypography).forEach { (name, style) -> + defaults.forEach { (defaultName, default) -> + runCatching { lerp(style.letterSpacing, default.letterSpacing, 0.5f) } + .onFailure { error -> + throw AssertionError("lerp($name, Material $defaultName) failed: ${error.message}") + } + } + } + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/VideoDecoderPerformanceTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/VideoDecoderPerformanceTest.kt new file mode 100644 index 000000000..b0dbdcd9a --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/VideoDecoderPerformanceTest.kt @@ -0,0 +1,185 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.webrtc.EncodedImage +import org.webrtc.VideoCodecStatus +import org.webrtc.VideoDecoder + +class VideoDecoderPerformanceTest { + @Test + fun highFpsDecoderTuningPreservesExactUserSelection() { + assertEquals(120, mediaCodecPerformanceTargetFps(120)) + assertEquals(240, mediaCodecPerformanceTargetFps(240)) + assertEquals(340, mediaCodecPerformanceTargetFps(340)) + assertEquals(360, mediaCodecPerformanceTargetFps(360)) + } + + @Test + fun sixtyFpsUsesRealtimeDecoderScheduling() { + assertEquals(60, mediaCodecPerformanceTargetFps(60)) + } + + @Test + fun lowFpsDoesNotRequireDecoderPerformanceOverride() { + assertNull(mediaCodecPerformanceTargetFps(30)) + } + + @Test + fun mediaTekStandardLowLatencyRequiresAdvertisedSupportAtSixtyFps() { + assertTrue( + shouldEnableMediaTekStandardLowLatency( + decoderImplementationName = "c2.mtk.avc.decoder", + requestedFps = 60, + featureAdvertised = true, + ), + ) + assertFalse( + shouldEnableMediaTekStandardLowLatency( + decoderImplementationName = "c2.mtk.avc.decoder", + requestedFps = 30, + featureAdvertised = true, + ), + ) + assertFalse( + shouldEnableMediaTekStandardLowLatency( + decoderImplementationName = "c2.mtk.hevc.decoder", + requestedFps = 60, + featureAdvertised = false, + ), + ) + assertFalse( + shouldEnableMediaTekStandardLowLatency( + decoderImplementationName = "c2.qti.avc.decoder", + requestedFps = 60, + featureAdvertised = true, + ), + ) + } + + @Test + fun approvedHardwareDecoderGetsMediaCodecTuning() { + val hardwareDecoder = fakeDecoder() + + assertTrue( + shouldUseMediaCodecDecoderTuning( + selectedDecoder = hardwareDecoder, + approvedHardwareDecoder = hardwareDecoder, + requestedFps = 60, + lowLatencyEnabled = false, + ), + ) + } + + @Test + fun qualcommH264SixtyFpsBypassesPerformanceWrapper() { + val hardwareDecoder = fakeDecoder() + + assertFalse( + shouldUseMediaCodecDecoderTuning( + selectedDecoder = hardwareDecoder, + approvedHardwareDecoder = hardwareDecoder, + requestedFps = 60, + lowLatencyEnabled = false, + codec = VideoCodec.H264, + decoderImplementationName = "c2.qti.avc.decoder", + ), + ) + } + + @Test + fun qualcommGuardDoesNotDisableOtherPerformanceTuning() { + val hardwareDecoder = fakeDecoder() + + assertTrue( + shouldUseMediaCodecDecoderTuning( + selectedDecoder = hardwareDecoder, + approvedHardwareDecoder = hardwareDecoder, + requestedFps = 120, + lowLatencyEnabled = false, + codec = VideoCodec.H264, + decoderImplementationName = "c2.qti.avc.decoder", + ), + ) + assertTrue( + shouldUseMediaCodecDecoderTuning( + selectedDecoder = hardwareDecoder, + approvedHardwareDecoder = hardwareDecoder, + requestedFps = 60, + lowLatencyEnabled = false, + codec = VideoCodec.H265, + decoderImplementationName = "c2.qti.hevc.decoder", + ), + ) + assertTrue( + shouldUseMediaCodecDecoderTuning( + selectedDecoder = hardwareDecoder, + approvedHardwareDecoder = hardwareDecoder, + requestedFps = 60, + lowLatencyEnabled = false, + codec = VideoCodec.H264, + decoderImplementationName = "c2.mtk.avc.decoder", + ), + ) + } + + @Test + fun explicitLowLatencyStillUsesQualcommWrapper() { + val hardwareDecoder = fakeDecoder() + + assertTrue( + shouldUseMediaCodecDecoderTuning( + selectedDecoder = hardwareDecoder, + approvedHardwareDecoder = hardwareDecoder, + requestedFps = 60, + lowLatencyEnabled = true, + codec = VideoCodec.H264, + decoderImplementationName = "OMX.qcom.video.decoder.avc", + ), + ) + } + + @Test + fun nativeFallbackIsNotWrappedForPerformanceTuning() { + assertFalse( + shouldUseMediaCodecDecoderTuning( + selectedDecoder = fakeDecoder(), + approvedHardwareDecoder = null, + requestedFps = 60, + lowLatencyEnabled = false, + ), + ) + } + + @Test + fun nativeFallbackIsNotWrappedForLowLatencyTuning() { + assertFalse( + shouldUseMediaCodecDecoderTuning( + selectedDecoder = fakeDecoder(), + approvedHardwareDecoder = null, + requestedFps = 30, + lowLatencyEnabled = true, + ), + ) + } + + private fun fakeDecoder(): VideoDecoder = + object : VideoDecoder { + override fun initDecode( + settings: VideoDecoder.Settings?, + decodeCallback: VideoDecoder.Callback?, + ): VideoCodecStatus = error("unused") + + override fun release(): VideoCodecStatus = error("unused") + + override fun decode( + frame: EncodedImage?, + info: VideoDecoder.DecodeInfo?, + ): VideoCodecStatus = error("unused") + + override fun getImplementationName(): String = "test" + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/VirtualCursorTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/VirtualCursorTest.kt new file mode 100644 index 000000000..0f35e980e --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/VirtualCursorTest.kt @@ -0,0 +1,193 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import kotlin.math.abs + +/** + * Direct click has no absolute-positioning packet to lean on. Every new tap therefore reanchors the + * host cursor at its top-left boundary before moving to the mapped target; the retained cursor model + * is used only for relative movement during that press. These tests cover both the boundary anchor + * and the resize/rounding properties needed while a drag is active. + */ +class VirtualCursorTest { + + /** Plays the role of the host: applies the deltas we send and reports the true cursor. */ + private class FakeHost(var x: Float, var y: Float) { + fun apply(delta: CursorDelta?) { + if (delta == null) return + x += delta.dx + y += delta.dy + } + + fun applyClamped(delta: CursorDelta, width: Int, height: Int) { + x = (x + delta.dx).coerceIn(0f, (width - 1).toFloat()) + y = (y + delta.dy).coerceIn(0f, (height - 1).toFloat()) + } + } + + @Test + fun firstTapOfASessionAnchorsFromTheCentre() { + val cursor = VirtualCursor() + cursor.onStreamSize(1920, 1080) + assertEquals(StreamPoint(960f, 540f), cursor.position) + } + + @Test + fun tappingLandsExactlyOnTarget() { + val cursor = VirtualCursor() + cursor.onStreamSize(1920, 1080) + val host = FakeHost(960f, 540f) // starts where the model assumes + + host.apply(cursor.consumeDeltaTo(StreamPoint(100f, 200f))) + + assertEquals(100f, host.x, 0.5f) + assertEquals(200f, host.y, 0.5f) + } + + @Test + fun directClickReanchorsAnUnknownHostCursorBeforeMovingToTarget() { + val width = 1920 + val height = 1080 + val cursor = VirtualCursor() + cursor.onStreamSize(width, height) + val host = FakeHost(1733f, 941f) + + cursor.reanchorDeltasTo(StreamPoint(100f, 200f)).forEach { delta -> + host.applyClamped(delta, width, height) + } + + assertEquals(100f, host.x, 0.5f) + assertEquals(200f, host.y, 0.5f) + assertEquals(StreamPoint(100f, 200f), cursor.position) + } + + @Test + fun directClickClampsTheMappedBottomRightEdgeToARealDesktopPixel() { + val cursor = VirtualCursor() + cursor.onStreamSize(1920, 1080) + + cursor.reanchorDeltasTo(StreamPoint(1920f, 1080f)) + + assertEquals(StreamPoint(1919f, 1079f), cursor.position) + } + + /** + * The PiP regression itself. Nothing about a window resize reaches this class, so the only way + * to state it is: repeated size notifications must not disturb a model that is already correct. + */ + @Test + fun repeatedStreamSizeNotificationsDoNotDisturbTheModel() { + val cursor = VirtualCursor() + cursor.onStreamSize(1920, 1080) + val host = FakeHost(960f, 540f) + + // User moves the cursor to the bottom-right corner. + host.apply(cursor.consumeDeltaTo(StreamPoint(1900f, 1060f))) + assertEquals(1900f, host.x, 0.5f) + + // Enter PiP, leave PiP, rotate — each of which re-enters handle() and re-reports the + // stream size. The model must be untouched. + repeat(5) { cursor.onStreamSize(1920, 1080) } + assertEquals(StreamPoint(1900f, 1060f), cursor.position) + + // The next tap, in the opposite corner, must still land on target. + host.apply(cursor.consumeDeltaTo(StreamPoint(20f, 30f))) + assertEquals(20f, host.x, 0.5f) + assertEquals(30f, host.y, 0.5f) + } + + @Test + fun resolutionChangeRescalesTheModelRatherThanGuessing() { + val cursor = VirtualCursor() + cursor.onStreamSize(1920, 1080) + val host = FakeHost(960f, 540f) + + // Three quarters across, one quarter down. + host.apply(cursor.consumeDeltaTo(StreamPoint(1440f, 270f))) + + // The host switches to 1280x720; its cursor keeps the same relative position. + host.x = host.x / 1920f * 1280f + host.y = host.y / 1080f * 720f + cursor.onStreamSize(1280, 720) + + assertEquals(960f, cursor.position.x, 0.5f) + assertEquals(180f, cursor.position.y, 0.5f) + + // And a tap in the new space still lands where asked. + host.apply(cursor.consumeDeltaTo(StreamPoint(100f, 600f))) + assertEquals(100f, host.x, 0.5f) + assertEquals(600f, host.y, 0.5f) + } + + @Test + fun forgettingReAnchorsOnTheNextStreamSize() { + val cursor = VirtualCursor() + cursor.onStreamSize(1920, 1080) + cursor.consumeDeltaTo(StreamPoint(1900f, 1060f)) + + cursor.forget() + cursor.onStreamSize(1920, 1080) + + assertEquals(StreamPoint(960f, 540f), cursor.position) + } + + @Test + fun subPixelMovesAreNotSent() { + val cursor = VirtualCursor() + cursor.onStreamSize(1920, 1080) + + assertNull(cursor.consumeDeltaTo(StreamPoint(960.2f, 540.1f))) + // And the model did not drift by the rejected fraction either. + assertEquals(StreamPoint(960f, 540f), cursor.position) + } + + /** + * The wire format carries whole pixels, so each move rounds. Advancing the model by the target + * instead of by what was sent would absorb that residue every event and let the error compound; + * over a long drag that is the "cursor drifts away" symptom. + */ + @Test + fun errorDoesNotAccumulateOverALongDrag() { + val cursor = VirtualCursor() + cursor.onStreamSize(1920, 1080) + val host = FakeHost(960f, 540f) + + var target = 960f + repeat(500) { + target += 0.37f // deliberately fractional, so every step has a rounding residue + host.apply(cursor.consumeDeltaTo(StreamPoint(target, 540f))) + } + + // Bounded by a single rounding, not by 500 of them. + assertEquals( + "host drifted from the requested position by ${abs(host.x - target)}px", + target, + host.x, + 1f, + ) + } + + @Test + fun degenerateStreamSizeIsIgnored() { + val cursor = VirtualCursor() + cursor.onStreamSize(0, 1080) + cursor.onStreamSize(1920, 0) + // Still uninitialised, so the first real size anchors normally. + cursor.onStreamSize(1920, 1080) + assertEquals(StreamPoint(960f, 540f), cursor.position) + } + + @Test + fun nonFiniteTargetsAreDroppedWithoutPoisoningTheCursor() { + val cursor = VirtualCursor() + cursor.onStreamSize(1920, 1080) + + assertNull(cursor.consumeDeltaTo(StreamPoint(Float.NaN, 100f))) + assertEquals(emptyList(), cursor.reanchorDeltasTo(StreamPoint(100f, Float.POSITIVE_INFINITY))) + assertEquals(StreamPoint(960f, 540f), cursor.position) + + assertEquals(CursorDelta(-860, -340), cursor.consumeDeltaTo(StreamPoint(100f, 200f))) + } +} diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 000000000..da9fa6e26 --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,5 @@ +plugins { + id("com.android.application") version "9.3.2" apply false + id("org.jetbrains.kotlin.plugin.compose") version "2.3.20" apply false + id("org.jetbrains.kotlin.plugin.serialization") version "2.3.20" apply false +} diff --git a/android/docs/google-play-tv-screenshots/01-store.png b/android/docs/google-play-tv-screenshots/01-store.png new file mode 100644 index 000000000..08e3be3b5 Binary files /dev/null and b/android/docs/google-play-tv-screenshots/01-store.png differ diff --git a/android/docs/google-play-tv-screenshots/02-settings.png b/android/docs/google-play-tv-screenshots/02-settings.png new file mode 100644 index 000000000..56de63766 Binary files /dev/null and b/android/docs/google-play-tv-screenshots/02-settings.png differ diff --git a/android/docs/google-play-tv-screenshots/03-game-info-meccha-chameleon.png b/android/docs/google-play-tv-screenshots/03-game-info-meccha-chameleon.png new file mode 100644 index 000000000..6c7fe5e73 Binary files /dev/null and b/android/docs/google-play-tv-screenshots/03-game-info-meccha-chameleon.png differ diff --git a/android/docs/google-play-tv-screenshots/04-queueing.png b/android/docs/google-play-tv-screenshots/04-queueing.png new file mode 100644 index 000000000..ddbb924ec Binary files /dev/null and b/android/docs/google-play-tv-screenshots/04-queueing.png differ diff --git a/android/docs/visual-proof/a56-codec-fix-runtime.png b/android/docs/visual-proof/a56-codec-fix-runtime.png new file mode 100644 index 000000000..63811a0d2 Binary files /dev/null and b/android/docs/visual-proof/a56-codec-fix-runtime.png differ diff --git a/android/docs/visual-proof/android-app-diagnostics-consent.png b/android/docs/visual-proof/android-app-diagnostics-consent.png new file mode 100644 index 000000000..0141e24db Binary files /dev/null and b/android/docs/visual-proof/android-app-diagnostics-consent.png differ diff --git a/android/docs/visual-proof/android-app-sign-in.png b/android/docs/visual-proof/android-app-sign-in.png new file mode 100644 index 000000000..cf21d6d8f Binary files /dev/null and b/android/docs/visual-proof/android-app-sign-in.png differ diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 000000000..0b3cf7a6e --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,7 @@ +org.gradle.jvmargs=-Xmx6g -Dfile.encoding=UTF-8 +kotlin.daemon.jvmargs=-Xmx6g +org.gradle.caching=true +android.useAndroidX=true +android.nonTransitiveRClass=true +kotlin.code.style=official +org.gradle.configuration-cache=true diff --git a/android/gradle/gradle-daemon-jvm.properties b/android/gradle/gradle-daemon-jvm.properties new file mode 100644 index 000000000..baa28d154 --- /dev/null +++ b/android/gradle/gradle-daemon-jvm.properties @@ -0,0 +1,13 @@ +#This file is generated by updateDaemonJvm +toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect +toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect +toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect +toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect +toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/7083b89563e7ce20943037b8cd2b8cc2/redirect +toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/060bbb778a1f55ea705fdebd2ccfeab9/redirect +toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect +toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect +toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/d09679dc60fe5aa05ef7d03efdefac20/redirect +toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/ed4e3bf2f5e7c5d9aabc4cbd8acd555e/redirect +toolchainVendor=JETBRAINS +toolchainVersion=21 diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 000000000..d997cfc60 Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..1a704683a --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android/gradlew b/android/gradlew new file mode 100755 index 000000000..739907dfd --- /dev/null +++ b/android/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat new file mode 100644 index 000000000..e509b2dd8 --- /dev/null +++ b/android/gradlew.bat @@ -0,0 +1,93 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 000000000..17895f6af --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,21 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "OpenNOWAndroid" +include(":app") diff --git a/opennow-stable/src/main/gfn/games.test.ts b/opennow-stable/src/main/gfn/games.test.ts index 6a1e0ec80..0df82f9b5 100644 --- a/opennow-stable/src/main/gfn/games.test.ts +++ b/opennow-stable/src/main/gfn/games.test.ts @@ -145,3 +145,31 @@ test("does not duplicate primary catalog store variants from public data", () => assert.deepEqual(game?.variants.map((variant) => variant.store), ["Steam"]); }); + +test("does not add combined primary public store strings as launcher variants", () => { + const [game] = mergePublicGameVariants( + [ + { + id: "m1", + title: "M1", + selectedVariantIndex: 0, + variants: [ + { id: "steam", store: "Steam", supportedControls: [] }, + { id: "epic", store: "Epic", supportedControls: [] }, + ], + availableStores: ["Steam", "Epic"], + }, + ], + [ + publicGameToGameInfo({ + id: 123, + title: "M1", + store: "EPIC,STEAM", + status: "AVAILABLE", + }), + ], + ); + + assert.deepEqual(game?.variants.map((variant) => variant.store), ["Steam", "Epic"]); + assert.deepEqual(game?.availableStores, ["Steam", "Epic"]); +}); diff --git a/opennow-stable/src/main/gfn/games.ts b/opennow-stable/src/main/gfn/games.ts index c19194e1f..abc38cb5d 100644 --- a/opennow-stable/src/main/gfn/games.ts +++ b/opennow-stable/src/main/gfn/games.ts @@ -8,7 +8,7 @@ import type { } from "@shared/gfn"; import { isOwnedLibraryStatus } from "@shared/gfn"; import { cacheManager } from "../services/cacheManager"; -import { fetchPublicGamesUncached, mergePublicGameVariants } from "./publicGames"; +import { fetchPublicGamesUncached, hasSamePublicGameTitle, mergePublicGameVariants } from "./publicGames"; import { buildGfnGraphQlHeaders, buildGfnLcarsHeaders, @@ -788,19 +788,22 @@ ${appFields} cursor = endCursor; } - let games = dedupeGames(collectedApps.map(appToGame)); + const catalogGames = dedupeGames(collectedApps.map(appToGame)); const publicGames = await fetchPublicGames(); + let games = mergePublicGameVariants(catalogGames, publicGames); if (searchQuery.length > 0) { const publicSearchMatches = publicGames.filter((game) => matchesPublicGameSearch(game, searchQuery)); - games = dedupeGames([...games, ...publicSearchMatches]); + const publicOnlySearchMatches = publicSearchMatches.filter( + (publicGame) => !catalogGames.some((catalogGame) => hasSamePublicGameTitle(catalogGame, publicGame)), + ); + games = dedupeGames([...games, ...publicOnlySearchMatches]); } - const gamesWithPublicVariants = mergePublicGameVariants(games, publicGames); return { - games: gamesWithPublicVariants, + games, numberReturned, - numberSupported: Math.max(numberSupported, gamesWithPublicVariants.length), - totalCount: Math.max(totalCount, gamesWithPublicVariants.length), + numberSupported: Math.max(numberSupported, games.length), + totalCount: Math.max(totalCount, games.length), hasNextPage, endCursor: endCursor || undefined, searchQuery, diff --git a/opennow-stable/src/main/gfn/publicGames.ts b/opennow-stable/src/main/gfn/publicGames.ts index d94846965..2ea493e8c 100644 --- a/opennow-stable/src/main/gfn/publicGames.ts +++ b/opennow-stable/src/main/gfn/publicGames.ts @@ -22,6 +22,18 @@ const PRIMARY_CATALOG_STORE_KEYS = new Set([ "MICROSOFT_STORE", ]); +function splitPublicStoreKeys(store: string): string[] { + return store + .split(",") + .map((part) => normalizeGameStore(part.trim())) + .filter((part) => part.length > 0); +} + +function isPrimaryCatalogStoreValue(store: string): boolean { + const storeKeys = splitPublicStoreKeys(store); + return storeKeys.length > 0 && storeKeys.every((storeKey) => PRIMARY_CATALOG_STORE_KEYS.has(storeKey)); +} + export function inferPublicGameStore(item: RawPublicGame): string { const explicitStore = item.store?.trim(); if (explicitStore) { @@ -79,6 +91,11 @@ function normalizeTitleKey(title: string): string { .trim(); } +export function hasSamePublicGameTitle(left: GameInfo, right: GameInfo): boolean { + const leftKey = normalizeTitleKey(left.title); + return leftKey.length > 0 && leftKey === normalizeTitleKey(right.title); +} + function mergeSearchText(left?: string, right?: string): string | undefined { const merged = [left, right] .filter((value): value is string => typeof value === "string" && value.trim().length > 0) @@ -92,7 +109,7 @@ function getSupplementalPublicVariants(game: GameInfo, publicGame: GameInfo): Ga return publicGame.variants.filter((variant) => { const storeKey = normalizeGameStore(variant.store); - return !PRIMARY_CATALOG_STORE_KEYS.has(storeKey) && !existingStores.has(storeKey); + return !isPrimaryCatalogStoreValue(variant.store) && !existingStores.has(storeKey); }); } diff --git a/opennow-stable/src/main/settings.ts b/opennow-stable/src/main/settings.ts index 853f153ef..aa205e14c 100644 --- a/opennow-stable/src/main/settings.ts +++ b/opennow-stable/src/main/settings.ts @@ -154,7 +154,7 @@ const DEFAULT_STREAM_PREFERENCES = getDefaultStreamPreferences(); const CONTROLLER_THEME_STYLES_SET = new Set(["aurora", "nebula", "grid", "minimal", "pulse"]); const NATIVE_VIDEO_BACKEND_PREFERENCES = new Set(["auto", "d3d11", "d3d12"]); -const APP_ACCENT_COLORS = new Set(["green", "blue", "violet", "amber", "rose"]); +const APP_ACCENT_COLORS = new Set(["green", "blue", "violet", "rose"]); function clampThemeByte(value: unknown): number { const n = typeof value === "number" && Number.isFinite(value) ? Math.round(value) : NaN; diff --git a/opennow-stable/src/renderer/src/App.tsx b/opennow-stable/src/renderer/src/App.tsx index 6455e04b1..743fdd25e 100644 --- a/opennow-stable/src/renderer/src/App.tsx +++ b/opennow-stable/src/renderer/src/App.tsx @@ -95,6 +95,8 @@ import { StreamLoading } from "./components/StreamLoading"; import { ControllerStreamLoading } from "./components/controllerMode/ControllerStreamLoading"; import { StreamView } from "./components/StreamView"; import { QueueServerSelectModal } from "./components/QueueServerSelectModal"; +import { getStoreDisplayName, getStoreIconComponent } from "./components/GameCard"; +import { getStoreOptions as getLaunchStoreOptions } from "./lib/gameCardStores"; const DEFAULT_STREAM_PREFERENCES = getDefaultStreamPreferences(); @@ -291,7 +293,9 @@ export function App(): JSX.Element { const [removeAccountConfirmOpen, setRemoveAccountConfirmOpen] = useState(false); const [logoutConfirmOpen, setLogoutConfirmOpen] = useState(false); const [launchError, setLaunchError] = useState(null); + const [launchStorePickerGame, setLaunchStorePickerGame] = useState(null); const [queueModalGame, setQueueModalGame] = useState(null); + const [queueModalVariantId, setQueueModalVariantId] = useState(null); const [queueModalData, setQueueModalData] = useState(null); const [sessionStartedAtMs, setSessionStartedAtMs] = useState(null); const [remoteStreamWarning, setRemoteStreamWarning] = useState(null); @@ -2602,7 +2606,7 @@ export function App(): JSX.Element { }, [attemptSessionRecovery, diagnosticsStore, handleControllerMetaToggle, refreshNavbarActiveSession, resetLaunchRuntime, scheduleStableRecoveryReset, settings, streamMicLevel, streamVolume, t]); // Play game handler - const handlePlayGame = useCallback(async (game: GameInfo, options?: { bypassGuards?: boolean; streamingBaseUrl?: string }) => { + const handlePlayGame = useCallback(async (game: GameInfo, options?: { bypassGuards?: boolean; streamingBaseUrl?: string; variantId?: string }) => { if (!selectedProvider) return; console.log("handlePlayGame entry", { @@ -2621,7 +2625,7 @@ export function App(): JSX.Element { return; } - const selectedVariantId = variantByGameId[game.id] ?? defaultVariantId(game); + const selectedVariantId = options?.variantId ?? variantByGameId[game.id] ?? defaultVariantId(game); const selectedVariant = getSelectedVariant(game, selectedVariantId); const epicOwnershipError = getEpicOwnershipLaunchError(selectedVariant); if (epicOwnershipError) { @@ -2905,7 +2909,7 @@ export function App(): JSX.Element { ]); // Gate handler: shows queue server modal for FREE-tier users before launching - const handleInitiatePlay = useCallback(async (game: GameInfo) => { + const continueLaunchWithVariant = useCallback(async (game: GameInfo, variantId?: string) => { const effectiveTier = normalizeMembershipTier( subscriptionInfo?.membershipTier ?? authSession?.user.membershipTier, ); @@ -2913,12 +2917,12 @@ export function App(): JSX.Element { const isAllianceServer = isAllianceStreamingBaseUrl(effectiveStreamingBaseUrl); if (isAllianceServer) { setQueueModalData(null); - void handlePlayGame(game); + void handlePlayGame(game, { variantId }); return; } if (settings.hideServerSelector) { setQueueModalData(null); - void handlePlayGame(game); + void handlePlayGame(game, { variantId }); return; } if (isFreeUser && streamStatus === "idle" && !launchInFlightRef.current) { @@ -2937,14 +2941,14 @@ export function App(): JSX.Element { }, ); setQueueModalData(null); - void handlePlayGame(game); + void handlePlayGame(game, { variantId }); return; } const queueData = queueResult.value; if (!queueData || Object.keys(queueData).length === 0) { setQueueModalData(null); - void handlePlayGame(game); + void handlePlayGame(game, { variantId }); return; } @@ -2953,36 +2957,96 @@ export function App(): JSX.Element { "[QueueServerSelect] No eligible non-nuked PrintedWaste zones available, skipping queue checks.", ); setQueueModalData(null); - void handlePlayGame(game); + void handlePlayGame(game, { variantId }); return; } setQueueModalData(queueData); setQueueModalGame(game); + setQueueModalVariantId(variantId ?? null); } catch (error) { console.warn("[QueueServerSelect] PrintedWaste queue checks failed, launching without modal.", error); setQueueModalData(null); - void handlePlayGame(game); + void handlePlayGame(game, { variantId }); } return; } - void handlePlayGame(game); - }, [subscriptionInfo, authSession, streamStatus, handlePlayGame, effectiveStreamingBaseUrl]); + void handlePlayGame(game, { variantId }); + }, [subscriptionInfo, authSession, streamStatus, handlePlayGame, effectiveStreamingBaseUrl, settings.hideServerSelector]); + + const handleInitiatePlay = useCallback(async (game: GameInfo) => { + const storeOptions = getLaunchStoreOptions(game, variantByGameId[game.id]); + if (storeOptions.length > 1) { + setLaunchStorePickerGame(game); + return; + } + + await continueLaunchWithVariant(game, storeOptions[0]?.variantId); + }, [continueLaunchWithVariant, variantByGameId]); const handleQueueModalConfirm = useCallback((zoneUrl: string | null) => { const game = queueModalGame; + const variantId = queueModalVariantId ?? undefined; setQueueModalGame(null); + setQueueModalVariantId(null); setQueueModalData(null); if (game) { - void handlePlayGame(game, { streamingBaseUrl: zoneUrl ?? undefined }); + void handlePlayGame(game, { streamingBaseUrl: zoneUrl ?? undefined, variantId }); } - }, [queueModalGame, handlePlayGame]); + }, [queueModalGame, queueModalVariantId, handlePlayGame]); const handleQueueModalCancel = useCallback(() => { setQueueModalGame(null); + setQueueModalVariantId(null); setQueueModalData(null); }, []); + const launchStoreOptions = useMemo(() => ( + launchStorePickerGame + ? getLaunchStoreOptions(launchStorePickerGame, variantByGameId[launchStorePickerGame.id]).map((option) => ({ + ...option, + displayName: getStoreDisplayName(option.store), + IconComponent: getStoreIconComponent(option.store), + })) + : [] + ), [launchStorePickerGame, variantByGameId]); + + const handleLaunchStoreCancel = useCallback(() => { + setLaunchStorePickerGame(null); + }, []); + + const handleLaunchStoreSelect = useCallback((variantId: string) => { + const game = launchStorePickerGame; + if (!game) { + return; + } + + setLaunchStorePickerGame(null); + handleSelectGameVariant(game.id, variantId); + void continueLaunchWithVariant(game, variantId); + }, [continueLaunchWithVariant, handleSelectGameVariant, launchStorePickerGame]); + + useEffect(() => { + if (!launchStorePickerGame) { + return; + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setLaunchStorePickerGame(null); + } + }; + + window.addEventListener("keydown", handleKeyDown); + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + + return () => { + window.removeEventListener("keydown", handleKeyDown); + document.body.style.overflow = previousOverflow; + }; + }, [launchStorePickerGame]); + useEffect(() => { if (!logoutConfirmOpen && !removeAccountConfirmOpen) return; diff --git a/opennow-stable/src/renderer/src/components/QueueAdPreview.tsx b/opennow-stable/src/renderer/src/components/QueueAdPreview.tsx index 7ecab6612..c6c7331aa 100644 --- a/opennow-stable/src/renderer/src/components/QueueAdPreview.tsx +++ b/opennow-stable/src/renderer/src/components/QueueAdPreview.tsx @@ -1,5 +1,5 @@ -import { AlertTriangle, Loader2, PauseCircle, PlayCircle, RefreshCcw, XCircle } from "lucide-react"; -import { forwardRef, useEffect, useImperativeHandle, useRef, useState, type JSX } from "react"; +import { AlertTriangle, Loader2, PauseCircle, PlayCircle, RefreshCcw, Volume2, VolumeX, XCircle } from "lucide-react"; +import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState, type JSX } from "react"; type QueueAdPlaybackState = "loading" | "playing" | "paused" | "stalled" | "blocked" | "timeout" | "error"; export type QueueAdPlaybackEvent = "loadstart" | "playing" | "paused" | "ended" | "timeupdate" | "error"; @@ -101,6 +101,18 @@ export const QueueAdPreview = forwardRef("loading"); + const [controlsVisible, setControlsVisible] = useState(false); + const [muted, setMuted] = useState(false); + const mutedRef = useRef(false); + + const revealControls = useCallback((): void => { + setControlsVisible(true); + }, []); + + const setMutedState = (nextMuted: boolean): void => { + mutedRef.current = nextMuted; + setMuted(nextMuted); + }; const setPlayback = (next: QueueAdPlaybackState): void => { playbackStateRef.current = next; @@ -115,27 +127,61 @@ export const QueueAdPreview = forwardRef { + const video = videoRef.current; + if (!video) { + return; + } + revealControls(); + if (video.paused || video.ended) { + void attemptPlayback(); + return; } + video.pause(); + }; + + const toggleMuted = (): void => { + const video = videoRef.current; + if (!video) { + return; + } + const nextMuted = !video.muted; + video.muted = nextMuted; + setMutedState(nextMuted); + revealControls(); }; useImperativeHandle(ref, () => ({ @@ -166,6 +212,27 @@ export const QueueAdPreview = forwardRef { + const video = videoRef.current; + if (video) { + video.muted = muted; + } + }, [muted, mediaUrl]); + + useEffect(() => { + if (!controlsVisible || playbackState !== "playing") { + return; + } + + const timeout = window.setTimeout(() => { + setControlsVisible(false); + }, 2400); + + return () => { + window.clearTimeout(timeout); + }; + }, [controlsVisible, playbackState]); + useEffect(() => { const video = videoRef.current; if (!video) { @@ -188,6 +255,7 @@ export const QueueAdPreview = forwardRef { + setMutedState(video.muted); setPlayback("playing"); onPlaybackEventRef.current?.("playing"); }; @@ -201,6 +269,7 @@ export const QueueAdPreview = forwardRef { if (!video.ended && playbackStateRef.current === "playing") { setPlayback("paused"); + revealControls(); onPlaybackEventRef.current?.("paused"); } }; @@ -222,17 +291,20 @@ export const QueueAdPreview = forwardRef { if (!video.paused && !video.ended) { setPlayback("stalled"); + revealControls(); } }; const handleStalled = (): void => { if (!video.paused && !video.ended) { setPlayback("stalled"); + revealControls(); } }; const handleError = (): void => { setPlayback("error"); + revealControls(); onPlaybackEventRef.current?.("error"); restoreOriginalVolume(); }; @@ -259,15 +331,18 @@ export const QueueAdPreview = forwardRef -
+
+
)} +
+ + +
{presentation.retryLabel && (
@@ -302,4 +403,4 @@ export const QueueAdPreview = forwardRef ); -}); \ No newline at end of file +}); diff --git a/opennow-stable/src/renderer/src/components/StatsOverlay.tsx b/opennow-stable/src/renderer/src/components/StatsOverlay.tsx index 52c87de6a..ca9c95b6c 100644 --- a/opennow-stable/src/renderer/src/components/StatsOverlay.tsx +++ b/opennow-stable/src/renderer/src/components/StatsOverlay.tsx @@ -23,6 +23,7 @@ export function StatsOverlay({ const rttColor = getRttColor(stats.rttMs); const showPacketLoss = stats.packetLossPercent > 0; const hasData = stats.resolution !== "" || stats.bitrateKbps > 0; + const currentFps = stats.decodeFps > 0 ? `${stats.decodeFps} FPS` : "-- FPS"; if (!hasData) { return ( @@ -40,7 +41,7 @@ export function StatsOverlay({ {/* Resolution & FPS */}
- {stats.resolution} @ {stats.decodeFps} FPS + {stats.resolution || "--"} @ {currentFps}
{/* Bitrate */} diff --git a/opennow-stable/src/renderer/src/gfn/webrtcClient.ts b/opennow-stable/src/renderer/src/gfn/webrtcClient.ts index b8eaf1365..ee8b672e0 100644 --- a/opennow-stable/src/renderer/src/gfn/webrtcClient.ts +++ b/opennow-stable/src/renderer/src/gfn/webrtcClient.ts @@ -1149,8 +1149,8 @@ export class GfnWebRtcClient { this.diagnostics.colorCodec = describeColorQuality(settings.colorQuality); this.diagnostics.isHdr = this.isHdr; this.diagnostics.targetBitrateKbps = this.negotiatedMaxBitrateKbps; - this.diagnostics.decodeFps = settings.fps; - this.diagnostics.renderFps = settings.fps; + this.diagnostics.decodeFps = 0; + this.diagnostics.renderFps = 0; } private closeDataChannels(): void { diff --git a/opennow-stable/src/renderer/src/lib/uiCustomization.ts b/opennow-stable/src/renderer/src/lib/uiCustomization.ts index 7ab18e63f..6465f9886 100644 --- a/opennow-stable/src/renderer/src/lib/uiCustomization.ts +++ b/opennow-stable/src/renderer/src/lib/uiCustomization.ts @@ -10,7 +10,6 @@ const ACCENT_COLOR_OPTIONS: readonly AccentColorOption[] = [ { value: "green", labelKey: "settings.interface.accentColorGreen", hex: "#58d98a" }, { value: "blue", labelKey: "settings.interface.accentColorBlue", hex: "#4f8cff" }, { value: "violet", labelKey: "settings.interface.accentColorViolet", hex: "#8b6cff" }, - { value: "amber", labelKey: "settings.interface.accentColorAmber", hex: "#f5b942" }, { value: "rose", labelKey: "settings.interface.accentColorRose", hex: "#ff6b9a" }, ] as const; diff --git a/opennow-stable/src/renderer/src/styles.css b/opennow-stable/src/renderer/src/styles.css index 635149dd8..5786fd5db 100644 --- a/opennow-stable/src/renderer/src/styles.css +++ b/opennow-stable/src/renderer/src/styles.css @@ -6381,89 +6381,75 @@ button.game-card-store-chip.owned.active:hover { font-weight: 700; } -.queue-ad-preview-status { - display: flex; +.queue-ad-preview-overlay-action, +.queue-ad-preview-control { + display: inline-flex; align-items: center; - justify-content: space-between; - gap: 12px; - padding: 10px 12px; - border-radius: 12px; - background: rgba(0, 0, 0, 0.28); -} - -.queue-ad-preview-status-main { - display: flex; - align-items: flex-start; - gap: 10px; - min-width: 0; -} - -.queue-ad-preview-copy { - display: flex; - flex-direction: column; - gap: 2px; - min-width: 0; + justify-content: center; + border: 1px solid rgba(255, 255, 255, 0.16); + background: rgba(0, 0, 0, 0.52); + color: #fff; + font: inherit; + cursor: pointer; + backdrop-filter: blur(12px); + transition: background var(--t-normal), border-color var(--t-normal), transform var(--t-normal), opacity var(--t-normal); } -.queue-ad-preview-label { - color: var(--ink); - font-size: 0.78rem; +.queue-ad-preview-overlay-action { + gap: 6px; + padding: 8px 11px; + border-radius: 999px; + font-size: 0.76rem; font-weight: 700; } -.queue-ad-preview-message { - color: var(--ink-soft); - font-size: 0.74rem; - line-height: 1.4; -} - -.queue-ad-preview-icon { - flex: 0 0 auto; - color: var(--accent); - margin-top: 1px; -} - -.queue-ad-preview-icon--spinning { - animation: spin 1s linear infinite; -} - -.queue-ad-preview--blocked .queue-ad-preview-icon, -.queue-ad-preview--stalled .queue-ad-preview-icon, -.queue-ad-preview--timeout .queue-ad-preview-icon { - color: #ffd166; +.queue-ad-preview-controls { + position: absolute; + right: 10px; + bottom: 10px; + display: flex; + gap: 8px; + opacity: 0; + pointer-events: none; + transform: translateY(4px); + transition: opacity var(--t-normal), transform var(--t-normal); } -.queue-ad-preview--error .queue-ad-preview-icon { - color: #f87171; +.queue-ad-preview:hover .queue-ad-preview-controls, +.queue-ad-preview:focus-within .queue-ad-preview-controls, +.queue-ad-preview--controls-visible .queue-ad-preview-controls, +.queue-ad-preview--paused .queue-ad-preview-controls, +.queue-ad-preview--blocked .queue-ad-preview-controls, +.queue-ad-preview--stalled .queue-ad-preview-controls, +.queue-ad-preview--timeout .queue-ad-preview-controls, +.queue-ad-preview--error .queue-ad-preview-controls { + opacity: 1; + pointer-events: auto; + transform: translateY(0); } -.queue-ad-preview-retry { - display: inline-flex; - align-items: center; - gap: 6px; - flex: 0 0 auto; - padding: 7px 10px; - border: 1px solid rgba(255, 255, 255, 0.1); - border-radius: 999px; - background: rgba(255, 255, 255, 0.06); - color: var(--ink); - font: inherit; - font-size: 0.74rem; - font-weight: 600; - cursor: pointer; - transition: background var(--t-normal), border-color var(--t-normal), transform var(--t-normal); +.queue-ad-preview-control { + width: 36px; + height: 36px; + border-radius: 50%; } -.queue-ad-preview-retry:hover { - background: rgba(255, 255, 255, 0.1); - border-color: rgba(255, 255, 255, 0.2); +.queue-ad-preview-overlay-action:hover, +.queue-ad-preview-control:hover { + background: rgba(255, 255, 255, 0.16); + border-color: rgba(255, 255, 255, 0.3); transform: translateY(-1px); } -.queue-ad-preview-retry:active { +.queue-ad-preview-overlay-action:active, +.queue-ad-preview-control:active { transform: translateY(0); } +.queue-ad-preview-icon--spinning { + animation: spin 1s linear infinite; +} + .sload-message { margin: 0; font-size: 0.95rem; @@ -10278,22 +10264,6 @@ button.game-card-store-chip.owned.active:hover { object-fit: cover; } -.csl-ad-media .queue-ad-preview-status { - background: rgba(0, 0, 0, 0.32); -} - -.csl-ad-media .queue-ad-preview-label { - font-size: 0.82rem; -} - -.csl-ad-media .queue-ad-preview-message { - font-size: 0.78rem; -} - -.csl-ad-media .queue-ad-preview-retry { - background: rgba(255, 255, 255, 0.08); -} - .csl-error-panel { display: flex; flex-direction: column; @@ -10409,4 +10379,3 @@ button.game-card-store-chip.owned.active:hover { max-height: min(72vh, 760px); vertical-align: middle; } - diff --git a/opennow-stable/src/shared/gfn.ts b/opennow-stable/src/shared/gfn.ts index c7ed0ee4f..80f4d2ad0 100644 --- a/opennow-stable/src/shared/gfn.ts +++ b/opennow-stable/src/shared/gfn.ts @@ -130,7 +130,7 @@ export function colorQualityIs10Bit(cq: ColorQuality): boolean { /** Controller-mode XMB background visual preset */ export type ControllerThemeStyle = "aurora" | "nebula" | "grid" | "minimal" | "pulse"; -export type AppAccentColor = "green" | "blue" | "violet" | "amber" | "rose"; +export type AppAccentColor = "green" | "blue" | "violet" | "rose"; /** RGB tint for controller-mode background (0–255 each) */ export interface ControllerThemeRgb {