diff --git a/app/src/main/app/PluviaApp.kt b/app/src/main/app/PluviaApp.kt index 02dc0ac2c..18bf7190d 100644 --- a/app/src/main/app/PluviaApp.kt +++ b/app/src/main/app/PluviaApp.kt @@ -4,6 +4,7 @@ import android.app.Application import android.os.Bundle import android.os.Looper import android.util.Log +import com.winlator.cmod.app.config.DeviceProfileSettings import com.winlator.cmod.app.db.PluviaDatabase import com.winlator.cmod.app.update.UpdateService import com.winlator.cmod.feature.stores.gog.service.GOGAuthManager @@ -71,6 +72,9 @@ class PluviaApp : Application() { // Cached probe for devices whose native stack still needs system libjpeg preloaded. preloadSystemLibraries() + runCatching { DeviceProfileSettings.seedFromDetection(this) } + .onFailure { Log.w("PluviaApp", "device profile detection failed", it) } + registerRefreshRateLifecycleCallbacks() PrefManager.install(this) diff --git a/app/src/main/app/config/DeviceProfile.kt b/app/src/main/app/config/DeviceProfile.kt new file mode 100644 index 000000000..d37f65bcb --- /dev/null +++ b/app/src/main/app/config/DeviceProfile.kt @@ -0,0 +1,92 @@ +package com.winlator.cmod.app.config + +import android.os.Build +import android.util.Log +import com.winlator.cmod.R +import java.util.Locale + +enum class DeviceProfile( + val prefValue: String, + val assetToken: String, + val titleRes: Int, + val summaryRes: Int, +) { + DEFAULT("default", "", R.string.device_profile_default, R.string.device_profile_default_summary), + ASTRA_2("astra2", "astra2", R.string.device_profile_astra_2, R.string.device_profile_astra_2_summary), + ; + + companion object { + private const val TAG = "DeviceProfile" + + private val MARKETING_NAME_KEYS = + listOf( + "ro.vendor.product.ztename", + "ro.vendor.feature.nubia.exif.module", + "ro.product.marketname", + "ro.vendor.product.marketname", + ) + + private val ASTRA_2_NAME_TOKENS = listOf("astra 2", "astra2") + + private val ASTRA_2_BUILD_TOKENS = listOf("pq85p01", "np06j", "np06p") + + @JvmStatic + fun fromPrefValue(value: String?): DeviceProfile = + entries.firstOrNull { it.prefValue.equals(value, ignoreCase = true) } ?: DEFAULT + + @JvmStatic + fun detect(): DeviceProfile = + classify(buildHaystack(), marketingName()) + + internal fun classify( + buildHaystack: String, + marketingName: String, + ): DeviceProfile { + val name = marketingName.lowercase(Locale.ROOT) + val build = buildHaystack.lowercase(Locale.ROOT) + if (ASTRA_2_NAME_TOKENS.any { name.contains(it) }) return ASTRA_2 + if (ASTRA_2_BUILD_TOKENS.any { build.contains(it) }) return ASTRA_2 + if (ASTRA_2_NAME_TOKENS.any { build.contains(it) }) return ASTRA_2 + return DEFAULT + } + + @JvmStatic + fun deviceLabel(): String { + val marketing = marketingName().trim() + if (marketing.isNotEmpty()) return marketing + val model = Build.MODEL.orEmpty().trim() + val manufacturer = Build.MANUFACTURER.orEmpty().trim() + if (model.isEmpty()) return manufacturer + if (manufacturer.isEmpty()) return model + if (model.lowercase(Locale.ROOT).startsWith(manufacturer.lowercase(Locale.ROOT))) return model + return "$manufacturer $model" + } + + private fun buildHaystack(): String = + listOf( + Build.MANUFACTURER, + Build.BRAND, + Build.MODEL, + Build.DEVICE, + Build.PRODUCT, + Build.HARDWARE, + ).joinToString(" ") { it.orEmpty() } + + private fun marketingName(): String { + for (key in MARKETING_NAME_KEYS) { + val value = readProperty(key) + if (!value.isNullOrBlank()) return value + } + return "" + } + + private fun readProperty(key: String): String? = + try { + val clazz = Class.forName("android.os.SystemProperties") + (clazz.getMethod("get", String::class.java).invoke(null, key) as? String)?.ifEmpty { null } + } catch (e: Throwable) { + Log.w(TAG, "SystemProperties unavailable for $key", e) + null + } + } +} diff --git a/app/src/main/app/config/DeviceProfileSettings.kt b/app/src/main/app/config/DeviceProfileSettings.kt new file mode 100644 index 000000000..80643ffcc --- /dev/null +++ b/app/src/main/app/config/DeviceProfileSettings.kt @@ -0,0 +1,165 @@ +package com.winlator.cmod.app.config + +import android.content.Context +import androidx.preference.PreferenceManager +import com.winlator.cmod.R + +enum class DeviceProfileFeature( + val titleRes: Int, + val summaryRes: Int, + val integrated: Boolean, +) { + INPUT_CONTROL_LAYOUTS( + R.string.device_profile_feature_input_layouts, + R.string.device_profile_feature_input_layouts_summary, + true, + ), + ADAPTIVE_JOYSTICKS( + R.string.device_profile_feature_adaptive_joysticks, + R.string.device_profile_feature_adaptive_joysticks_summary, + true, + ), + LIBRARY_ICONS( + R.string.device_profile_feature_library_icons, + R.string.device_profile_feature_library_icons_summary, + true, + ), + SESSION_MENU_SIZES( + R.string.device_profile_feature_session_menu, + R.string.device_profile_feature_session_menu_summary, + true, + ), + SHORTCUT_RECOMMENDATIONS( + R.string.device_profile_feature_shortcut_defaults, + R.string.device_profile_feature_shortcut_defaults_summary, + false, + ), +} + +object DeviceProfileSettings { + const val KEY_PROFILE = "device_profile" + const val KEY_DETECTED = "device_profile_detected" + const val STEAM_HEADER_ASPECT = 460f / 215f + const val LIBRARY_TITLE_STRIP_DP = 24f + const val STOCK_LIBRARY_CARD_FACTOR = 1.25f + + @Volatile + private var cached: DeviceProfile? = null + + private fun prefs(context: Context) = PreferenceManager.getDefaultSharedPreferences(context) + + @JvmStatic + fun current(context: Context): DeviceProfile { + cached?.let { return it } + val resolved = DeviceProfile.fromPrefValue(prefs(context).getString(KEY_PROFILE, null)) + cached = resolved + return resolved + } + + @JvmStatic + fun setCurrent( + context: Context, + profile: DeviceProfile, + ) { + prefs(context).edit().putString(KEY_PROFILE, profile.prefValue).apply() + cached = profile + } + + @JvmStatic + fun detected(context: Context): DeviceProfile = + DeviceProfile.fromPrefValue(prefs(context).getString(KEY_DETECTED, null)) + + @JvmStatic + fun isUserOverridden(context: Context): Boolean = current(context) != detected(context) + + @JvmStatic + fun seedFromDetection(context: Context) { + val preferences = prefs(context) + val detected = DeviceProfile.detect() + val editor = preferences.edit().putString(KEY_DETECTED, detected.prefValue) + if (!preferences.contains(KEY_PROFILE)) { + editor.putString(KEY_PROFILE, detected.prefValue) + cached = detected + } + editor.apply() + } + + @JvmStatic + fun invalidate() { + cached = null + } + + @JvmStatic + fun assetProfilesToken(context: Context): String = current(context).assetToken + + @JvmStatic + fun adaptiveJoysticksDefault(context: Context): Boolean = current(context) == DeviceProfile.ASTRA_2 + + @JvmStatic + fun adaptiveJoysticksDefaultExtra(context: Context): String = if (adaptiveJoysticksDefault(context)) "1" else "0" + + @JvmStatic + fun preferWideArtwork(context: Context): Boolean = preferWideArtwork(current(context)) + + @JvmStatic + fun libraryImageAspect(context: Context): Float? = libraryImageAspect(current(context)) + + @JvmStatic + fun libraryTitleStripDp(): Float = LIBRARY_TITLE_STRIP_DP + + @JvmStatic + fun libraryColumns( + context: Context, + widthDp: Int, + portrait: Boolean, + ): Int = libraryColumns(current(context), widthDp, portrait) + + @JvmStatic + fun sessionActionCardMaxAspect(context: Context): Float = sessionActionCardMaxAspect(current(context)) + + internal fun preferWideArtwork(profile: DeviceProfile): Boolean = profile == DeviceProfile.ASTRA_2 + + internal fun libraryImageAspect(profile: DeviceProfile): Float? = + when (profile) { + DeviceProfile.ASTRA_2 -> STEAM_HEADER_ASPECT + DeviceProfile.DEFAULT -> null + } + + internal fun libraryColumns( + profile: DeviceProfile, + widthDp: Int, + portrait: Boolean, + ): Int = + when (profile) { + DeviceProfile.ASTRA_2 -> if (portrait) 2 else 4 + DeviceProfile.DEFAULT -> stockLibraryColumns(widthDp) + } + + internal fun stockLibraryColumns(widthDp: Int): Int = + when { + widthDp <= 0 -> 4 + widthDp < 480 -> 2 + widthDp < 700 -> 3 + else -> 4 + } + + internal fun sessionActionCardMaxAspect(profile: DeviceProfile): Float = + when (profile) { + DeviceProfile.ASTRA_2 -> 1.0f + DeviceProfile.DEFAULT -> Float.MAX_VALUE + } + + internal fun actionCardRowHeight( + availableHeight: Float, + cardWidth: Float, + rows: Int, + spacing: Float, + minHeight: Float, + maxAspect: Float, + ): Float { + val safeRows = rows.coerceAtLeast(1) + val fill = (availableHeight - spacing * (safeRows - 1)) / safeRows + val capped = if (maxAspect == Float.MAX_VALUE) fill else minOf(fill, cardWidth * maxAspect) + return maxOf(capped, minHeight) + } +} diff --git a/app/src/main/app/shell/UnifiedActivityStores.kt b/app/src/main/app/shell/UnifiedActivityStores.kt index 301ff1cba..f39da9a31 100644 --- a/app/src/main/app/shell/UnifiedActivityStores.kt +++ b/app/src/main/app/shell/UnifiedActivityStores.kt @@ -144,6 +144,7 @@ import androidx.navigation.navArgument import coil.compose.AsyncImage import coil.imageLoader import coil.request.ImageRequest +import com.winlator.cmod.app.config.DeviceProfileSettings import com.winlator.cmod.BuildConfig import com.winlator.cmod.R import com.winlator.cmod.app.PluviaApp @@ -435,11 +436,12 @@ internal fun UnifiedActivity.GameCapsule( } } } else { + val preferWide = !useLibraryCapsule && !listMode && DeviceProfileSettings.preferWideArtwork(context) val imageModel = - remember(app.id, gogGame, epicGame, useLibraryCapsule, listMode, artworkCacheRefreshKey) { + remember(app.id, gogGame, epicGame, useLibraryCapsule, listMode, preferWide, artworkCacheRefreshKey) { StoreArtworkCache.imageModel( context, - StoreArtworkCache.primaryRef(app, gogGame, epicGame, useLibraryCapsule, listMode), + StoreArtworkCache.primaryRef(app, gogGame, epicGame, useLibraryCapsule, listMode, preferWide), ) } AsyncImage( diff --git a/app/src/main/assets/inputcontrols/profiles-astra2/controls-1.icp b/app/src/main/assets/inputcontrols/profiles-astra2/controls-1.icp new file mode 100644 index 000000000..c086a56f7 --- /dev/null +++ b/app/src/main/assets/inputcontrols/profiles-astra2/controls-1.icp @@ -0,0 +1,265 @@ +{ + "id": 1, + "name": "RTS", + "cursorSpeed": 1, + "elements": [ + { + "type": "RANGE_BUTTON", + "shape": "CIRCLE", + "bindings": [ + "NONE", + "NONE", + "NONE", + "NONE" + ], + "scale": 1.0, + "x": 0.175, + "y": 0.091398, + "toggleSwitch": false, + "text": "", + "iconId": 0, + "range": "FROM_F1_TO_F12" + }, + { + "type": "RANGE_BUTTON", + "shape": "CIRCLE", + "bindings": [ + "NONE", + "NONE", + "NONE", + "NONE" + ], + "scale": 1.0, + "x": 0.825, + "y": 0.091398, + "toggleSwitch": false, + "text": "", + "iconId": 0, + "range": "FROM_1_TO_12" + }, + { + "type": "BUTTON", + "shape": "SQUARE", + "bindings": [ + "KEY_ESC", + "NONE", + "NONE", + "NONE" + ], + "scale": 1.05, + "x": 0.125, + "y": 0.389785, + "toggleSwitch": false, + "text": "", + "iconId": 0 + }, + { + "type": "BUTTON", + "shape": "SQUARE", + "bindings": [ + "KEY_DEL", + "NONE", + "NONE", + "NONE" + ], + "scale": 1.05, + "x": 0.208333, + "y": 0.389785, + "toggleSwitch": false, + "text": "", + "iconId": 0 + }, + { + "type": "BUTTON", + "shape": "SQUARE", + "bindings": [ + "KEY_TAB", + "NONE", + "NONE", + "NONE" + ], + "scale": 1.05, + "x": 0.125, + "y": 0.514113, + "toggleSwitch": false, + "text": "", + "iconId": 0 + }, + { + "type": "BUTTON", + "shape": "SQUARE", + "bindings": [ + "KEY_UP", + "NONE", + "NONE", + "NONE" + ], + "scale": 1.05, + "x": 0.208333, + "y": 0.514113, + "toggleSwitch": false, + "text": "", + "iconId": 3 + }, + { + "type": "BUTTON", + "shape": "SQUARE", + "bindings": [ + "KEY_LEFT", + "NONE", + "NONE", + "NONE" + ], + "scale": 1.05, + "x": 0.125, + "y": 0.638441, + "toggleSwitch": false, + "text": "", + "iconId": 2 + }, + { + "type": "BUTTON", + "shape": "SQUARE", + "bindings": [ + "KEY_RIGHT", + "NONE", + "NONE", + "NONE" + ], + "scale": 1.05, + "x": 0.208333, + "y": 0.638441, + "toggleSwitch": false, + "text": "", + "iconId": 4 + }, + { + "type": "BUTTON", + "shape": "SQUARE", + "bindings": [ + "KEY_SHIFT_L", + "NONE", + "NONE", + "NONE" + ], + "scale": 1.05, + "x": 0.125, + "y": 0.762769, + "toggleSwitch": false, + "text": "", + "iconId": 0 + }, + { + "type": "BUTTON", + "shape": "SQUARE", + "bindings": [ + "KEY_DOWN", + "NONE", + "NONE", + "NONE" + ], + "scale": 1.05, + "x": 0.208333, + "y": 0.762769, + "toggleSwitch": false, + "text": "", + "iconId": 5 + }, + { + "type": "BUTTON", + "shape": "SQUARE", + "bindings": [ + "KEY_CTRL_L", + "NONE", + "NONE", + "NONE" + ], + "scale": 1.05, + "x": 0.125, + "y": 0.887097, + "toggleSwitch": false, + "text": "", + "iconId": 0 + }, + { + "type": "BUTTON", + "shape": "SQUARE", + "bindings": [ + "KEY_ALT_L", + "NONE", + "NONE", + "NONE" + ], + "scale": 1.05, + "x": 0.208333, + "y": 0.887097, + "toggleSwitch": false, + "text": "", + "iconId": 0 + }, + { + "type": "BUTTON", + "shape": "SQUARE", + "bindings": [ + "MOUSE_RIGHT_BUTTON", + "NONE", + "NONE", + "NONE" + ], + "scale": 1.05, + "x": 0.825, + "y": 0.638441, + "toggleSwitch": false, + "text": "", + "iconId": 0 + }, + { + "type": "BUTTON", + "shape": "SQUARE", + "bindings": [ + "KEY_SPACE", + "NONE", + "NONE", + "NONE" + ], + "scale": 1.05, + "x": 0.825, + "y": 0.766129, + "toggleSwitch": false, + "text": "", + "iconId": 0 + }, + { + "type": "BUTTON", + "shape": "SQUARE", + "bindings": [ + "KEY_BKSP", + "NONE", + "NONE", + "NONE" + ], + "scale": 1.05, + "x": 0.908333, + "y": 0.638441, + "toggleSwitch": false, + "text": "", + "iconId": 0 + }, + { + "type": "BUTTON", + "shape": "SQUARE", + "bindings": [ + "KEY_ENTER", + "NONE", + "NONE", + "NONE" + ], + "scale": 1.05, + "x": 0.908333, + "y": 0.766129, + "toggleSwitch": false, + "text": "", + "iconId": 0 + } + ] +} \ No newline at end of file diff --git a/app/src/main/assets/inputcontrols/profiles-astra2/controls-2.icp b/app/src/main/assets/inputcontrols/profiles-astra2/controls-2.icp new file mode 100644 index 000000000..b8fbc6f25 --- /dev/null +++ b/app/src/main/assets/inputcontrols/profiles-astra2/controls-2.icp @@ -0,0 +1,215 @@ +{ + "id": 2, + "name": "Template (12 buttons)", + "cursorSpeed": 1, + "elements": [ + { + "type": "BUTTON", + "shape": "RECT", + "bindings": [ + "NONE", + "NONE", + "NONE", + "NONE" + ], + "scale": 1.2, + "x": 0.095833, + "y": 0.091398, + "toggleSwitch": false, + "text": "", + "iconId": 0 + }, + { + "type": "BUTTON", + "shape": "RECT", + "bindings": [ + "NONE", + "NONE", + "NONE", + "NONE" + ], + "scale": 1.2, + "x": 0.904167, + "y": 0.091398, + "toggleSwitch": false, + "text": "", + "iconId": 0 + }, + { + "type": "BUTTON", + "shape": "RECT", + "bindings": [ + "NONE", + "NONE", + "NONE", + "NONE" + ], + "scale": 1.2, + "x": 0.095833, + "y": 0.212366, + "toggleSwitch": false, + "text": "", + "iconId": 0 + }, + { + "type": "BUTTON", + "shape": "RECT", + "bindings": [ + "NONE", + "NONE", + "NONE", + "NONE" + ], + "scale": 1.2, + "x": 0.904167, + "y": 0.212366, + "toggleSwitch": false, + "text": "", + "iconId": 0 + }, + { + "type": "D_PAD", + "shape": "CIRCLE", + "bindings": [ + "KEY_W", + "KEY_D", + "KEY_S", + "KEY_A" + ], + "scale": 0.95, + "x": 0.166667, + "y": 0.752688, + "toggleSwitch": false, + "text": "", + "iconId": 0 + }, + { + "type": "BUTTON", + "shape": "CIRCLE", + "bindings": [ + "NONE", + "NONE", + "NONE", + "NONE" + ], + "scale": 1.05, + "x": 0.870833, + "y": 0.370968, + "toggleSwitch": false, + "text": "", + "iconId": 0 + }, + { + "type": "BUTTON", + "shape": "CIRCLE", + "bindings": [ + "NONE", + "NONE", + "NONE", + "NONE" + ], + "scale": 1.05, + "x": 0.8075, + "y": 0.473118, + "toggleSwitch": false, + "text": "", + "iconId": 0 + }, + { + "type": "BUTTON", + "shape": "CIRCLE", + "bindings": [ + "NONE", + "NONE", + "NONE", + "NONE" + ], + "scale": 1.05, + "x": 0.934167, + "y": 0.473118, + "toggleSwitch": false, + "text": "", + "iconId": 0 + }, + { + "type": "BUTTON", + "shape": "CIRCLE", + "bindings": [ + "NONE", + "NONE", + "NONE", + "NONE" + ], + "scale": 1.05, + "x": 0.870833, + "y": 0.575269, + "toggleSwitch": false, + "text": "", + "iconId": 0 + }, + { + "type": "BUTTON", + "shape": "CIRCLE", + "bindings": [ + "NONE", + "NONE", + "NONE", + "NONE" + ], + "scale": 1.05, + "x": 0.270833, + "y": 0.645161, + "toggleSwitch": false, + "text": "", + "iconId": 0 + }, + { + "type": "BUTTON", + "shape": "CIRCLE", + "bindings": [ + "NONE", + "NONE", + "NONE", + "NONE" + ], + "scale": 1.05, + "x": 0.729167, + "y": 0.645161, + "toggleSwitch": false, + "text": "", + "iconId": 0 + }, + { + "type": "BUTTON", + "shape": "ROUND_RECT", + "bindings": [ + "NONE", + "NONE", + "NONE", + "NONE" + ], + "scale": 0.95, + "x": 0.4375, + "y": 0.900538, + "toggleSwitch": false, + "text": "", + "iconId": 0 + }, + { + "type": "BUTTON", + "shape": "ROUND_RECT", + "bindings": [ + "NONE", + "NONE", + "NONE", + "NONE" + ], + "scale": 0.95, + "x": 0.5625, + "y": 0.900538, + "toggleSwitch": false, + "text": "", + "iconId": 0 + } + ] +} \ No newline at end of file diff --git a/app/src/main/assets/inputcontrols/profiles-astra2/controls-3.icp b/app/src/main/assets/inputcontrols/profiles-astra2/controls-3.icp new file mode 100644 index 000000000..1b2734eff --- /dev/null +++ b/app/src/main/assets/inputcontrols/profiles-astra2/controls-3.icp @@ -0,0 +1,247 @@ +{ + "id": 3, + "name": "Virtual Gamepad", + "cursorSpeed": 1, + "elements": [ + { + "type": "BUTTON", + "shape": "ROUND_RECT", + "bindings": [ + "GAMEPAD_BUTTON_L2", + "NONE", + "NONE", + "NONE" + ], + "scale": 1.2, + "x": 0.095833, + "y": 0.091398, + "toggleSwitch": false, + "text": "", + "iconId": 0 + }, + { + "type": "BUTTON", + "shape": "ROUND_RECT", + "bindings": [ + "GAMEPAD_BUTTON_R2", + "NONE", + "NONE", + "NONE" + ], + "scale": 1.2, + "x": 0.904167, + "y": 0.091398, + "toggleSwitch": false, + "text": "", + "iconId": 0 + }, + { + "type": "BUTTON", + "shape": "ROUND_RECT", + "bindings": [ + "GAMEPAD_BUTTON_L1", + "NONE", + "NONE", + "NONE" + ], + "scale": 1.2, + "x": 0.095833, + "y": 0.212366, + "toggleSwitch": false, + "text": "", + "iconId": 0 + }, + { + "type": "BUTTON", + "shape": "ROUND_RECT", + "bindings": [ + "GAMEPAD_BUTTON_R1", + "NONE", + "NONE", + "NONE" + ], + "scale": 1.2, + "x": 0.904167, + "y": 0.212366, + "toggleSwitch": false, + "text": "", + "iconId": 0 + }, + { + "type": "D_PAD", + "shape": "CIRCLE", + "bindings": [ + "GAMEPAD_DPAD_UP", + "GAMEPAD_DPAD_RIGHT", + "GAMEPAD_DPAD_DOWN", + "GAMEPAD_DPAD_LEFT" + ], + "scale": 0.95, + "x": 0.129167, + "y": 0.473118, + "toggleSwitch": false, + "text": "", + "iconId": 0 + }, + { + "type": "STICK", + "shape": "CIRCLE", + "bindings": [ + "GAMEPAD_LEFT_THUMB_UP", + "GAMEPAD_LEFT_THUMB_RIGHT", + "GAMEPAD_LEFT_THUMB_DOWN", + "GAMEPAD_LEFT_THUMB_LEFT" + ], + "scale": 1.0, + "x": 0.166667, + "y": 0.752688, + "toggleSwitch": false, + "text": "", + "iconId": 0 + }, + { + "type": "STICK", + "shape": "CIRCLE", + "bindings": [ + "GAMEPAD_RIGHT_THUMB_UP", + "GAMEPAD_RIGHT_THUMB_RIGHT", + "GAMEPAD_RIGHT_THUMB_DOWN", + "GAMEPAD_RIGHT_THUMB_LEFT" + ], + "scale": 1.0, + "x": 0.833333, + "y": 0.752688, + "toggleSwitch": false, + "text": "", + "iconId": 0 + }, + { + "type": "BUTTON", + "shape": "CIRCLE", + "bindings": [ + "GAMEPAD_BUTTON_Y", + "NONE", + "NONE", + "NONE" + ], + "scale": 1.05, + "x": 0.870833, + "y": 0.370968, + "toggleSwitch": false, + "text": "", + "iconId": 0 + }, + { + "type": "BUTTON", + "shape": "CIRCLE", + "bindings": [ + "GAMEPAD_BUTTON_X", + "NONE", + "NONE", + "NONE" + ], + "scale": 1.05, + "x": 0.8075, + "y": 0.473118, + "toggleSwitch": false, + "text": "", + "iconId": 0 + }, + { + "type": "BUTTON", + "shape": "CIRCLE", + "bindings": [ + "GAMEPAD_BUTTON_B", + "NONE", + "NONE", + "NONE" + ], + "scale": 1.05, + "x": 0.934167, + "y": 0.473118, + "toggleSwitch": false, + "text": "", + "iconId": 0 + }, + { + "type": "BUTTON", + "shape": "CIRCLE", + "bindings": [ + "GAMEPAD_BUTTON_A", + "NONE", + "NONE", + "NONE" + ], + "scale": 1.05, + "x": 0.870833, + "y": 0.575269, + "toggleSwitch": false, + "text": "", + "iconId": 0 + }, + { + "type": "BUTTON", + "shape": "CIRCLE", + "bindings": [ + "GAMEPAD_BUTTON_R3", + "NONE", + "NONE", + "NONE" + ], + "scale": 1.05, + "x": 0.270833, + "y": 0.645161, + "toggleSwitch": false, + "text": "", + "iconId": 0 + }, + { + "type": "BUTTON", + "shape": "CIRCLE", + "bindings": [ + "GAMEPAD_BUTTON_L3", + "NONE", + "NONE", + "NONE" + ], + "scale": 1.05, + "x": 0.729167, + "y": 0.645161, + "toggleSwitch": false, + "text": "", + "iconId": 0 + }, + { + "type": "BUTTON", + "shape": "ROUND_RECT", + "bindings": [ + "GAMEPAD_BUTTON_SELECT", + "NONE", + "NONE", + "NONE" + ], + "scale": 0.95, + "x": 0.4375, + "y": 0.900538, + "toggleSwitch": false, + "text": "", + "iconId": 16 + }, + { + "type": "BUTTON", + "shape": "ROUND_RECT", + "bindings": [ + "GAMEPAD_BUTTON_START", + "NONE", + "NONE", + "NONE" + ], + "scale": 0.95, + "x": 0.5625, + "y": 0.900538, + "toggleSwitch": false, + "text": "", + "iconId": 15 + } + ] +} \ No newline at end of file diff --git a/app/src/main/assets/inputcontrols/profiles-astra2/controls-6.icp b/app/src/main/assets/inputcontrols/profiles-astra2/controls-6.icp new file mode 100644 index 000000000..eeee11d71 --- /dev/null +++ b/app/src/main/assets/inputcontrols/profiles-astra2/controls-6.icp @@ -0,0 +1,265 @@ +{ + "id": 6, + "name": "FPS", + "cursorSpeed": 1.0000001, + "elements": [ + { + "type": "RANGE_BUTTON", + "shape": "CIRCLE", + "bindings": [ + "NONE", + "NONE", + "NONE", + "NONE" + ], + "scale": 1.0, + "x": 0.175, + "y": 0.091398, + "toggleSwitch": false, + "text": "", + "iconId": 0, + "range": "FROM_F1_TO_F12" + }, + { + "type": "RANGE_BUTTON", + "shape": "CIRCLE", + "bindings": [ + "NONE", + "NONE", + "NONE", + "NONE" + ], + "scale": 1.0, + "x": 0.825, + "y": 0.091398, + "toggleSwitch": false, + "text": "", + "iconId": 0, + "range": "FROM_1_TO_12" + }, + { + "type": "RADIAL_MENU", + "shape": "CIRCLE", + "bindings": [ + "KEY_I", + "KEY_Z", + "KEY_X", + "KEY_M" + ], + "scale": 1.1, + "x": 0.095833, + "y": 0.241935, + "toggleSwitch": false, + "text": "", + "iconId": 0 + }, + { + "type": "RADIAL_MENU", + "shape": "CIRCLE", + "bindings": [ + "KEY_F", + "KEY_G", + "KEY_C", + "KEY_T" + ], + "scale": 1.1, + "x": 0.904167, + "y": 0.241935, + "toggleSwitch": false, + "text": "", + "iconId": 0 + }, + { + "type": "BUTTON", + "shape": "CIRCLE", + "bindings": [ + "KEY_SHIFT_L", + "NONE", + "NONE", + "NONE" + ], + "scale": 1.05, + "x": 0.129167, + "y": 0.473118, + "toggleSwitch": true, + "text": "", + "iconId": 0 + }, + { + "type": "BUTTON", + "shape": "CIRCLE", + "bindings": [ + "KEY_R", + "NONE", + "NONE", + "NONE" + ], + "scale": 1.05, + "x": 0.870833, + "y": 0.370968, + "toggleSwitch": false, + "text": "", + "iconId": 0 + }, + { + "type": "BUTTON", + "shape": "CIRCLE", + "bindings": [ + "KEY_E", + "NONE", + "NONE", + "NONE" + ], + "scale": 1.05, + "x": 0.8075, + "y": 0.473118, + "toggleSwitch": false, + "text": "", + "iconId": 0 + }, + { + "type": "BUTTON", + "shape": "CIRCLE", + "bindings": [ + "MOUSE_LEFT_BUTTON", + "NONE", + "NONE", + "NONE" + ], + "scale": 1.05, + "x": 0.934167, + "y": 0.473118, + "toggleSwitch": false, + "text": "", + "iconId": 0 + }, + { + "type": "BUTTON", + "shape": "CIRCLE", + "bindings": [ + "KEY_SPACE", + "NONE", + "NONE", + "NONE" + ], + "scale": 1.05, + "x": 0.870833, + "y": 0.575269, + "toggleSwitch": false, + "text": "", + "iconId": 0 + }, + { + "type": "STICK", + "shape": "CIRCLE", + "bindings": [ + "KEY_W", + "KEY_D", + "KEY_S", + "KEY_A" + ], + "scale": 1.0, + "x": 0.166667, + "y": 0.752688, + "toggleSwitch": false, + "text": "", + "iconId": 0 + }, + { + "type": "BUTTON", + "shape": "CIRCLE", + "bindings": [ + "MOUSE_RIGHT_BUTTON", + "NONE", + "NONE", + "NONE" + ], + "scale": 1.0, + "x": 0.833333, + "y": 0.752688, + "toggleSwitch": false, + "text": "", + "iconId": 0 + }, + { + "type": "BUTTON", + "shape": "CIRCLE", + "bindings": [ + "KEY_CTRL_L", + "NONE", + "NONE", + "NONE" + ], + "scale": 1.05, + "x": 0.270833, + "y": 0.645161, + "toggleSwitch": true, + "text": "", + "iconId": 0 + }, + { + "type": "BUTTON", + "shape": "CIRCLE", + "bindings": [ + "KEY_ALT_L", + "NONE", + "NONE", + "NONE" + ], + "scale": 1.05, + "x": 0.729167, + "y": 0.645161, + "toggleSwitch": true, + "text": "", + "iconId": 0 + }, + { + "type": "BUTTON", + "shape": "CIRCLE", + "bindings": [ + "KEY_TAB", + "NONE", + "NONE", + "NONE" + ], + "scale": 1.05, + "x": 0.6875, + "y": 0.880376, + "toggleSwitch": false, + "text": "", + "iconId": 0 + }, + { + "type": "BUTTON", + "shape": "ROUND_RECT", + "bindings": [ + "KEY_ESC", + "NONE", + "NONE", + "NONE" + ], + "scale": 0.95, + "x": 0.4375, + "y": 0.900538, + "toggleSwitch": false, + "text": "", + "iconId": 0 + }, + { + "type": "BUTTON", + "shape": "ROUND_RECT", + "bindings": [ + "KEY_ENTER", + "NONE", + "NONE", + "NONE" + ], + "scale": 0.95, + "x": 0.5625, + "y": 0.900538, + "toggleSwitch": false, + "text": "", + "iconId": 0 + } + ] +} \ No newline at end of file diff --git a/app/src/main/assets/inputcontrols/profiles-astra2/controls-7.icp b/app/src/main/assets/inputcontrols/profiles-astra2/controls-7.icp new file mode 100644 index 000000000..a27877e59 --- /dev/null +++ b/app/src/main/assets/inputcontrols/profiles-astra2/controls-7.icp @@ -0,0 +1,247 @@ +{ + "id": 7, + "name": "GameHub", + "cursorSpeed": 1, + "elements": [ + { + "type": "BUTTON", + "shape": "ROUND_RECT", + "bindings": [ + "GAMEPAD_BUTTON_L2", + "NONE", + "NONE", + "NONE" + ], + "scale": 1.2, + "x": 0.095833, + "y": 0.091398, + "toggleSwitch": false, + "text": "", + "iconId": 0 + }, + { + "type": "BUTTON", + "shape": "ROUND_RECT", + "bindings": [ + "GAMEPAD_BUTTON_R2", + "NONE", + "NONE", + "NONE" + ], + "scale": 1.2, + "x": 0.904167, + "y": 0.091398, + "toggleSwitch": false, + "text": "", + "iconId": 0 + }, + { + "type": "BUTTON", + "shape": "ROUND_RECT", + "bindings": [ + "GAMEPAD_BUTTON_L1", + "NONE", + "NONE", + "NONE" + ], + "scale": 1.2, + "x": 0.095833, + "y": 0.212366, + "toggleSwitch": false, + "text": "", + "iconId": 0 + }, + { + "type": "BUTTON", + "shape": "ROUND_RECT", + "bindings": [ + "GAMEPAD_BUTTON_R1", + "NONE", + "NONE", + "NONE" + ], + "scale": 1.2, + "x": 0.904167, + "y": 0.212366, + "toggleSwitch": false, + "text": "", + "iconId": 0 + }, + { + "type": "D_PAD", + "shape": "CIRCLE", + "bindings": [ + "GAMEPAD_DPAD_UP", + "GAMEPAD_DPAD_RIGHT", + "GAMEPAD_DPAD_DOWN", + "GAMEPAD_DPAD_LEFT" + ], + "scale": 0.95, + "x": 0.129167, + "y": 0.473118, + "toggleSwitch": false, + "text": "", + "iconId": 0 + }, + { + "type": "STICK", + "shape": "CIRCLE", + "bindings": [ + "GAMEPAD_LEFT_THUMB_UP", + "GAMEPAD_LEFT_THUMB_RIGHT", + "GAMEPAD_LEFT_THUMB_DOWN", + "GAMEPAD_LEFT_THUMB_LEFT" + ], + "scale": 1.0, + "x": 0.166667, + "y": 0.752688, + "toggleSwitch": false, + "text": "", + "iconId": 0 + }, + { + "type": "STICK", + "shape": "CIRCLE", + "bindings": [ + "GAMEPAD_RIGHT_THUMB_UP", + "GAMEPAD_RIGHT_THUMB_RIGHT", + "GAMEPAD_RIGHT_THUMB_DOWN", + "GAMEPAD_RIGHT_THUMB_LEFT" + ], + "scale": 1.0, + "x": 0.833333, + "y": 0.752688, + "toggleSwitch": false, + "text": "", + "iconId": 0 + }, + { + "type": "BUTTON", + "shape": "CIRCLE", + "bindings": [ + "GAMEPAD_BUTTON_Y", + "NONE", + "NONE", + "NONE" + ], + "scale": 1.05, + "x": 0.870833, + "y": 0.370968, + "toggleSwitch": false, + "text": "", + "iconId": 0 + }, + { + "type": "BUTTON", + "shape": "CIRCLE", + "bindings": [ + "GAMEPAD_BUTTON_X", + "NONE", + "NONE", + "NONE" + ], + "scale": 1.05, + "x": 0.8075, + "y": 0.473118, + "toggleSwitch": false, + "text": "", + "iconId": 0 + }, + { + "type": "BUTTON", + "shape": "CIRCLE", + "bindings": [ + "GAMEPAD_BUTTON_B", + "NONE", + "NONE", + "NONE" + ], + "scale": 1.05, + "x": 0.934167, + "y": 0.473118, + "toggleSwitch": false, + "text": "", + "iconId": 0 + }, + { + "type": "BUTTON", + "shape": "CIRCLE", + "bindings": [ + "GAMEPAD_BUTTON_A", + "NONE", + "NONE", + "NONE" + ], + "scale": 1.05, + "x": 0.870833, + "y": 0.575269, + "toggleSwitch": false, + "text": "", + "iconId": 0 + }, + { + "type": "BUTTON", + "shape": "CIRCLE", + "bindings": [ + "GAMEPAD_BUTTON_R3", + "NONE", + "NONE", + "NONE" + ], + "scale": 1.05, + "x": 0.270833, + "y": 0.645161, + "toggleSwitch": false, + "text": "", + "iconId": 0 + }, + { + "type": "BUTTON", + "shape": "CIRCLE", + "bindings": [ + "GAMEPAD_BUTTON_L3", + "NONE", + "NONE", + "NONE" + ], + "scale": 1.05, + "x": 0.729167, + "y": 0.645161, + "toggleSwitch": false, + "text": "", + "iconId": 0 + }, + { + "type": "BUTTON", + "shape": "ROUND_RECT", + "bindings": [ + "GAMEPAD_BUTTON_SELECT", + "NONE", + "NONE", + "NONE" + ], + "scale": 0.95, + "x": 0.4375, + "y": 0.900538, + "toggleSwitch": false, + "text": "", + "iconId": 16 + }, + { + "type": "BUTTON", + "shape": "ROUND_RECT", + "bindings": [ + "GAMEPAD_BUTTON_START", + "NONE", + "NONE", + "NONE" + ], + "scale": 0.95, + "x": 0.5625, + "y": 0.900538, + "toggleSwitch": false, + "text": "", + "iconId": 15 + } + ] +} \ No newline at end of file diff --git a/app/src/main/assets/inputcontrols/profiles-astra2/controls-8.icp b/app/src/main/assets/inputcontrols/profiles-astra2/controls-8.icp new file mode 100644 index 000000000..57a28e79e --- /dev/null +++ b/app/src/main/assets/inputcontrols/profiles-astra2/controls-8.icp @@ -0,0 +1,231 @@ +{ + "id": 8, + "name": "No R-Stick", + "cursorSpeed": 1, + "elements": [ + { + "type": "BUTTON", + "shape": "ROUND_RECT", + "bindings": [ + "GAMEPAD_BUTTON_L2", + "NONE", + "NONE", + "NONE" + ], + "scale": 1.2, + "x": 0.095833, + "y": 0.091398, + "toggleSwitch": false, + "text": "", + "iconId": 0 + }, + { + "type": "BUTTON", + "shape": "ROUND_RECT", + "bindings": [ + "GAMEPAD_BUTTON_R2", + "NONE", + "NONE", + "NONE" + ], + "scale": 1.2, + "x": 0.904167, + "y": 0.091398, + "toggleSwitch": false, + "text": "", + "iconId": 0 + }, + { + "type": "BUTTON", + "shape": "ROUND_RECT", + "bindings": [ + "GAMEPAD_BUTTON_L1", + "NONE", + "NONE", + "NONE" + ], + "scale": 1.2, + "x": 0.095833, + "y": 0.212366, + "toggleSwitch": false, + "text": "", + "iconId": 0 + }, + { + "type": "BUTTON", + "shape": "ROUND_RECT", + "bindings": [ + "GAMEPAD_BUTTON_R1", + "NONE", + "NONE", + "NONE" + ], + "scale": 1.2, + "x": 0.904167, + "y": 0.212366, + "toggleSwitch": false, + "text": "", + "iconId": 0 + }, + { + "type": "D_PAD", + "shape": "CIRCLE", + "bindings": [ + "GAMEPAD_DPAD_UP", + "GAMEPAD_DPAD_RIGHT", + "GAMEPAD_DPAD_DOWN", + "GAMEPAD_DPAD_LEFT" + ], + "scale": 0.95, + "x": 0.129167, + "y": 0.473118, + "toggleSwitch": false, + "text": "", + "iconId": 0 + }, + { + "type": "STICK", + "shape": "CIRCLE", + "bindings": [ + "GAMEPAD_LEFT_THUMB_UP", + "GAMEPAD_LEFT_THUMB_RIGHT", + "GAMEPAD_LEFT_THUMB_DOWN", + "GAMEPAD_LEFT_THUMB_LEFT" + ], + "scale": 1.0, + "x": 0.166667, + "y": 0.752688, + "toggleSwitch": false, + "text": "", + "iconId": 0 + }, + { + "type": "BUTTON", + "shape": "CIRCLE", + "bindings": [ + "GAMEPAD_BUTTON_Y", + "NONE", + "NONE", + "NONE" + ], + "scale": 1.05, + "x": 0.870833, + "y": 0.370968, + "toggleSwitch": false, + "text": "", + "iconId": 0 + }, + { + "type": "BUTTON", + "shape": "CIRCLE", + "bindings": [ + "GAMEPAD_BUTTON_X", + "NONE", + "NONE", + "NONE" + ], + "scale": 1.05, + "x": 0.8075, + "y": 0.473118, + "toggleSwitch": false, + "text": "", + "iconId": 0 + }, + { + "type": "BUTTON", + "shape": "CIRCLE", + "bindings": [ + "GAMEPAD_BUTTON_B", + "NONE", + "NONE", + "NONE" + ], + "scale": 1.05, + "x": 0.934167, + "y": 0.473118, + "toggleSwitch": false, + "text": "", + "iconId": 0 + }, + { + "type": "BUTTON", + "shape": "CIRCLE", + "bindings": [ + "GAMEPAD_BUTTON_A", + "NONE", + "NONE", + "NONE" + ], + "scale": 1.05, + "x": 0.870833, + "y": 0.575269, + "toggleSwitch": false, + "text": "", + "iconId": 0 + }, + { + "type": "BUTTON", + "shape": "CIRCLE", + "bindings": [ + "GAMEPAD_BUTTON_R3", + "NONE", + "NONE", + "NONE" + ], + "scale": 1.05, + "x": 0.270833, + "y": 0.645161, + "toggleSwitch": false, + "text": "", + "iconId": 0 + }, + { + "type": "BUTTON", + "shape": "CIRCLE", + "bindings": [ + "GAMEPAD_BUTTON_L3", + "NONE", + "NONE", + "NONE" + ], + "scale": 1.05, + "x": 0.729167, + "y": 0.645161, + "toggleSwitch": false, + "text": "", + "iconId": 0 + }, + { + "type": "BUTTON", + "shape": "ROUND_RECT", + "bindings": [ + "GAMEPAD_BUTTON_SELECT", + "NONE", + "NONE", + "NONE" + ], + "scale": 0.95, + "x": 0.4375, + "y": 0.900538, + "toggleSwitch": false, + "text": "", + "iconId": 16 + }, + { + "type": "BUTTON", + "shape": "ROUND_RECT", + "bindings": [ + "GAMEPAD_BUTTON_START", + "NONE", + "NONE", + "NONE" + ], + "scale": 0.95, + "x": 0.5625, + "y": 0.900538, + "toggleSwitch": false, + "text": "", + "iconId": 15 + } + ] +} \ No newline at end of file diff --git a/app/src/main/feature/settings/containers/ContainerSettingsComposeDialog.kt b/app/src/main/feature/settings/containers/ContainerSettingsComposeDialog.kt index 95de2b86d..c66487b02 100644 --- a/app/src/main/feature/settings/containers/ContainerSettingsComposeDialog.kt +++ b/app/src/main/feature/settings/containers/ContainerSettingsComposeDialog.kt @@ -12,6 +12,7 @@ import androidx.appcompat.app.AppCompatDialog import androidx.compose.foundation.layout.widthIn import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Modifier +import com.winlator.cmod.app.config.DeviceProfileSettings import com.winlator.cmod.runtime.display.environment.components.NetworkingSettings import com.winlator.cmod.shared.ui.nav.PANE_DIR_ACTIVATE import com.winlator.cmod.shared.ui.nav.PaneNavWindowHandlers @@ -440,7 +441,10 @@ class ContainerSettingsComposeDialog @JvmOverloads constructor( state.enableDInput.value = true } state.adaptiveJoysticks.value = - c?.getExtra(InputControlsView.EXTRA_ADAPTIVE_JOYSTICKS, "0") == "1" + c?.getExtra( + InputControlsView.EXTRA_ADAPTIVE_JOYSTICKS, + DeviceProfileSettings.adaptiveJoysticksDefaultExtra(context), + ).let { it ?: DeviceProfileSettings.adaptiveJoysticksDefaultExtra(context) } == "1" state.fullscreenStretched.value = c?.isFullscreenStretched() ?: false state.useUnixLibs.value = c?.isUseUnixLibs() ?: true diff --git a/app/src/main/feature/settings/device/DeviceProfileScreen.kt b/app/src/main/feature/settings/device/DeviceProfileScreen.kt new file mode 100644 index 000000000..187953a1f --- /dev/null +++ b/app/src/main/feature/settings/device/DeviceProfileScreen.kt @@ -0,0 +1,496 @@ +package com.winlator.cmod.feature.settings.device + +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.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Check +import androidx.compose.material.icons.outlined.ExpandLess +import androidx.compose.material.icons.outlined.ExpandMore +import androidx.compose.material.icons.outlined.Refresh +import androidx.compose.material.icons.outlined.TabletAndroid +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +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.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.unit.dp +import androidx.compose.ui.unit.sp +import com.winlator.cmod.R +import com.winlator.cmod.app.config.DeviceProfile +import com.winlator.cmod.app.config.DeviceProfileFeature +import com.winlator.cmod.app.config.DeviceProfileSettings +import com.winlator.cmod.feature.settings.SettingsNavBridge +import com.winlator.cmod.runtime.input.controls.InputControlsManager +import com.winlator.cmod.shared.ui.focus.rememberSettingsContentNav +import com.winlator.cmod.shared.ui.layout.isPortraitLayout +import com.winlator.cmod.shared.ui.nav.LocalPaneNav +import com.winlator.cmod.shared.ui.nav.paneNavItem + +private val PageBg = Color(0xFF101018) +private val PageText = Color(0xFFF0F4FF) +private val PageSub = Color(0xFF93A6BC) +private val PageCard = Color(0xFF181822) +private val PageAccent = Color(0xFF4FC3F7) +private val PageMuted = Color(0xFF5A6B80) + +@Composable +private fun SectionHeading( + textRes: Int, + descRes: Int, + topPadding: Int, +) { + Text( + stringResource(textRes), + color = PageSub, + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 1.sp, + modifier = Modifier.padding(top = topPadding.dp), + ) + Text( + stringResource(descRes), + color = PageSub, + style = MaterialTheme.typography.labelMedium, + ) + Spacer(Modifier.size(2.dp)) +} + +@Composable +private fun DeviceHeaderCard( + profile: DeviceProfile, + deviceLabel: String, + overridden: Boolean, + expanded: Boolean, + onToggle: () -> Unit, +) { + val shape = RoundedCornerShape(14.dp) + Row( + modifier = + Modifier + .fillMaxWidth() + .clip(shape) + .background(PageAccent.copy(alpha = 0.08f)) + .border(1.dp, PageAccent.copy(alpha = 0.35f), shape) + .clickable { onToggle() } + .paneNavItem( + cornerRadius = 14.dp, + onActivate = onToggle, + highlightColor = PageAccent, + tapToSelect = true, + ).padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = + Modifier + .size(40.dp) + .clip(RoundedCornerShape(11.dp)) + .background(PageAccent.copy(alpha = 0.20f)), + contentAlignment = Alignment.Center, + ) { + Icon( + Icons.Outlined.TabletAndroid, + contentDescription = null, + tint = PageAccent, + modifier = Modifier.size(22.dp), + ) + } + + Spacer(Modifier.width(14.dp)) + + Column(Modifier.weight(1f)) { + Text( + stringResource(profile.titleRes), + color = PageText, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + Text( + deviceLabel, + color = PageSub, + fontSize = 11.sp, + ) + Text( + stringResource( + if (overridden) { + R.string.device_profile_source_manual + } else { + R.string.device_profile_source_detected + }, + ), + color = PageMuted, + fontSize = 11.sp, + ) + } + + Text( + stringResource(R.string.device_profile_change), + color = PageAccent, + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 0.6.sp, + ) + Spacer(Modifier.width(6.dp)) + Icon( + if (expanded) Icons.Outlined.ExpandLess else Icons.Outlined.ExpandMore, + contentDescription = null, + tint = PageAccent, + modifier = Modifier.size(20.dp), + ) + } +} + +@Composable +private fun ProfileChoiceRow( + profile: DeviceProfile, + selected: Boolean, + onSelect: () -> Unit, +) { + val shape = RoundedCornerShape(12.dp) + Row( + modifier = + Modifier + .fillMaxWidth() + .clip(shape) + .background(PageCard) + .clickable { onSelect() } + .paneNavItem( + cornerRadius = 12.dp, + onActivate = onSelect, + highlightColor = PageAccent, + tapToSelect = true, + ).padding(horizontal = 14.dp, vertical = 11.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text( + stringResource(profile.titleRes), + color = if (selected) PageAccent else PageText, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + ) + Text( + stringResource(profile.summaryRes), + color = PageSub, + fontSize = 11.sp, + ) + } + if (selected) { + Icon( + Icons.Outlined.Check, + contentDescription = null, + tint = PageAccent, + modifier = Modifier.size(20.dp), + ) + } + } +} + +@Composable +private fun FeatureRow( + feature: DeviceProfileFeature, + statusText: String?, + portrait: Boolean, +) { + val shape = RoundedCornerShape(12.dp) + Row( + modifier = + Modifier + .fillMaxWidth() + .clip(shape) + .background(PageCard) + .padding(horizontal = 14.dp, vertical = 11.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text( + stringResource(feature.titleRes), + color = if (feature.integrated) PageText else PageText.copy(alpha = 0.45f), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + ) + Text( + stringResource(feature.summaryRes), + color = if (feature.integrated) PageSub else PageSub.copy(alpha = 0.45f), + fontSize = 11.sp, + ) + if (portrait) { + Spacer(Modifier.size(3.dp)) + FeatureStatus(feature, statusText) + } + } + if (!portrait) { + Spacer(Modifier.width(10.dp)) + FeatureStatus(feature, statusText) + } + } +} + +@Composable +private fun FeatureStatus( + feature: DeviceProfileFeature, + statusText: String?, +) { + if (feature.integrated) { + Text( + statusText.orEmpty(), + color = PageAccent, + fontSize = 11.sp, + fontWeight = FontWeight.Medium, + ) + } else { + Text( + stringResource(R.string.common_ui_coming_soon), + color = PageMuted, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 0.6.sp, + ) + } +} + +@Composable +private fun InfoRow( + labelRes: Int, + value: String, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(PageCard) + .padding(horizontal = 14.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + stringResource(labelRes), + color = PageSub, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + ) + Text( + value, + color = PageText, + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + ) + } +} + +@Composable +private fun ActionRow( + titleRes: Int, + descRes: Int, + onActivate: () -> Unit, +) { + val shape = RoundedCornerShape(12.dp) + Row( + modifier = + Modifier + .fillMaxWidth() + .clip(shape) + .background(PageCard) + .clickable { onActivate() } + .paneNavItem( + cornerRadius = 12.dp, + onActivate = onActivate, + highlightColor = PageAccent, + tapToSelect = true, + ).padding(horizontal = 14.dp, vertical = 11.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text( + stringResource(titleRes), + color = PageText, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + ) + Text( + stringResource(descRes), + color = PageSub, + fontSize = 11.sp, + ) + } + Icon( + Icons.Outlined.Refresh, + contentDescription = null, + tint = PageAccent, + modifier = Modifier.size(20.dp), + ) + } +} + +@Composable +fun DeviceProfileScreen(bridge: SettingsNavBridge? = null) { + val context = LocalContext.current + val contentNav = rememberSettingsContentNav(bridge) + val portrait = isPortraitLayout() + + var selected by remember { mutableStateOf(DeviceProfileSettings.current(context)) } + var expanded by remember { mutableStateOf(false) } + var resyncSignal by remember { mutableStateOf(0) } + + val deviceLabel = remember { DeviceProfile.deviceLabel() } + val overridden = selected != DeviceProfileSettings.detected(context) + + fun resyncLayouts() { + runCatching { InputControlsManager(context).resyncAssetProfiles() } + resyncSignal++ + } + + fun choose(profile: DeviceProfile) { + expanded = false + if (profile == selected) return + DeviceProfileSettings.setCurrent(context, profile) + selected = profile + resyncLayouts() + } + + val adaptiveStatus = + stringResource( + if (DeviceProfileSettings.adaptiveJoysticksDefault(context)) { + R.string.device_profile_recommended_on + } else { + R.string.device_profile_recommended_off + }, + ) + val layoutStatus = + stringResource( + if (selected == DeviceProfile.DEFAULT) { + R.string.device_profile_layouts_stock + } else { + R.string.device_profile_layouts_tuned + }, + ) + val libraryStatus = + stringResource( + if (DeviceProfileSettings.preferWideArtwork(context)) { + R.string.device_profile_library_wide + } else { + R.string.device_profile_library_stock + }, + ) + val sessionStatus = + stringResource( + if (DeviceProfileSettings.sessionActionCardMaxAspect(context) == Float.MAX_VALUE) { + R.string.device_profile_session_stock + } else { + R.string.device_profile_session_square + }, + ) + + CompositionLocalProvider(LocalPaneNav provides contentNav) { + Column( + modifier = + Modifier + .fillMaxSize() + .background(PageBg) + .verticalScroll(rememberScrollState()) + .windowInsetsPadding(WindowInsets.navigationBars) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + SectionHeading( + textRes = R.string.device_profile_heading, + descRes = R.string.device_profile_desc, + topPadding = 4, + ) + + DeviceHeaderCard( + profile = selected, + deviceLabel = deviceLabel, + overridden = overridden, + expanded = expanded, + onToggle = { expanded = !expanded }, + ) + + if (expanded) { + DeviceProfile.entries.forEach { profile -> + ProfileChoiceRow( + profile = profile, + selected = profile == selected, + onSelect = { choose(profile) }, + ) + } + } + + SectionHeading( + textRes = R.string.device_profile_controls_heading, + descRes = R.string.device_profile_controls_desc, + topPadding = 10, + ) + + DeviceProfileFeature.entries.forEach { feature -> + FeatureRow( + feature = feature, + statusText = + when (feature) { + DeviceProfileFeature.INPUT_CONTROL_LAYOUTS -> layoutStatus + DeviceProfileFeature.ADAPTIVE_JOYSTICKS -> adaptiveStatus + DeviceProfileFeature.LIBRARY_ICONS -> libraryStatus + DeviceProfileFeature.SESSION_MENU_SIZES -> sessionStatus + else -> null + }, + portrait = portrait, + ) + } + + ActionRow( + titleRes = R.string.device_profile_reapply, + descRes = R.string.device_profile_reapply_desc, + onActivate = ::resyncLayouts, + ) + + SectionHeading( + textRes = R.string.device_profile_device_heading, + descRes = R.string.device_profile_device_desc, + topPadding = 10, + ) + + InfoRow(R.string.device_profile_info_model, android.os.Build.MODEL.orEmpty()) + InfoRow(R.string.device_profile_info_board, android.os.Build.DEVICE.orEmpty()) + InfoRow(R.string.device_profile_info_manufacturer, android.os.Build.MANUFACTURER.orEmpty()) + InfoRow( + R.string.device_profile_info_screen, + screenSummary(context), + ) + } + } +} + +private fun screenSummary(context: android.content.Context): String { + val metrics = context.resources.displayMetrics + val longEdge = maxOf(metrics.widthPixels, metrics.heightPixels) + val shortEdge = minOf(metrics.widthPixels, metrics.heightPixels) + return "$longEdge x $shortEdge" +} diff --git a/app/src/main/feature/settings/nav/SettingsNavGraph.kt b/app/src/main/feature/settings/nav/SettingsNavGraph.kt index a6c175d4c..80c1b1742 100644 --- a/app/src/main/feature/settings/nav/SettingsNavGraph.kt +++ b/app/src/main/feature/settings/nav/SettingsNavGraph.kt @@ -212,6 +212,9 @@ fun SettingsHost( popExitTransition = { fadeOut(tween(250, easing = androidx.compose.animation.core.FastOutSlowInEasing)) }, modifier = Modifier.fillMaxSize(), ) { + composable(SettingsRoutes.fromNavItem(SettingsNavItem.DEVICE_PROFILE)) { + com.winlator.cmod.feature.settings.device.DeviceProfileScreen(bridge = bridge) + } composable(SettingsRoutes.fromNavItem(SettingsNavItem.CONTAINERS)) { AndroidFragment() } diff --git a/app/src/main/feature/settings/nav/SettingsNavSidebar.kt b/app/src/main/feature/settings/nav/SettingsNavSidebar.kt index e504047c7..9666bf1aa 100644 --- a/app/src/main/feature/settings/nav/SettingsNavSidebar.kt +++ b/app/src/main/feature/settings/nav/SettingsNavSidebar.kt @@ -42,6 +42,7 @@ import androidx.compose.material.icons.outlined.Menu import androidx.compose.material.icons.outlined.Memory import androidx.compose.material.icons.outlined.ShoppingBag import androidx.compose.material.icons.outlined.SportsEsports +import androidx.compose.material.icons.outlined.TabletAndroid import androidx.compose.material.icons.outlined.Tune import androidx.compose.material.icons.outlined.VideogameAsset import androidx.compose.material.icons.outlined.ViewInAr @@ -95,6 +96,7 @@ private val InterFamily = FontFamily(Font(R.font.inter_medium, FontWeight.Medium // ─── Navigation model ─────────────────────────────────────────────── enum class NavSection { + DEVICE, ACCOUNTS, SYSTEM, TOOLS, @@ -108,6 +110,12 @@ enum class SettingsNavItem( val titleRes: Int, val section: NavSection, ) { + DEVICE_PROFILE( + R.id.main_menu_device_profile, + Icons.Outlined.TabletAndroid, + R.string.device_profile_nav_title, + NavSection.DEVICE, + ), GOOGLE(R.id.main_menu_google, Icons.Outlined.AccountCircle, R.string.google_cloud_google, NavSection.ACCOUNTS), STORES(R.id.main_menu_stores, Icons.Outlined.ShoppingBag, R.string.stores_accounts_title, NavSection.ACCOUNTS), CONTAINERS(R.id.main_menu_containers, Icons.Outlined.ViewInAr, R.string.common_ui_containers, NavSection.SYSTEM), diff --git a/app/src/main/feature/shortcuts/ShortcutSettingsComposeDialog.kt b/app/src/main/feature/shortcuts/ShortcutSettingsComposeDialog.kt index 9602b1dbd..40bf1fc99 100644 --- a/app/src/main/feature/shortcuts/ShortcutSettingsComposeDialog.kt +++ b/app/src/main/feature/shortcuts/ShortcutSettingsComposeDialog.kt @@ -28,6 +28,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.coroutines.CoroutineScope +import com.winlator.cmod.app.config.DeviceProfileSettings import com.winlator.cmod.runtime.display.environment.components.NetworkingSettings import com.winlator.cmod.BuildConfig import com.winlator.cmod.R @@ -392,7 +393,10 @@ class ShortcutSettingsComposeDialog private constructor( state.disableXInput.value = shortcut.getExtra("disableXinput", "0") == "1" state.adaptiveJoysticks.value = getShortcutSetting( InputControlsView.EXTRA_ADAPTIVE_JOYSTICKS, - container.getExtra(InputControlsView.EXTRA_ADAPTIVE_JOYSTICKS, "0") + container.getExtra( + InputControlsView.EXTRA_ADAPTIVE_JOYSTICKS, + DeviceProfileSettings.adaptiveJoysticksDefaultExtra(context), + ), ) == "1" state.shortcutExclusiveXInput.value = shortcut.getExtra("exclusiveXInput", "").let { if (it.isEmpty()) container.isExclusiveXInput() else it == "1" @@ -1319,7 +1323,10 @@ class ShortcutSettingsComposeDialog private constructor( hasContainerOverride = hasContainerOverride or saveOverride( InputControlsView.EXTRA_ADAPTIVE_JOYSTICKS, if (state.adaptiveJoysticks.value) "1" else "0", - container.getExtra(InputControlsView.EXTRA_ADAPTIVE_JOYSTICKS, "0") + container.getExtra( + InputControlsView.EXTRA_ADAPTIVE_JOYSTICKS, + DeviceProfileSettings.adaptiveJoysticksDefaultExtra(context), + ), ) // Touchscreen mode @@ -2473,7 +2480,10 @@ class ShortcutSettingsComposeDialog private constructor( if ((inputType and WinHandler.FLAG_DINPUT_MAPPER_STANDARD.toInt()) == WinHandler.FLAG_DINPUT_MAPPER_STANDARD.toInt()) 0 else 1 state.shortcutExclusiveXInput.value = container.isExclusiveXInput() state.adaptiveJoysticks.value = - container.getExtra(InputControlsView.EXTRA_ADAPTIVE_JOYSTICKS, "0") == "1" + container.getExtra( + InputControlsView.EXTRA_ADAPTIVE_JOYSTICKS, + DeviceProfileSettings.adaptiveJoysticksDefaultExtra(context), + ) == "1" if (!state.shortcutExclusiveXInput.value) { state.enableXInput.value = true state.enableDInput.value = true diff --git a/app/src/main/feature/stores/common/StoreArtworkCache.kt b/app/src/main/feature/stores/common/StoreArtworkCache.kt index 14075b4d6..868f264f0 100644 --- a/app/src/main/feature/stores/common/StoreArtworkCache.kt +++ b/app/src/main/feature/stores/common/StoreArtworkCache.kt @@ -145,10 +145,11 @@ object StoreArtworkCache { epicGame: EpicGame?, useLibraryCapsule: Boolean, listMode: Boolean, + preferWide: Boolean = false, ): ArtworkRef? = when { - gogGame != null -> gogPrimaryRef(gogGame) - epicGame != null -> epicPrimaryRef(epicGame) + gogGame != null -> gogPrimaryRef(gogGame, preferWide) + epicGame != null -> epicPrimaryRef(epicGame, preferWide) app.id < 0 -> null else -> { val slot: String @@ -162,6 +163,10 @@ object StoreArtworkCache { slot = "library_capsule" url = app.getLibraryCapsuleUrl() } + preferWide -> { + slot = "header" + url = app.getHeaderImageUrl() + } else -> { slot = "capsule" url = app.getCapsuleUrl() @@ -191,8 +196,12 @@ object StoreArtworkCache { ArtworkRef("epic", game.id.toString(), "hero", game.artPortrait), ).filter { it.url.isNotBlank() } - fun epicPrimaryRef(game: EpicGame): ArtworkRef? = + fun epicPrimaryRef( + game: EpicGame, + preferWide: Boolean = false, + ): ArtworkRef? = when { + preferWide && game.artPortrait.isNotBlank() -> ArtworkRef("epic", game.id.toString(), "hero", game.artPortrait) game.artCover.isNotBlank() -> ArtworkRef("epic", game.id.toString(), "cover", game.artCover) game.artSquare.isNotBlank() -> ArtworkRef("epic", game.id.toString(), "square", game.artSquare) game.artLogo.isNotBlank() -> ArtworkRef("epic", game.id.toString(), "logo", game.artLogo) @@ -213,8 +222,12 @@ object StoreArtworkCache { ArtworkRef("gog", game.id, "icon", game.iconUrl), ).filter { it.url.isNotBlank() } - fun gogPrimaryRef(game: GOGGame): ArtworkRef? = + fun gogPrimaryRef( + game: GOGGame, + preferWide: Boolean = false, + ): ArtworkRef? = when { + preferWide && game.heroImageUrl.isNotBlank() -> ArtworkRef("gog", game.id, "hero", game.heroImageUrl) game.imageUrl.isNotBlank() -> ArtworkRef("gog", game.id, "cover", game.imageUrl) game.iconUrl.isNotBlank() -> ArtworkRef("gog", game.id, "icon", game.iconUrl) else -> null diff --git a/app/src/main/res/values-b+es+419/strings.xml b/app/src/main/res/values-b+es+419/strings.xml index cc398cf49..69d76e577 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -179,6 +179,7 @@ Obtener arte + Ninguna biblioteca de tienda coincide con este juego Obteniendo… Acceso directo creado No se pudo crear el acceso directo: %s @@ -1081,6 +1082,46 @@ Por ejemplo, META para la tecla META, \n Escala del mouse Escala del mouse: %1$d%% + + Dispositivo + PERFIL DEL DISPOSITIVO + Un perfil por dispositivo, que ajusta los diseños y los tamaños a esta pantalla. + CAMBIAR + Detectado automáticamente + Elegido manualmente + Predeterminado + Todos los teléfonos y tablets sin un perfil propio. + Astra 2 + Tablet gamer RedMagic Astra 2, todos los modelos regionales. + LO QUE ESTABLECE ESTE PERFIL + Valores predeterminados que el perfil aplica en toda la app. + Diseños de controles de entrada + Diseños táctiles integrados ubicados para este tamaño de pantalla. + Palancas adaptables + Valor predeterminado para los contenedores que nunca lo han definido. + Tamaños y proporciones de los iconos de la biblioteca + Columnas de la cuadrícula y forma de las portadas en la biblioteca. + Tamaños de los botones del menú del juego + Tamaños de las áreas táctiles en el menú del juego. + Ajustes de juego recomendados + Valores predeterminados por juego sugeridos para este dispositivo. + Recomendado: Activado + Recomendado: Desactivado + Diseños originales + Ajustados a esta pantalla + Volver a aplicar los diseños integrados + Restaura todos los diseños integrados que no hayas editado. + ESTE DISPOSITIVO + Con qué se comparó el perfil. + Modelo + Placa + Fabricante + Pantalla + Tarjetas anchas 3:2 para arte horizontal + Cuadrícula estándar + Botones de acción cuadrados + Tamaños estándar + Velocidad del cursor Tema diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 4ad53e434..c0880ad01 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -178,6 +178,7 @@ Hent grafik + Ingen butiksbiblioteker matcher dette spil Henter Genvej oprettet Kunne ikke oprette genvej: %s @@ -794,6 +795,46 @@ F.eks. META for META-tast, \n Dødzone: %1$d%% Følsomhed: %1$d%% + + Enhed + ENHEDSPROFIL + Én profil pr. enhed, der tilpasser layouts og størrelser til denne skærm. + SKIFT + Fundet automatisk + Valgt manuelt + Standard + Alle telefoner og tablets uden deres egen profil. + Astra 2 + RedMagic Astra 2-gamingtablet, alle regionale modeller. + HVAD DENNE PROFIL SÆTTER + Standarder, som profilen anvender i hele appen. + Layouts til inputkontroller + Medfølgende touchlayouts placeret til denne skærmstørrelse. + Adaptive joysticks + Standard for containere, der aldrig har angivet den. + Ikonstørrelser og -forhold i biblioteket + Gitterkolonner og coverform i biblioteket. + Knapstørrelser i menuen i spillet + Størrelsen på berøringsmål i sessionspanelet. + Anbefalede spilindstillinger + Per-spil standarder foreslået til denne enhed. + Anbefalet: Til + Anbefalet: Fra + Standardlayouts + Tilpasset denne skærm + Anvend medfølgende layouts igen + Gendanner alle medfølgende layouts, du ikke har redigeret. + DENNE ENHED + Hvad profilen blev matchet mod. + Model + Hardwarekort + Producent + Skærm + Brede 3:2-kort til liggende grafik + Standardgitter + Kvadratiske handlingsknapper + Standardstørrelser + Markørhastighed Tema diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index ab2014838..fd472dd30 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -178,6 +178,7 @@ Artwork abrufen + Keine Store-Bibliothek passt zu diesem Spiel Wird abgerufen Verknüpfung erstellt Verknüpfung konnte nicht erstellt werden: %s @@ -794,6 +795,46 @@ Z. B. META für Meta-Taste, \n Totzone: %1$d%% Empfindlichkeit: %1$d%% + + Gerät + GERÄTEPROFIL + Ein Profil pro Gerät, das Layouts und Größen auf diesen Bildschirm abstimmt. + WECHSELN + Automatisch erkannt + Manuell gewählt + Standard + Jedes Smartphone und Tablet ohne eigenes Profil. + Astra 2 + RedMagic Astra 2 Gaming-Tablet, alle regionalen Modelle. + WAS DIESES PROFIL FESTLEGT + Standardwerte, die das Profil in der ganzen App anwendet. + Layouts der Eingabesteuerung + Mitgelieferte Touch-Layouts, passend zu dieser Bildschirmgröße platziert. + Adaptive Joysticks + Standard für Container, die es noch nie festgelegt haben. + Symbolgrößen und Seitenverhältnisse der Bibliothek + Rasterspalten und Coverform in der Bibliothek. + Tastengrößen im Spielmenü + Größe der Tippflächen im Sitzungsmenü. + Empfohlene Spieleinstellungen + Spielspezifische Standardwerte, die für dieses Gerät vorgeschlagen werden. + Empfohlen: Ein + Empfohlen: Aus + Standard-Layouts + Auf diesen Bildschirm abgestimmt + Mitgelieferte Layouts neu anwenden + Stellt jedes mitgelieferte Layout wieder her, das du nicht bearbeitet hast. + DIESES GERÄT + Womit das Profil abgeglichen wurde. + Modell + Platine + Hersteller + Bildschirm + Breite 3:2-Karten für Querformat-Artwork + Standardraster + Quadratische Aktionstasten + Standardgrößen + Zeigergeschwindigkeit Design diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index ab625ef90..b86566cd2 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -178,6 +178,7 @@ Obtener arte + Ninguna biblioteca de tiendas coincide con este juego Obteniendo Acceso directo creado Error al crear acceso directo: %s @@ -794,6 +795,46 @@ Ej. META para la tecla META, \n Zona muerta: %1$d%% Sensibilidad: %1$d%% + + Dispositivo + PERFIL DEL DISPOSITIVO + Un perfil por dispositivo, que ajusta las disposiciones y los tamaños a esta pantalla. + CAMBIAR + Detectado automáticamente + Elegido manualmente + Predeterminado + Todos los móviles y tablets sin un perfil propio. + Astra 2 + Tablet gaming RedMagic Astra 2, todos los modelos regionales. + LO QUE DEFINE ESTE PERFIL + Valores predeterminados que el perfil aplica en toda la app. + Disposiciones de controles de entrada + Disposiciones táctiles integradas colocadas para este tamaño de pantalla. + Joysticks adaptativos + Predeterminado para los contenedores que nunca lo han configurado. + Tamaños y proporciones de los iconos de la biblioteca + Columnas de la cuadrícula y forma de las portadas en la biblioteca. + Tamaños de los botones del menú en el juego + Tamaños de las zonas táctiles del panel de sesión. + Ajustes de juego recomendados + Valores predeterminados por juego sugeridos para este dispositivo. + Recomendado: Activado + Recomendado: Desactivado + Disposiciones de serie + Ajustadas a esta pantalla + Reaplicar disposiciones integradas + Restaura todas las disposiciones integradas que no hayas editado. + ESTE DISPOSITIVO + Los datos con los que se emparejó el perfil. + Modelo + Placa + Fabricante + Pantalla + Tarjetas anchas 3:2 para arte horizontal + Cuadrícula estándar + Botones de acción cuadrados + Tamaños estándar + Velocidad del cursor Tema diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index a799c6b16..ed6b5d1f8 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -179,6 +179,7 @@ Hae kuvitus + Mikään kauppakirjasto ei vastaa tätä peliä Haetaan Pikakuvake luotu Pikakuvakkeen luonti epäonnistui: %s @@ -1081,6 +1082,46 @@ E.g. META for META-näppäin, \n Hiiren skaala Hiiren skaala: %1$d%% + + Laite + LAITEPROFIILI + Yksi profiili laitetta kohti: sovittaa asettelut ja koot tälle näytölle. + VAIHDA + Tunnistettu automaattisesti + Valittu itse + Oletus + Kaikki puhelimet ja tabletit, joilla ei ole omaa profiilia. + Astra 2 + RedMagic Astra 2 -pelitabletti, kaikki aluemallit. + MITÄ TÄMÄ PROFIILI MÄÄRITTÄÄ + Oletukset, joita profiili käyttää koko sovelluksessa. + Ohjainasettelut + Mukana tulevat kosketusasettelut tälle näyttökoolle. + Mukautuvat sauvat + Oletus säiliöille, joissa sitä ei ole koskaan asetettu. + Kirjaston kuvakekoot ja -suhteet + Ruudukon sarakkeet ja kansien muoto kirjastossa. + Pelinaikaisen valikon painikekoot + Kosketusalueiden koot istuntovalikossa. + Suositellut peliasetukset + Pelikohtaiset oletukset, joita suositellaan tälle laitteelle. + Suositus: Käytössä + Suositus: Pois + Vakioasettelut + Sovitettu tälle näytölle + Palauta vakioasettelut + Palauttaa kaikki mukana tulevat asettelut, joita et ole muokannut. + TÄMÄ LAITE + Mihin profiili sovitettiin. + Malli + Piirilevy + Valmistaja + Näyttö + Leveät 3:2-kortit vaakakuville + Vakioruudukko + Neliömäiset toimintopainikkeet + Vakiokoot + Osoittimen nopeus Teema diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 9044c9ea4..b278f7871 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -178,6 +178,7 @@ Récupérer les illustrations + Aucune bibliothèque de boutique ne correspond à ce jeu Récupération Raccourci créé Échec de la création du raccourci : %s @@ -794,6 +795,46 @@ Par ex. META pour la touche META, \n Zone morte : %1$d%% Sensibilité : %1$d%% + + Appareil + PROFIL D\'APPAREIL + Un profil par appareil, adaptant les dispositions et les tailles à cet écran. + CHANGER + Détecté automatiquement + Choisi manuellement + Par défaut + Tous les téléphones et tablettes sans profil propre. + Astra 2 + Tablette de jeu RedMagic Astra 2, tous les modèles régionaux. + CE QUE CE PROFIL DÉFINIT + Valeurs par défaut que le profil applique dans toute l\'application. + Dispositions des contrôles de saisie + Dispositions tactiles intégrées placées pour cette taille d\'écran. + Joysticks adaptatifs + Valeur par défaut pour les conteneurs qui ne l\'ont jamais définie. + Tailles et ratios des icônes de la bibliothèque + Colonnes de la grille et forme des jaquettes dans la bibliothèque. + Tailles des boutons du menu en jeu + Tailles des zones tactiles dans le menu de session. + Paramètres de jeu recommandés + Valeurs par défaut par jeu suggérées pour cet appareil. + Recommandé : Activé + Recommandé : Désactivé + Dispositions d\'origine + Adaptées à cet écran + Réappliquer les dispositions intégrées + Restaure toutes les dispositions intégrées que vous n\'avez pas modifiées. + CET APPAREIL + Les critères utilisés pour associer le profil. + Modèle + Carte + Fabricant + Écran + Cartes larges 3:2 pour les visuels en paysage + Grille standard + Boutons d\'action carrés + Tailles standard + Vitesse du curseur Thème diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index cd1f46abf..f3c1ed249 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -176,6 +176,7 @@ %d फ़ाइलें आर्टवर्क प्राप्त करें + इस गेम से कोई स्टोर लाइब्रेरी मेल नहीं खाती प्राप्त हो रहा है शॉर्टकट बनाया गया शॉर्टकट बनाने में विफल: %s @@ -2676,4 +2677,43 @@ प्राथमिक अडैप्टर के लिए बताया गया हार्डवेयर पता। डिवाइस से स्वतः बने पते के लिए इसे खाली छोड़ें। स्वतः: %1$s 12 हेक्स अंक दर्ज करें (उदाहरण के लिए 3c:5a:b4:12:34:56)। पहला बाइट यूनिकास्ट और वैश्विक रूप से प्रशासित होना चाहिए, यानी बिट 0 और 1 खाली हों; कई गेम स्थानीय रूप से प्रशासित पतों को अस्वीकार करते हैं, जैसे 02:…। + + डिवाइस + डिवाइस प्रोफ़ाइल + हर डिवाइस के लिए एक प्रोफ़ाइल, जो लेआउट और आकार इस स्क्रीन के अनुसार ढालती है। + बदलें + स्वतः पहचानी गई + मैन्युअल रूप से चुनी गई + डिफ़ॉल्ट + हर वह फ़ोन और टैबलेट जिसकी अपनी प्रोफ़ाइल नहीं है। + Astra 2 + RedMagic Astra 2 गेमिंग टैबलेट, हर क्षेत्रीय मॉडल। + यह प्रोफ़ाइल क्या सेट करती है + प्रोफ़ाइल पूरे ऐप में जो डिफ़ॉल्ट लागू करती है। + इनपुट नियंत्रण लेआउट + बंडल टच लेआउट, इस स्क्रीन आकार के अनुसार रखे गए। + अनुकूली जॉयस्टिक + उन कंटेनरों के लिए डिफ़ॉल्ट जिन्होंने इसे कभी सेट नहीं किया। + लाइब्रेरी आइकन आकार और अनुपात + लाइब्रेरी में ग्रिड कॉलम और कवर का आकार। + इन-गेम मेन्यू बटन आकार + सेशन ड्रॉअर में टच लक्ष्य के आकार। + अनुशंसित गेम सेटिंग्स + इस डिवाइस के लिए सुझाए गए प्रति-गेम डिफ़ॉल्ट। + अनुशंसित: चालू + अनुशंसित: बंद + मूल लेआउट + इस स्क्रीन के लिए ढाले गए + बंडल लेआउट फिर से लागू करें + हर उस बंडल लेआउट को पुनर्स्थापित करता है जिसे आपने संपादित नहीं किया है। + यह डिवाइस + प्रोफ़ाइल किससे मिलान करके चुनी गई। + मॉडल + बोर्ड + निर्माता + स्क्रीन + लैंडस्केप आर्टवर्क के लिए चौड़े 3:2 कार्ड + स्टॉक ग्रिड + वर्गाकार एक्शन बटन + स्टॉक आकार diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index a97a56e81..1104cae22 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -178,6 +178,7 @@ Recupera immagini + Nessuna libreria store corrisponde a questo gioco Recupero Scorciatoia creata Impossibile creare la scorciatoia: %s @@ -794,6 +795,46 @@ Ad es. META per il tasto META, \n Zona morta: %1$d%% Sensibilità: %1$d%% + + Dispositivo + PROFILO DISPOSITIVO + Un profilo per dispositivo, che adatta layout e dimensioni a questo schermo. + CAMBIA + Rilevato automaticamente + Scelto manualmente + Predefinito + Ogni telefono e tablet senza un profilo proprio. + Astra 2 + Tablet da gioco RedMagic Astra 2, tutti i modelli regionali. + COSA IMPOSTA QUESTO PROFILO + Valori predefiniti che il profilo applica in tutta l\'app. + Layout dei controlli + Layout touch integrati, posizionati per questa dimensione di schermo. + Levette adattive + Valore predefinito per i contenitori che non l\'hanno mai impostato. + Dimensioni e proporzioni delle icone + Colonne della griglia e forma delle copertine nella libreria. + Dimensioni dei pulsanti in gioco + Dimensioni delle aree touch nel menu di sessione. + Impostazioni di gioco consigliate + Valori predefiniti per gioco suggeriti per questo dispositivo. + Consigliato: attivo + Consigliato: disattivo + Layout standard + Adattati a questo schermo + Riapplica i layout integrati + Ripristina ogni layout integrato che non hai modificato. + QUESTO DISPOSITIVO + I dati con cui è stato abbinato il profilo. + Modello + Scheda + Produttore + Schermo + Schede larghe 3:2 per artwork orizzontali + Griglia standard + Pulsanti di azione quadrati + Dimensioni standard + Velocità cursore Tema diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index dfd9a7323..b4389e6d0 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -179,6 +179,7 @@ アートワークを取得 + このゲームに一致するストアライブラリはありません 取得中 ショートカットを作成しました ショートカットの作成に失敗しました: %s @@ -1081,6 +1082,46 @@ マウススケール マウススケール: %1$d%% + + デバイス + デバイスプロファイル + デバイスごとに 1 つのプロファイルで、レイアウトとサイズをこの画面に合わせます。 + 変更 + 自動的に検出されました + 手動で選択されました + 既定 + 専用プロファイルがないすべてのスマートフォンとタブレット。 + Astra 2 + RedMagic Astra 2 ゲーミングタブレット、全地域モデル。 + このプロファイルが設定する項目 + プロファイルがアプリ全体に適用する既定値。 + 入力コントロールのレイアウト + この画面サイズに合わせて配置された組み込みタッチレイアウト。 + アダプティブジョイスティック + 未設定のコンテナに適用される既定値。 + ライブラリのアイコンサイズと比率 + ライブラリのグリッド列数とカバーの形状。 + ゲーム内メニューボタンのサイズ + セッションドロワーのタッチ領域のサイズ。 + 推奨のゲーム設定 + このデバイスに推奨されるゲーム個別の既定値。 + 推奨: オン + 推奨: オフ + 標準レイアウト + この画面に合わせて調整 + 組み込みレイアウトを再適用 + 編集していない組み込みレイアウトをすべて元に戻します。 + このデバイス + プロファイルの判定に使用された情報。 + モデル + ボード + メーカー + 画面 + 横長アートワーク向けの3:2ワイドカード + 標準グリッド + 正方形のアクションボタン + 標準サイズ + カーソル速度 テーマ diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index ab047ef8e..833ae40c6 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -178,6 +178,7 @@ 아트워크 가져오기 + 이 게임과 일치하는 스토어 라이브러리가 없습니다 가져오는 중 바로가기가 생성되었습니다 바로가기 생성 실패: %s @@ -794,6 +795,46 @@ 데드존: %1$d%% 감도: %1$d%% + + 기기 + 기기 프로필 + 기기마다 하나의 프로필로, 레이아웃과 크기를 이 화면에 맞게 조정합니다. + 변경 + 자동으로 감지됨 + 수동으로 선택됨 + 기본값 + 전용 프로필이 없는 모든 휴대폰과 태블릿. + Astra 2 + RedMagic Astra 2 게이밍 태블릿, 모든 지역 모델. + 이 프로필이 설정하는 항목 + 프로필이 앱 전체에 적용하는 기본값입니다. + 입력 컨트롤 레이아웃 + 이 화면 크기에 맞게 배치된 기본 제공 터치 레이아웃. + 적응형 조이스틱 + 한 번도 설정하지 않은 컨테이너의 기본값. + 라이브러리 아이콘 크기 및 비율 + 라이브러리의 그리드 열 수와 커버 모양. + 게임 내 메뉴 버튼 크기 + 세션 드로어의 터치 영역 크기. + 권장 게임 설정 + 이 기기에 제안되는 게임별 기본값. + 권장: 켜기 + 권장: 끄기 + 기본 레이아웃 + 이 화면에 맞게 조정됨 + 기본 제공 레이아웃 다시 적용 + 편집하지 않은 모든 기본 제공 레이아웃을 복원합니다. + 이 기기 + 프로필이 일치한 기준입니다. + 모델 + 보드 + 제조사 + 화면 + 가로 아트워크용 3:2 와이드 카드 + 기본 그리드 + 정사각형 동작 버튼 + 기본 크기 + 커서 속도 테마 diff --git a/app/src/main/res/values-no/strings.xml b/app/src/main/res/values-no/strings.xml index 647cd8ed1..ebcec3a90 100644 --- a/app/src/main/res/values-no/strings.xml +++ b/app/src/main/res/values-no/strings.xml @@ -179,6 +179,7 @@ Hent grafikk + Ingen butikkbibliotek passer til dette spillet Henter Snarvei opprettet Kunne ikke opprette snarvei: %s @@ -1081,6 +1082,46 @@ F.eks. META for META-tast, \n Musskala Musskala: %1$d%% + + Enhet + ENHETSPROFIL + Én profil per enhet som tilpasser oppsett og størrelser til denne skjermen. + BYTT + Oppdaget automatisk + Valgt manuelt + Standard + Alle telefoner og nettbrett uten egen profil. + Astra 2 + RedMagic Astra 2-spillnettbrett, alle regionale modeller. + HVA DENNE PROFILEN ANGIR + Standardverdiene profilen bruker i hele appen. + Kontrolloppsett for inndata + Medfølgende berøringsoppsett plassert for denne skjermstørrelsen. + Adaptive styrespaker + Standard for beholdere som aldri har angitt det. + Ikonstørrelser og format i biblioteket + Rutenettkolonner og omslagsform i biblioteket. + Knappestørrelser i menyen i spillet + Størrelsen på berøringsmål i menyen i spillet. + Anbefalte spillinnstillinger + Standardverdier per spill som foreslås for denne enheten. + Anbefalt: På + Anbefalt: Av + Standardoppsett + Tilpasset denne skjermen + Bruk medfølgende oppsett på nytt + Gjenoppretter alle medfølgende oppsett du ikke har redigert. + DENNE ENHETEN + Det profilen ble matchet mot. + Modell + Hovedkort + Produsent + Skjerm + Brede 3:2-kort for liggende grafikk + Standardrutenett + Kvadratiske handlingsknapper + Standardstørrelser + Markørhastighet Tema diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index a358f6da8..3f06c35aa 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -182,6 +182,7 @@ Pobierz grafiki + Żadna biblioteka sklepu nie zawiera tej gry Pobieranie Skrót utworzony Nie udało się utworzyć skrótu: %s @@ -800,6 +801,46 @@ Np. META dla klawisza META, \n Martwa strefa: %1$d%% Czułość: %1$d%% + + Urządzenie + PROFIL URZĄDZENIA + Jeden profil na urządzenie, dostosowujący układy i rozmiary do tego ekranu. + ZMIEŃ + Wykryto automatycznie + Wybrano ręcznie + Domyślny + Każdy telefon i tablet bez własnego profilu. + Astra 2 + Tablet do gier RedMagic Astra 2, każdy model regionalny. + CO USTAWIA TEN PROFIL + Wartości domyślne stosowane przez profil w całej aplikacji. + Układy kontrolek wejścia + Dołączone układy dotykowe rozmieszczone dla tego rozmiaru ekranu. + Adaptacyjne drążki + Domyślne dla kontenerów, w których nigdy tego nie ustawiono. + Rozmiary i proporcje ikon biblioteki + Kolumny siatki i kształt okładek w bibliotece. + Rozmiary przycisków menu w grze + Rozmiary obszarów dotyku w menu sesji. + Zalecane ustawienia gry + Ustawienia domyślne dla gier sugerowane dla tego urządzenia. + Zalecane: Włączone + Zalecane: Wyłączone + Układy standardowe + Dostosowane do tego ekranu + Zastosuj ponownie dołączone układy + Przywraca każdy dołączony układ, którego nie edytowałeś. + TO URZĄDZENIE + Do czego dopasowano profil. + Model + Płyta + Producent + Ekran + Szerokie karty 3:2 dla grafik poziomych + Siatka standardowa + Kwadratowe przyciski akcji + Rozmiary standardowe + Prędkość kursora Motyw diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 09a599e76..a99803456 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -178,6 +178,7 @@ Obter arte + Nenhuma biblioteca de loja corresponde a este jogo Obtendo… Atalho criado Falha ao criar atalho: %s @@ -794,6 +795,46 @@ Ex. META para tecla META, \n Zona Morta: %1$d%% Sensibilidade: %1$d%% + + Dispositivo + PERFIL DO DISPOSITIVO + Um perfil por dispositivo, ajustando layouts e tamanhos a esta tela. + MUDAR + Detectado automaticamente + Escolhido manualmente + Padrão + Todo celular e tablet sem um perfil próprio. + Astra 2 + Tablet gamer RedMagic Astra 2, todos os modelos regionais. + O QUE ESTE PERFIL DEFINE + Padrões que o perfil aplica em todo o aplicativo. + Layouts de controles + Layouts de toque inclusos, posicionados para este tamanho de tela. + Analógicos adaptáveis + Padrão para containers que nunca definiram isso. + Tamanhos e proporções dos ícones da biblioteca + Colunas da grade e formato das capas na biblioteca. + Tamanho dos botões do menu no jogo + Tamanho das áreas de toque na gaveta da sessão. + Configurações recomendadas do jogo + Padrões por jogo sugeridos para este dispositivo. + Recomendado: Ativado + Recomendado: Desativado + Layouts originais + Ajustados para esta tela + Reaplicar layouts inclusos + Restaura todo layout incluso que você não editou. + ESTE DISPOSITIVO + Com o que o perfil foi comparado. + Modelo + Placa + Fabricante + Tela + Cartões largos 3:2 para arte em paisagem + Grade padrão + Botões de ação quadrados + Tamanhos padrão + Velocidade do Cursor Tema diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 77ed1ece3..782a1e3af 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -179,6 +179,7 @@ Obter arte + Nenhuma biblioteca de loja corresponde a este jogo A obter… Atalho criado Falha ao criar atalho: %s @@ -1081,6 +1082,46 @@ Por ex. META para tecla META, \n Escala do rato Escala do rato: %1$d%% + + Dispositivo + PERFIL DO DISPOSITIVO + Um perfil por dispositivo, a ajustar disposições e tamanhos a este ecrã. + ALTERAR + Detetado automaticamente + Escolhido manualmente + Predefinição + Todos os telemóveis e tablets sem perfil próprio. + Astra 2 + Tablet de jogos RedMagic Astra 2, todos os modelos regionais. + O QUE ESTE PERFIL DEFINE + Predefinições que o perfil aplica em toda a aplicação. + Disposições dos controlos de entrada + Disposições táteis incluídas, posicionadas para este tamanho de ecrã. + Analógicos adaptativos + Predefinição para contentores que nunca a definiram. + Tamanhos e proporções dos ícones da biblioteca + Colunas da grelha e forma das capas na biblioteca. + Tamanhos dos botões do menu no jogo + Tamanhos das áreas de toque no menu da sessão. + Definições de jogo recomendadas + Predefinições por jogo sugeridas para este dispositivo. + Recomendado: Ligado + Recomendado: Desligado + Disposições originais + Ajustadas a este ecrã + Reaplicar disposições incluídas + Restaura todas as disposições incluídas que não editou. + ESTE DISPOSITIVO + Aquilo com que o perfil foi comparado. + Modelo + Placa + Fabricante + Ecrã + Cartões largos 3:2 para arte horizontal + Grelha padrão + Botões de ação quadrados + Tamanhos padrão + Velocidade do cursor Tema diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index 322ad3094..d13dbe763 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -178,6 +178,7 @@ Obține ilustrații + Nicio bibliotecă de magazin nu se potrivește cu acest joc Se obțin… Comanda rapida creata Nu s-a putut crea comanda rapida: %s @@ -794,6 +795,46 @@ De ex. META pentru tasta META, \n Zona moarta: %1$d%% Sensibilitate: %1$d%% + + Dispozitiv + PROFIL DISPOZITIV + Un profil pentru fiecare dispozitiv, care adaptează aspectele și dimensiunile la acest ecran. + SCHIMBĂ + Detectat automat + Ales manual + Implicit + Toate telefoanele și tabletele fără un profil propriu. + Astra 2 + Tableta de gaming RedMagic Astra 2, toate modelele regionale. + CE SETEAZĂ ACEST PROFIL + Valorile implicite pe care profilul le aplică în toată aplicația. + Aspecte ale comenzilor + Aspectele tactile incluse, poziționate pentru această dimensiune de ecran. + Joystick-uri adaptive + Valoarea implicită pentru containerele care nu au setat-o niciodată. + Dimensiuni și rapoarte ale pictogramelor + Coloanele grilei și forma copertelor în bibliotecă. + Dimensiuni ale butoanelor din meniul de joc + Dimensiunea zonelor tactile din meniul de sesiune. + Setări de joc recomandate + Valori implicite per joc, sugerate pentru acest dispozitiv. + Recomandat: Activat + Recomandat: Dezactivat + Aspecte standard + Adaptate pentru acest ecran + Reaplică aspectele incluse + Restaurează fiecare aspect inclus pe care nu l-ai modificat. + ACEST DISPOZITIV + Elementele după care a fost potrivit profilul. + Model + Placă + Producător + Ecran + Carduri late 3:2 pentru grafică orizontală + Grilă standard + Butoane de acțiune pătrate + Dimensiuni standard + Viteza cursorului Tema diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 30ba961dc..7b32e648b 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -181,6 +181,7 @@ Загрузить обложки + Этой игре не соответствует ни одна библиотека магазина Загрузка Ярлык создан. Не удалось создать ярлык: %s @@ -866,6 +867,46 @@ Чувствительность: %1$d%% Масштаб мыши Масштаб мыши: %1$d%% + + Устройство + ПРОФИЛЬ УСТРОЙСТВА + Один профиль на устройство: макеты и размеры подгоняются под этот экран. + ИЗМЕНИТЬ + Определено автоматически + Выбрано вручную + По умолчанию + Все телефоны и планшеты без собственного профиля. + Astra 2 + Игровой планшет RedMagic Astra 2, все региональные модели. + ЧТО ЗАДАЁТ ЭТОТ ПРОФИЛЬ + Значения по умолчанию, которые профиль применяет во всём приложении. + Макеты элементов управления + Встроенные сенсорные макеты, размещённые под этот размер экрана. + Адаптивные стики + Значение по умолчанию для контейнеров, где оно ещё не задавалось. + Размеры и пропорции значков библиотеки + Столбцы сетки и форма обложек в библиотеке. + Размеры кнопок игрового меню + Размеры зон касания в игровой панели. + Рекомендуемые настройки игр + Настройки по умолчанию, предлагаемые для этого устройства. + Рекомендуется: Вкл. + Рекомендуется: Выкл. + Исходные макеты + Подогнано под этот экран + Применить встроенные макеты заново + Восстанавливает все встроенные макеты, которые вы не изменяли. + ЭТО УСТРОЙСТВО + С чем сопоставлялся профиль. + Модель + Плата + Производитель + Экран + Широкие карточки 3:2 для горизонтальных обложек + Стандартная сетка + Квадратные кнопки действий + Стандартные размеры + Скорость курсора Тема diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 7ea33c36b..85efb70bc 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -179,6 +179,7 @@ Hämta grafik + Inga butiksbibliotek matchar det här spelet Hämtar Genväg skapad Det gick inte att skapa genväg: %s @@ -1081,6 +1082,46 @@ T.ex. META för META-tangent, \n Musskala Musskala: %1$d%% + + Enhet + ENHETSPROFIL + En profil per enhet som anpassar layouter och storlekar till den här skärmen. + BYT + Upptäckt automatiskt + Vald manuellt + Standard + Alla telefoner och tabletter utan en egen profil. + Astra 2 + Speltabletten RedMagic Astra 2, alla regionala modeller. + VAD DEN HÄR PROFILEN STÄLLER IN + Standardvärden som profilen tillämpar i hela appen. + Layouter för inmatningskontroller + Medföljande pekkontrollayouter placerade för den här skärmstorleken. + Adaptiva styrspakar + Standard för containrar som aldrig ställt in det. + Ikonstorlekar och proportioner i biblioteket + Rutnätskolumner och omslagsform i biblioteket. + Knappstorlekar i spelmenyn + Storlek på tryckytorna i sessionsmenyn. + Rekommenderade spelinställningar + Standardvärden per spel som föreslås för den här enheten. + Rekommenderat: På + Rekommenderat: Av + Standardlayouter + Anpassade för den här skärmen + Tillämpa medföljande layouter igen + Återställer alla medföljande layouter som du inte har redigerat. + DEN HÄR ENHETEN + Vad profilen matchades mot. + Modell + Kretskort + Tillverkare + Skärm + Breda 3:2-kort för liggande grafik + Standardrutnät + Kvadratiska åtgärdsknappar + Standardstorlekar + Markörhastighet Tema diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index fec8fe2a2..b58ea9554 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -179,6 +179,7 @@ ดึงภาพประกอบ + ไม่มีคลังร้านค้าที่ตรงกับเกมนี้ กำลังดึง สร้างทางลัดแล้ว สร้างทางลัดไม่สำเร็จ: %s @@ -1081,6 +1082,46 @@ สเกลเมาส์ สเกลเมาส์: %1$d%% + + อุปกรณ์ + โปรไฟล์อุปกรณ์ + หนึ่งโปรไฟล์ต่อหนึ่งอุปกรณ์ ปรับเลย์เอาต์และขนาดให้เข้ากับหน้าจอนี้ + เปลี่ยน + ตรวจพบอัตโนมัติ + เลือกด้วยตนเอง + ค่าเริ่มต้น + โทรศัพท์และแท็บเล็ตทุกเครื่องที่ไม่มีโปรไฟล์ของตัวเอง + Astra 2 + แท็บเล็ตเกมมิ่ง RedMagic Astra 2 ทุกรุ่นในทุกภูมิภาค + สิ่งที่โปรไฟล์นี้กำหนด + ค่าเริ่มต้นที่โปรไฟล์ใช้กับทั้งแอป + เลย์เอาต์ปุ่มควบคุมอินพุต + เลย์เอาต์สัมผัสในตัว จัดวางให้พอดีกับขนาดหน้าจอนี้ + จอยสติกแบบปรับอัตโนมัติ + ค่าเริ่มต้นสำหรับคอนเทนเนอร์ที่ยังไม่เคยตั้งค่านี้ + ขนาดและสัดส่วนไอคอนในคลัง + จำนวนคอลัมน์และรูปทรงภาพปกในคลัง + ขนาดปุ่มเมนูในเกม + ขนาดพื้นที่สัมผัสในเมนูในเกม + การตั้งค่าเกมที่แนะนำ + ค่าเริ่มต้นเฉพาะเกมที่แนะนำสำหรับอุปกรณ์นี้ + แนะนำ: เปิด + แนะนำ: ปิด + เลย์เอาต์ต้นฉบับ + ปรับให้เข้ากับหน้าจอนี้ + ใช้เลย์เอาต์ในตัวอีกครั้ง + คืนค่าเลย์เอาต์ในตัวทุกชุดที่คุณยังไม่ได้แก้ไข + อุปกรณ์นี้ + ข้อมูลที่ใช้จับคู่กับโปรไฟล์ + รุ่น + บอร์ด + ผู้ผลิต + หน้าจอ + การ์ดกว้าง 3:2 สำหรับภาพแนวนอน + ตารางมาตรฐาน + ปุ่มการทำงานทรงสี่เหลี่ยมจัตุรัส + ขนาดมาตรฐาน + ความเร็วเคอร์เซอร์ ธีม diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 2a7ad1095..8a6220eef 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -179,6 +179,7 @@ Görselleri Getir + Bu oyunla eşleşen mağaza kütüphanesi yok Getiriliyor Kısayol oluşturuldu Kısayol oluşturulamadı: %s @@ -1081,6 +1082,46 @@ E.g. META için META tuşu, \n Fare Ölçeği Fare Ölçeği: %1$d%% + + Cihaz + CİHAZ PROFİLİ + Her cihaz için tek profil; düzenleri ve boyutları bu ekrana göre ayarlar. + DEĞİŞTİR + Otomatik algılandı + Elle seçildi + Varsayılan + Kendi profili olmayan tüm telefon ve tabletler. + Astra 2 + RedMagic Astra 2 oyun tableti, tüm bölgesel modeller. + BU PROFİLİN AYARLADIKLARI + Profilin uygulama genelinde uyguladığı varsayılanlar. + Giriş kontrol düzenleri + Bu ekran boyutuna göre yerleştirilmiş yerleşik dokunmatik düzenler. + Uyarlanabilir analog çubuklar + Bunu hiç ayarlamamış konteynerler için varsayılan. + Kütüphane simge boyutları ve oranları + Kütüphanedeki ızgara sütunları ve kapak şekli. + Oyun içi menü düğme boyutları + Oturum çekmecesindeki dokunma hedefi boyutları. + Önerilen oyun ayarları + Bu cihaz için önerilen oyun başına varsayılanlar. + Önerilen: Açık + Önerilen: Kapalı + Standart düzenler + Bu ekrana göre ayarlandı + Yerleşik düzenleri yeniden uygula + Düzenlemediğiniz tüm yerleşik düzenleri geri yükler. + BU CİHAZ + Profilin eşleştirildiği bilgiler. + Model + Kart + Üretici + Ekran + Yatay görseller için geniş 3:2 kartlar + Standart ızgara + Kare eylem düğmeleri + Standart boyutlar + İmleç Hızı Tema diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index ef3f9a528..44ea17fd3 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -182,6 +182,7 @@ Завантажити зображення + Жодна бібліотека магазину не містить цієї гри Завантаження Ярлик створено Не вдалося створити ярлик: %s @@ -800,6 +801,46 @@ Мертва зона: %1$d%% Чутливість: %1$d%% + + Пристрій + ПРОФІЛЬ ПРИСТРОЮ + Один профіль на пристрій — підлаштовує макети та розміри під цей екран. + ЗМІНИТИ + Визначено автоматично + Вибрано вручну + Типовий + Усі телефони та планшети без власного профілю. + Astra 2 + Ігровий планшет RedMagic Astra 2, усі регіональні моделі. + ЩО ЗАДАЄ ЦЕЙ ПРОФІЛЬ + Типові значення, які профіль застосовує в усьому застосунку. + Макети керування + Вбудовані сенсорні макети, розміщені під цей розмір екрана. + Адаптивні джойстики + Типове значення для контейнерів, де його ніколи не задавали. + Розміри та пропорції значків бібліотеки + Стовпці сітки та форма обкладинок у бібліотеці. + Розміри кнопок меню в грі + Розміри зон торкання в меню сесії. + Рекомендовані налаштування гри + Типові значення для кожної гри, запропоновані для цього пристрою. + Рекомендовано: увімк. + Рекомендовано: вимк. + Стандартні макети + Підлаштовано під цей екран + Застосувати вбудовані макети знову + Відновлює всі вбудовані макети, які ви не редагували. + ЦЕЙ ПРИСТРІЙ + З чим було зіставлено профіль. + Модель + Плата + Виробник + Екран + Широкі картки 3:2 для горизонтальних обкладинок + Стандартна сітка + Квадратні кнопки дій + Стандартні розміри + Швидкість курсора Тема diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 3c42fc0ef..4cbbba823 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -178,6 +178,7 @@ 抓取封面 + 没有商店库与此游戏匹配 抓取中 快捷方式已创建 创建快捷方式失败:%s @@ -794,6 +795,46 @@ 死区:%1$d%% 灵敏度:%1$d%% + + 设备 + 设备配置文件 + 每台设备一个配置文件,按此屏幕调整布局和尺寸。 + 更换 + 自动检测 + 手动选择 + 默认 + 适用于所有没有专属配置文件的手机和平板。 + Astra 2 + RedMagic Astra 2 游戏平板,涵盖所有地区版本。 + 此配置文件的设定内容 + 配置文件在整个应用中应用的默认值。 + 输入控制布局 + 内置触摸布局按此屏幕尺寸摆放。 + 自适应摇杆 + 从未设置过此项的容器所用的默认值。 + 游戏库图标大小和比例 + 游戏库中的网格列数和封面形状。 + 游戏内菜单按钮大小 + 会话抽屉中的触摸区域大小。 + 推荐游戏设置 + 为此设备建议的单游戏默认值。 + 推荐:开 + 推荐:关 + 原始布局 + 已按此屏幕调整 + 重新应用内置布局 + 恢复所有你未编辑过的内置布局。 + 本设备 + 配置文件的匹配依据。 + 型号 + 主板 + 制造商 + 屏幕 + 适合横向封面的 3:2 宽卡片 + 默认网格 + 方形操作按钮 + 默认尺寸 + 光标速度 主题 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index c145c526d..1328f1234 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -178,6 +178,7 @@ 擷取美術圖 + 沒有商店遊戲庫符合此遊戲 擷取中 捷徑已建立 無法建立捷徑:%s @@ -794,6 +795,46 @@ 死區:%1$d%% 靈敏度:%1$d%% + + 裝置 + 裝置設定檔 + 每部裝置一個設定檔,依此螢幕調整版面配置與尺寸。 + 變更 + 自動偵測 + 手動選擇 + 預設 + 所有沒有專屬設定檔的手機與平板。 + Astra 2 + RedMagic Astra 2 遊戲平板,涵蓋所有地區型號。 + 此設定檔會設定什麼 + 設定檔在整個應用程式中套用的預設值。 + 輸入控制版面配置 + 依此螢幕尺寸擺放的內建觸控版面配置。 + 自適應搖桿 + 從未設定過此項的容器所使用的預設值。 + 遊戲庫圖示尺寸與比例 + 遊戲庫中的格線欄數與封面形狀。 + 遊戲內選單按鈕尺寸 + 工作階段抽屜中的觸控目標尺寸。 + 建議的遊戲設定 + 為此裝置建議的個別遊戲預設值。 + 建議:開啟 + 建議:關閉 + 原始版面配置 + 已針對此螢幕調整 + 重新套用內建版面配置 + 還原你未曾編輯過的所有內建版面配置。 + 此裝置 + 設定檔比對時所依據的資訊。 + 型號 + 主板 + 製造商 + 螢幕 + 適合橫向封面的 3:2 寬卡片 + 預設格線 + 方形操作按鈕 + 預設尺寸 + 游標速度 佈景主題 diff --git a/app/src/main/res/values/refs.xml b/app/src/main/res/values/refs.xml index 587d87110..dd8186d2d 100644 --- a/app/src/main/res/values/refs.xml +++ b/app/src/main/res/values/refs.xml @@ -1,6 +1,7 @@ + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 72bc6a56b..82dc45924 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1230,6 +1230,46 @@ E.g. META for META key, \n Mouse Scale Mouse Scale: %1$d%% + + Device + DEVICE PROFILE + One profile per device, tuning layouts and sizes to this screen. + CHANGE + Detected automatically + Chosen manually + Default + Every phone and tablet without a profile of its own. + Astra 2 + RedMagic Astra 2 gaming tablet, every regional model. + WHAT THIS PROFILE SETS + Defaults the profile applies across the app. + Input control layouts + Bundled touch layouts placed for this screen size. + Adaptive joysticks + Default for containers that have never set it. + Library icon sizes and ratios + Grid columns and cover shape in the library. + In-game menu button sizes + Touch target sizes in the session drawer. + Recommended game settings + Per-game defaults suggested for this device. + Recommended: On + Recommended: Off + Stock layouts + Tuned for this screen + Re-apply bundled layouts + Restores every bundled layout you have not edited. + THIS DEVICE + What the profile was matched against. + Model + Board + Manufacturer + Screen + Wide 3:2 cards for landscape artwork + Stock grid + Square action buttons + Stock sizes + Cursor Speed Theme diff --git a/app/src/main/runtime/display/XServerDisplayActivity.java b/app/src/main/runtime/display/XServerDisplayActivity.java index 44e6c255c..de8a74ad8 100644 --- a/app/src/main/runtime/display/XServerDisplayActivity.java +++ b/app/src/main/runtime/display/XServerDisplayActivity.java @@ -48,6 +48,7 @@ import androidx.core.content.FileProvider; import androidx.compose.ui.platform.ComposeView; import androidx.core.view.WindowInsetsCompat; +import com.winlator.cmod.app.config.DeviceProfileSettings; import com.winlator.cmod.BuildConfig; import com.winlator.cmod.feature.leaderboard.SessionRecordingController; import com.winlator.cmod.feature.stores.steam.enums.Marker; @@ -779,7 +780,10 @@ private String getShortcutSetting(String key, String containerValue) { } private String containerAdaptiveJoysticks() { - return container != null ? container.getExtra(InputControlsView.EXTRA_ADAPTIVE_JOYSTICKS, "0") : "0"; + String fallback = DeviceProfileSettings.adaptiveJoysticksDefaultExtra(this); + return container != null + ? container.getExtra(InputControlsView.EXTRA_ADAPTIVE_JOYSTICKS, fallback) + : fallback; } private boolean isAdaptiveJoysticksEnabled() { diff --git a/app/src/main/runtime/display/XServerDrawerMenu.kt b/app/src/main/runtime/display/XServerDrawerMenu.kt index becdb870a..d5ed93795 100644 --- a/app/src/main/runtime/display/XServerDrawerMenu.kt +++ b/app/src/main/runtime/display/XServerDrawerMenu.kt @@ -1,5 +1,7 @@ package com.winlator.cmod.runtime.display +import androidx.compose.ui.platform.LocalContext +import com.winlator.cmod.app.config.DeviceProfileSettings import android.app.Activity import android.content.Context import androidx.compose.animation.AnimatedContent @@ -2112,17 +2114,28 @@ private fun ActionCardGrid( } val verticalPadding = (10f * paneScale).dp + val horizontalPadding = (10f * paneScale).dp + val maxAspect = DeviceProfileSettings.sessionActionCardMaxAspect(LocalContext.current) BoxWithConstraints(modifier = Modifier.fillMaxSize()) { val rows = ((cards.size + ActionCardColumns - 1) / ActionCardColumns).coerceAtLeast(1) + val cardWidth = + (maxWidth - horizontalPadding * 2 - ActionCardSpacing * (ActionCardColumns - 1)) / ActionCardColumns val rowHeight = - ((maxHeight - verticalPadding * 2 - ActionCardSpacing * (rows - 1)) / rows) - .coerceAtLeast(ActionCardMinHeight * paneScale) + DeviceProfileSettings + .actionCardRowHeight( + availableHeight = (maxHeight - verticalPadding * 2).value, + cardWidth = cardWidth.value, + rows = rows, + spacing = ActionCardSpacing.value, + minHeight = (ActionCardMinHeight * paneScale).value, + maxAspect = maxAspect, + ).dp Column( modifier = Modifier .fillMaxSize() .verticalScroll(rememberScrollState()) - .padding(horizontal = (10f * paneScale).dp, vertical = verticalPadding), + .padding(horizontal = horizontalPadding, vertical = verticalPadding), ) { FlowRow( modifier = Modifier.fillMaxWidth(), diff --git a/app/src/main/runtime/input/controls/ControlElement.java b/app/src/main/runtime/input/controls/ControlElement.java index eacbc44bc..3f5129a8e 100644 --- a/app/src/main/runtime/input/controls/ControlElement.java +++ b/app/src/main/runtime/input/controls/ControlElement.java @@ -103,6 +103,7 @@ public static String[] names() { private boolean isRadialBindingCurrentlyHeld = false; private boolean wasExpandedOnDown = false; private int currentPointerId = -1; + private int pressGeneration = 0; private boolean adaptiveShifted = false; private final Rect boundingBox = new Rect(); private final Path path = new Path(); @@ -3851,6 +3852,7 @@ public boolean handleTouchDown(int pointerId, float x, float y) { currentPointerId = pointerId; if (isAdaptiveStick()) shiftAdaptiveOrigin(x, y); if (type == Type.BUTTON) { + pressGeneration++; if (isKeepButtonPressedAfterMinTime()) touchTime = System.currentTimeMillis(); if (!toggleSwitch || !selected) { dispatchButtonBinding(true); @@ -3903,9 +3905,6 @@ public boolean handleTouchDown(int pointerId, float x, float y) { public boolean handleTouchMove(int pointerId, float x, float y) { if (pointerId == currentPointerId && type == Type.BUTTON) { - if (!containsPoint(x, y)) { - handleTouchUp(pointerId, x, y); - } return true; } @@ -4111,8 +4110,10 @@ public boolean handleTouchUp(int pointerId, float x, float y) { if (isKeepButtonPressedAfterMinTime() && touchTime != null) { long held = System.currentTimeMillis() - (long) touchTime; long delay = Math.max(0L, BUTTON_MIN_TIME_TO_KEEP_PRESSED - held); + final int generation = ++pressGeneration; inputControlsView.postDelayed( () -> { + if (generation != pressGeneration) return; dispatchButtonBinding(false); inputControlsView.invalidate(); }, diff --git a/app/src/main/runtime/input/controls/InputControlsManager.java b/app/src/main/runtime/input/controls/InputControlsManager.java index d299794b3..ad996525b 100644 --- a/app/src/main/runtime/input/controls/InputControlsManager.java +++ b/app/src/main/runtime/input/controls/InputControlsManager.java @@ -8,6 +8,7 @@ import android.util.JsonReader; import android.util.Log; import androidx.preference.PreferenceManager; +import com.winlator.cmod.app.config.DeviceProfileSettings; import com.winlator.cmod.app.config.SettingsConfig; import com.winlator.cmod.shared.android.AppUtils; import com.winlator.cmod.shared.io.FileUtils; @@ -25,7 +26,8 @@ import org.json.JSONObject; public class InputControlsManager { - private static final int ASSET_PROFILE_SYNC_REVISION = 8; + private static final int ASSET_PROFILE_SYNC_REVISION = 12; + private static final String ASSET_PROFILES_DIR = "inputcontrols/profiles"; public static final int LAST_BUILTIN_PROFILE_ID = 8; public static final int VIRTUAL_GAMEPAD_BUILTIN_ID = 3; public static final int GAMEHUB_LAYOUT_BUILTIN_ID = 7; @@ -107,6 +109,35 @@ public ArrayList getProfiles(boolean ignoreTemplates) { return profiles; } + private static String assetProfilesDir(String deviceToken) { + if (deviceToken == null || deviceToken.isEmpty()) return ASSET_PROFILES_DIR; + return ASSET_PROFILES_DIR + "-" + deviceToken; + } + + private static String[] listAssetProfiles(AssetManager assetManager, String dir) { + String[] names; + try { + names = assetManager.list(dir); + } catch (IOException e) { + return null; + } + if (names == null) return null; + ArrayList icps = new ArrayList<>(); + for (String name : names) if (name.toLowerCase(Locale.ROOT).endsWith(".icp")) icps.add(name); + return icps.isEmpty() ? null : icps.toArray(new String[0]); + } + + private static boolean isPristine(File workingFile, File backupFile) { + if (!workingFile.isFile()) return true; + if (!backupFile.isFile()) return false; + return FileUtils.contentEquals(workingFile, backupFile); + } + + public void resyncAssetProfiles() { + profilesLoaded = false; + loadProfiles(false); + } + private void copyAssetProfilesIfNeeded() { InputControlsManager.getProfilesDir(context); @@ -114,35 +145,53 @@ private void copyAssetProfilesIfNeeded() { int newVersion = AppUtils.getVersionCode(context); int oldVersion = preferences.getInt("inputcontrols_app_version", 0); int oldSyncRevision = preferences.getInt("inputcontrols_asset_sync_revision", 0); - if (oldVersion == newVersion && oldSyncRevision >= ASSET_PROFILE_SYNC_REVISION) return; - preferences - .edit() - .putInt("inputcontrols_app_version", newVersion) - .putInt("inputcontrols_asset_sync_revision", ASSET_PROFILE_SYNC_REVISION) - .apply(); + String deviceToken = DeviceProfileSettings.assetProfilesToken(context); + String oldDeviceToken = preferences.getString("inputcontrols_asset_device_profile", null); + boolean deviceChanged = !deviceToken.equals(oldDeviceToken); + if (oldVersion == newVersion + && oldSyncRevision >= ASSET_PROFILE_SYNC_REVISION + && !deviceChanged) return; for (int id : RETIRED_PROFILE_IDS) { ControlsProfile.getProfileFile(context, id).delete(); getBackupFile(context, id).delete(); } - for (int id : REFRESHED_PROFILE_IDS) { - ControlsProfile.getProfileFile(context, id).delete(); + if (oldVersion != newVersion || oldSyncRevision < ASSET_PROFILE_SYNC_REVISION) { + for (int id : REFRESHED_PROFILE_IDS) { + ControlsProfile.getProfileFile(context, id).delete(); + } } - try { - AssetManager assetManager = context.getAssets(); - String[] assetFiles = assetManager.list("inputcontrols/profiles"); - if (assetFiles == null) return; - for (String assetFile : assetFiles) { - String assetPath = "inputcontrols/profiles/" + assetFile; - ControlsProfile originProfile = loadProfile(context, assetManager.open(assetPath)); - if (originProfile == null) continue; - File workingFile = ControlsProfile.getProfileFile(context, originProfile.id); - if (!workingFile.isFile()) FileUtils.copy(context, assetPath, workingFile); - FileUtils.copy(context, assetPath, getBackupFile(context, originProfile.id)); + AssetManager assetManager = context.getAssets(); + String assetDir = assetProfilesDir(deviceToken); + String[] assetFiles = listAssetProfiles(assetManager, assetDir); + if (assetFiles == null && !assetDir.equals(ASSET_PROFILES_DIR)) { + assetDir = ASSET_PROFILES_DIR; + assetFiles = listAssetProfiles(assetManager, assetDir); + } + if (assetFiles == null) return; + + for (String assetFile : assetFiles) { + String assetPath = assetDir + "/" + assetFile; + ControlsProfile originProfile; + try (InputStream inStream = assetManager.open(assetPath)) { + originProfile = loadProfile(context, inStream); + } catch (IOException e) { + continue; } - } catch (IOException e) { + if (originProfile == null) continue; + File workingFile = ControlsProfile.getProfileFile(context, originProfile.id); + File backupFile = getBackupFile(context, originProfile.id); + if (isPristine(workingFile, backupFile)) FileUtils.copy(context, assetPath, workingFile); + FileUtils.copy(context, assetPath, backupFile); } + + preferences + .edit() + .putInt("inputcontrols_app_version", newVersion) + .putInt("inputcontrols_asset_sync_revision", ASSET_PROFILE_SYNC_REVISION) + .putString("inputcontrols_asset_device_profile", deviceToken) + .apply(); } public void loadProfiles(boolean ignoreTemplates) { diff --git a/app/src/main/runtime/input/ui/InputControlsView.java b/app/src/main/runtime/input/ui/InputControlsView.java index f958e70bc..4236476d9 100644 --- a/app/src/main/runtime/input/ui/InputControlsView.java +++ b/app/src/main/runtime/input/ui/InputControlsView.java @@ -26,8 +26,10 @@ import android.view.View; import android.view.ViewGroup; import android.widget.FrameLayout; +import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; +import java.util.List; import java.util.Set; import androidx.preference.PreferenceManager; import com.winlator.cmod.R; @@ -87,7 +89,7 @@ public class InputControlsView extends View { private Runnable hideControlsRunnable; // Runnable to hide the controls private SharedPreferences preferences; - private final SparseArray activeTouchElements = new SparseArray<>(); + private final SparseArray> activeTouchElements = new SparseArray<>(); private ControlElement stickElement; @@ -416,6 +418,7 @@ public synchronized void setProfile(ControlsProfile profile) { deselectAllElements(); } else this.profile = null; activeTouchElements.clear(); + syncCapturedPointers(); invalidate(); } @@ -504,20 +507,68 @@ public void setXServer(XServer xServer) { createMouseMoveTimer(); } + private void addCapture(int pointerId, ControlElement element) { + ArrayList captured = activeTouchElements.get(pointerId); + if (captured == null) { + captured = new ArrayList<>(2); + activeTouchElements.put(pointerId, captured); + } + if (!captured.contains(element)) captured.add(element); + } + + private void replaceCapture(int pointerId, ControlElement from, ControlElement to) { + ArrayList captured = activeTouchElements.get(pointerId); + if (captured == null) { + addCapture(pointerId, to); + return; + } + int index = captured.indexOf(from); + if (index == -1) { + if (!captured.contains(to)) captured.add(to); + return; + } + if (captured.contains(to)) captured.remove(index); + else captured.set(index, to); + } + + private List capturesFor(int pointerId) { + ArrayList captured = activeTouchElements.get(pointerId); + return captured == null ? java.util.Collections.emptyList() : captured; + } + + private boolean releaseCaptures(int pointerId, float x, float y, boolean withPosition) { + ArrayList captured = activeTouchElements.get(pointerId); + if (captured == null) return false; + boolean released = false; + for (int i = 0; i < captured.size(); i++) { + ControlElement element = captured.get(i); + if (element == null) continue; + boolean handled = + withPosition ? element.handleTouchUp(pointerId, x, y) : element.handleTouchUp(pointerId); + released |= handled; + } + activeTouchElements.remove(pointerId); + return released; + } + private void releaseActiveTouchElements() { - for (int i = 0; i < activeTouchElements.size(); i++) { - int activePointerId = activeTouchElements.keyAt(i); - ControlElement activeElement = activeTouchElements.valueAt(i); - if (activeElement != null) { - activeElement.handleTouchUp(activePointerId); - } + boolean batched = batchingUpdates; + batchingUpdates = true; + boolean released = false; + for (int i = activeTouchElements.size() - 1; i >= 0; i--) { + released |= releaseCaptures(activeTouchElements.keyAt(i), 0f, 0f, false); } activeTouchElements.clear(); + batchingUpdates = batched; + if (released) flushGamepadState(); + syncCapturedPointers(); } // Release captures whose pointer is no longer reported (missed UP/CANCEL). private void releaseStaleCaptures(MotionEvent event) { boolean removedAny = false; + boolean batched = batchingUpdates; + batchingUpdates = true; for (int i = activeTouchElements.size() - 1; i >= 0; i--) { int capturedId = activeTouchElements.keyAt(i); boolean stillDown = false; @@ -528,13 +579,21 @@ private void releaseStaleCaptures(MotionEvent event) { } } if (!stillDown) { - ControlElement element = activeTouchElements.valueAt(i); - if (element != null) element.handleTouchUp(capturedId); - activeTouchElements.removeAt(i); + releaseCaptures(capturedId, 0f, 0f, false); removedAny = true; } } - if (removedAny) syncCapturedPointers(); + batchingUpdates = batched; + if (removedAny) { + flushGamepadState(); + syncCapturedPointers(); + } + } + + private void flushGamepadState() { + if (batchingUpdates) return; + WinHandler winHandler = xServer != null ? xServer.getWinHandler() : null; + if (winHandler != null) winHandler.sendGamepadState(); } public void cancelContinuousMouseMove() { @@ -827,22 +886,30 @@ public boolean onTouchEvent(MotionEvent event) { float x = event.getX(actionIndex); float y = event.getY(actionIndex); + batchingUpdates = true; + if (stickElement != null && stickElement.handleTouchDown(pointerId, x, y)) { eventHandled = true; - activeTouchElements.put(pointerId, stickElement); + addCapture(pointerId, stickElement); } if (!eventHandled) { + boolean allowCoPress = true; for (ControlElement element : profile.getElements()) { - if (element.handleTouchDown(pointerId, x, y)) { + if (eventHandled + && (!allowCoPress || element.getType() != ControlElement.Type.BUTTON)) continue; + if (!element.handleTouchDown(pointerId, x, y)) continue; + addCapture(pointerId, element); + if (!eventHandled) { + allowCoPress = element.getType() != ControlElement.Type.RADIAL_MENU; eventHandled = true; - activeTouchElements.put(pointerId, element); - - if (hapticsEnabled) triggerTouchHaptic(); - break; } } + if (eventHandled && hapticsEnabled) triggerTouchHaptic(); } + + batchingUpdates = false; + if (eventHandled) flushGamepadState(); syncCapturedPointers(); if (!eventHandled) dispatchUnhandledTouch(event); break; @@ -859,54 +926,45 @@ public boolean onTouchEvent(MotionEvent event) { float x = event.getX(i); float y = event.getY(i); - ControlElement activeElement = activeTouchElements.get(movePointerId); boolean swipeAllowed = touchpadView == null || touchpadView.getScreenTouchMode() != TouchpadView.MODE_MAP_TO_RIGHT_STICK; boolean pointerHandled = false; - if (swipeAllowed - && activeElement != null - && activeElement.isCapturing(movePointerId) - && (activeElement.getType() == ControlElement.Type.RADIAL_MENU - || activeElement.getType() == ControlElement.Type.D_PAD) - && !activeElement.containsPoint(x, y)) { - for (ControlElement element : profile.getElements()) { - if (element.isSwipeTarget() && element.handleTouchDown(movePointerId, x, y)) { - activeElement.handleTouchUp(movePointerId, x, y); - activeTouchElements.put(movePointerId, element); - activeElement = element; - pointerHandled = true; - capturesChanged = true; - if (hapticsEnabled) triggerTouchHaptic(); - break; - } - } - } + List captured = capturesFor(movePointerId); + ControlElement[] snapshot = captured.toArray(new ControlElement[0]); - if (!pointerHandled) { - pointerHandled = - activeElement != null && activeElement.handleTouchMove(movePointerId, x, y); - } + for (ControlElement activeElement : snapshot) { + if (activeElement == null) continue; - if (swipeAllowed - && activeElement != null - && activeElement.getType() == ControlElement.Type.BUTTON - && !activeElement.isCapturing(movePointerId)) { - for (ControlElement element : profile.getElements()) { - if (element.isSwipeTarget() && element.handleTouchDown(movePointerId, x, y)) { - activeTouchElements.put(movePointerId, element); - pointerHandled = true; - capturesChanged = true; - if (hapticsEnabled) triggerTouchHaptic(); - break; + boolean handedOff = false; + if (swipeAllowed + && activeElement.isCapturing(movePointerId) + && (activeElement.getType() == ControlElement.Type.RADIAL_MENU + || activeElement.getType() == ControlElement.Type.D_PAD) + && !activeElement.containsPoint(x, y)) { + for (ControlElement element : profile.getElements()) { + if (element == activeElement) continue; + if (element.isSwipeTarget() && element.handleTouchDown(movePointerId, x, y)) { + activeElement.handleTouchUp(movePointerId, x, y); + replaceCapture(movePointerId, activeElement, element); + pointerHandled = true; + handedOff = true; + capturesChanged = true; + if (hapticsEnabled) triggerTouchHaptic(); + break; + } } } + + if (handedOff) continue; + + if (activeElement.handleTouchMove(movePointerId, x, y)) pointerHandled = true; } - if (!pointerHandled && activeElement == null) { + if (!pointerHandled && snapshot.length == 0) { if (stickElement != null && stickElement.handleTouchMove(movePointerId, x, y)) { - activeTouchElements.put(movePointerId, stickElement); + addCapture(movePointerId, stickElement); pointerHandled = true; capturesChanged = true; } @@ -914,7 +972,7 @@ public boolean onTouchEvent(MotionEvent event) { if (!pointerHandled) { for (ControlElement element : profile.getElements()) { if (element.handleTouchMove(movePointerId, x, y)) { - activeTouchElements.put(movePointerId, element); + addCapture(movePointerId, element); pointerHandled = true; capturesChanged = true; break; @@ -939,26 +997,24 @@ public boolean onTouchEvent(MotionEvent event) { } case MotionEvent.ACTION_UP: case MotionEvent.ACTION_POINTER_UP: { - ControlElement activeElement = activeTouchElements.get(pointerId); float x = event.getX(actionIndex); float y = event.getY(actionIndex); - if (activeElement != null) { - eventHandled = activeElement.handleTouchUp(pointerId, x, y); - activeTouchElements.remove(pointerId); + batchingUpdates = true; + if (activeTouchElements.get(pointerId) != null) { + eventHandled = releaseCaptures(pointerId, x, y, true); } else { if (stickElement != null && stickElement.handleTouchUp(pointerId, x, y)) { eventHandled = true; } - if (!eventHandled) { - for (ControlElement element : profile.getElements()) { - if (element.handleTouchUp(pointerId, x, y)) { - eventHandled = true; - break; - } + for (ControlElement element : profile.getElements()) { + if (element.handleTouchUp(pointerId, x, y)) { + eventHandled = true; } } } + batchingUpdates = false; + if (eventHandled) flushGamepadState(); syncCapturedPointers(); if (!eventHandled) dispatchUnhandledTouch(event); break; diff --git a/app/src/main/shared/ui/FourByTwoGridView.kt b/app/src/main/shared/ui/FourByTwoGridView.kt index 6ae1c234c..293d455a5 100644 --- a/app/src/main/shared/ui/FourByTwoGridView.kt +++ b/app/src/main/shared/ui/FourByTwoGridView.kt @@ -26,6 +26,8 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import com.winlator.cmod.app.config.DeviceProfileSettings +import com.winlator.cmod.shared.ui.layout.isPortraitLayout import com.winlator.cmod.shared.ui.layout.screenWidthDp import kotlinx.coroutines.delay import kotlinx.coroutines.flow.StateFlow @@ -33,16 +35,29 @@ import kotlin.math.abs enum class ViewMode { Grid } -fun gridColumnsForWidth(widthDp: Int): Int = - when { - widthDp <= 0 -> 4 - widthDp < 480 -> 2 - widthDp < 700 -> 3 - else -> 4 - } +fun gridColumnsForWidth(widthDp: Int): Int = DeviceProfileSettings.stockLibraryColumns(widthDp) + +@Composable +fun defaultGridColumns(): Int = + DeviceProfileSettings.libraryColumns( + LocalContext.current, + screenWidthDp().value.toInt(), + isPortraitLayout(), + ) @Composable -fun defaultGridColumns(): Int = gridColumnsForWidth(screenWidthDp().value.toInt()) +fun defaultGridImageAspect(): Float? = DeviceProfileSettings.libraryImageAspect(LocalContext.current) + +fun gridRowHeightFor( + columnWidth: Dp, + imageAspect: Float?, + titleStripDp: Float, +): Dp = + if (imageAspect == null || imageAspect <= 0f) { + columnWidth * DeviceProfileSettings.STOCK_LIBRARY_CARD_FACTOR + } else { + columnWidth / imageAspect + titleStripDp.dp + } /** * Unified grid layout used by store tabs @@ -62,6 +77,7 @@ fun FourByTwoGridView( items: List, modifier: Modifier = Modifier, columns: Int = defaultGridColumns(), + imageAspect: Float? = defaultGridImageAspect(), spacing: Dp = 12.dp, contentPadding: PaddingValues = PaddingValues(0.dp), gridState: LazyGridState = rememberLazyGridState(), @@ -94,7 +110,11 @@ fun FourByTwoGridView( val availableColumnWidth = ((maxWidth - horizontalInset - spacing * (effectiveColumns - 1).toFloat()) / effectiveColumns.toFloat()) .coerceAtLeast(1.dp) - val targetRowHeight = minOf(availableRowHeight, availableColumnWidth * 1.25f) + val targetRowHeight = + minOf( + availableRowHeight, + gridRowHeightFor(availableColumnWidth, imageAspect, DeviceProfileSettings.libraryTitleStripDp()), + ) val rowHeight by animateDpAsState( targetValue = targetRowHeight, animationSpec = diff --git a/app/src/test/kotlin/com/winlator/cmod/app/config/BundledControlLayoutTest.kt b/app/src/test/kotlin/com/winlator/cmod/app/config/BundledControlLayoutTest.kt new file mode 100644 index 000000000..361e9031f --- /dev/null +++ b/app/src/test/kotlin/com/winlator/cmod/app/config/BundledControlLayoutTest.kt @@ -0,0 +1,319 @@ +package com.winlator.cmod.app.config + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +class BundledControlLayoutTest { + private data class Box( + val left: Int, + val top: Int, + val right: Int, + val bottom: Int, + ) + + private val assetsRoot: File by lazy { + var dir: File? = File(".").canonicalFile + val suffixes = listOf("app/src/main/assets/inputcontrols", "src/main/assets/inputcontrols") + repeat(6) { + val here = dir ?: return@repeat + suffixes.forEach { suffix -> + val candidate = File(here, suffix) + if (candidate.isDirectory) return@lazy candidate + } + dir = here.parentFile + } + throw AssertionError("bundled input control assets not found from ${File(".").canonicalPath}") + } + + private fun layouts(dir: File): Map> { + val files = dir.listFiles { f -> f.name.endsWith(".icp") }.orEmpty() + return files.associate { f -> + val json = Json.parse(f.readText()).asMap() + json.num("id").toInt() to json + } + } + + private fun snapping(width: Int) = width / 100 + + private fun boxOf( + element: Map, + width: Int, + height: Int, + ): Box { + val snap = snapping(width) + val maxWidth = width / snap * snap + val maxHeight = height / snap * snap + val cx = Math.round(element.num("x") * maxWidth).toInt() + val cy = Math.round(element.num("y") * maxHeight).toInt() + val type = element.text("type") + val shape = element.text("shape") + val bindings = element.strings("bindings") + var halfWidth: Int + var halfHeight: Int + when (type) { + "BUTTON" -> + when (shape) { + "RECT", "ROUND_RECT" -> { + halfWidth = snap * 4 + halfHeight = snap * 2 + } + "SQUARE" -> { + halfWidth = (snap * 2.5f).toInt() + halfHeight = (snap * 2.5f).toInt() + } + else -> { + halfWidth = snap * 3 + halfHeight = snap * 3 + } + } + "D_PAD" -> { + halfWidth = snap * 7 + halfHeight = snap * 7 + } + "STICK", "TRACKPAD" -> { + halfWidth = snap * 6 + halfHeight = snap * 6 + } + "RANGE_BUTTON" -> { + halfWidth = snap * (bindings.size * 4 / 2) + halfHeight = snap * 2 + if ((element["orientation"] as? Number)?.toInt() == 1) { + val swap = halfWidth + halfWidth = halfHeight + halfHeight = swap + } + } + else -> { + halfWidth = snap * 3 + halfHeight = snap * 3 + } + } + val scale = ((element["scale"] as? Number)?.toDouble() ?: 1.0).toFloat() + halfWidth = (halfWidth * scale).toInt() + halfHeight = (halfHeight * scale).toInt() + return Box(cx - halfWidth, cy - halfHeight, cx + halfWidth, cy + halfHeight) + } + + private fun label(element: Map): String = + element.strings("bindings").firstOrNull { it != "NONE" } ?: element.text("type") + + @Test + fun everyDeviceVariantCoversTheSameProfileIds() { + val stock = layouts(File(assetsRoot, "profiles")) + assertTrue("stock layouts missing", stock.isNotEmpty()) + DeviceProfile.entries.filter { it.assetToken.isNotEmpty() }.forEach { profile -> + val dir = File(assetsRoot, "profiles-${profile.assetToken}") + assertTrue("missing asset dir for ${profile.name}: $dir", dir.isDirectory) + val variant = layouts(dir) + assertEquals( + "${profile.name} must ship the same profile ids as the stock layouts", + stock.keys.sorted(), + variant.keys.sorted(), + ) + variant.forEach { (id, json) -> + assertEquals( + "${profile.name} profile $id must keep the stock name so pickers stay stable", + stock.getValue(id).text("name"), + json.text("name"), + ) + } + } + } + + @Test + fun everyBundledElementStaysOnScreen() { + allLayoutDirs().forEach { dir -> + layouts(dir).forEach { (id, json) -> + val elements = json["elements"].asList().map { it.asMap() } + for (element in elements) { + val x = element.num("x") + val y = element.num("y") + assertTrue("${dir.name}/$id ${label(element)} x=$x out of range", x in 0.0..1.0) + assertTrue("${dir.name}/$id ${label(element)} y=$y out of range", y in 0.0..1.0) + } + } + } + } + + @Test + fun deviceVariantElementsDoNotOverlapOnTheTargetScreen() { + val width = 2400 + val height = 1504 + DeviceProfile.entries.filter { it.assetToken.isNotEmpty() }.forEach { profile -> + val dir = File(assetsRoot, "profiles-${profile.assetToken}") + layouts(dir).forEach { (id, json) -> + val elements = json["elements"].asList().map { it.asMap() } + val boxes = elements.map { boxOf(it, width, height) } + for (i in boxes.indices) { + val a = boxes[i] + assertTrue( + "${profile.name} profile $id ${label(elements[i])} runs off screen", + a.left >= 0 && a.top >= 0 && a.right <= width && a.bottom <= height, + ) + for (j in i + 1 until boxes.size) { + val b = boxes[j] + val overlaps = a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom + assertTrue( + "${profile.name} profile $id: ${label(elements[i])} overlaps " + label(elements[j]), + !overlaps, + ) + } + } + } + } + } + + @Test + fun everyTappableElementMeetsTheMinimumTouchTarget() { + val width = 2400 + val height = 1504 + val gameplayFloorPx = 108 + val menuFloorPx = 84 + DeviceProfile.entries.filter { it.assetToken.isNotEmpty() }.forEach { profile -> + val dir = File(assetsRoot, "profiles-${profile.assetToken}") + layouts(dir).forEach { (id, json) -> + val elements = json["elements"].asList().map { it.asMap() } + for (element in elements) { + val box = boxOf(element, width, height) + val shorter = minOf(box.right - box.left, box.bottom - box.top) + val floor = if (isDeliberateTarget(element, box, width)) menuFloorPx else gameplayFloorPx + assertTrue( + "${profile.name} profile $id ${label(element)} is only ${shorter}px across (floor $floor)", + shorter >= floor, + ) + } + } + } + } + + private fun isDeliberateTarget( + element: Map, + box: Box, + width: Int, + ): Boolean { + if (element.text("type") == "RANGE_BUTTON") return true + if (element.text("shape") != "ROUND_RECT") return false + val binding = element.strings("bindings").firstOrNull { it != "NONE" } + if (binding == "GAMEPAD_BUTTON_START" || binding == "GAMEPAD_BUTTON_SELECT") return true + if ((element["iconId"] as? Number)?.toInt() in listOf(15, 16)) return true + val centre = (box.left + box.right) / 2 + return Math.abs(centre - width / 2) <= 400 + } + + private fun allLayoutDirs(): List = + assetsRoot.listFiles { f -> f.isDirectory && f.name.startsWith("profiles") }.orEmpty().toList() +} + +private object Json { + fun parse(text: String): Any? = Reader(text).let { r -> r.value().also { r.skipWs() } } + + private class Reader(private val src: String) { + private var pos = 0 + + fun skipWs() { + while (pos < src.length && src[pos].isWhitespace()) pos++ + } + + fun value(): Any? { + skipWs() + return when (src[pos]) { + '{' -> obj() + '[' -> arr() + '"' -> str() + 't' -> { expect("true"); true } + 'f' -> { expect("false"); false } + 'n' -> { expect("null"); null } + else -> num() + } + } + + private fun expect(word: String) { + require(src.startsWith(word, pos)) { "expected $word at $pos" } + pos += word.length + } + + private fun obj(): Map { + val out = LinkedHashMap() + pos++ + skipWs() + if (src[pos] == '}') { pos++; return out } + while (true) { + skipWs() + val key = str() + skipWs() + require(src[pos] == ':') { "expected : at $pos" } + pos++ + out[key] = value() + skipWs() + when (src[pos]) { + ',' -> pos++ + '}' -> { pos++; return out } + else -> throw IllegalArgumentException("bad object at $pos") + } + } + } + + private fun arr(): List { + val out = ArrayList() + pos++ + skipWs() + if (src[pos] == ']') { pos++; return out } + while (true) { + out.add(value()) + skipWs() + when (src[pos]) { + ',' -> pos++ + ']' -> { pos++; return out } + else -> throw IllegalArgumentException("bad array at $pos") + } + } + } + + private fun str(): String { + require(src[pos] == '"') { "expected string at $pos" } + pos++ + val sb = StringBuilder() + while (src[pos] != '"') { + if (src[pos] == '\\') { + pos++ + when (val c = src[pos]) { + 'n' -> sb.append('\n') + 't' -> sb.append('\t') + 'r' -> sb.append('\r') + 'b' -> sb.append('\b') + 'u' -> { + sb.append(src.substring(pos + 1, pos + 5).toInt(16).toChar()) + pos += 4 + } + else -> sb.append(c) + } + } else { + sb.append(src[pos]) + } + pos++ + } + pos++ + return sb.toString() + } + + private fun num(): Double { + val start = pos + while (pos < src.length && (src[pos].isDigit() || src[pos] in "-+.eE")) pos++ + return src.substring(start, pos).toDouble() + } + } +} + +@Suppress("UNCHECKED_CAST") +private fun Any?.asMap(): Map = this as Map + +@Suppress("UNCHECKED_CAST") +private fun Any?.asList(): List = this as List + +private fun Map.num(key: String): Double = (this[key] as Number).toDouble() + +private fun Map.text(key: String): String = this[key] as String + +private fun Map.strings(key: String): List = this[key].asList().map { it as String } diff --git a/app/src/test/kotlin/com/winlator/cmod/app/config/DeviceProfileTest.kt b/app/src/test/kotlin/com/winlator/cmod/app/config/DeviceProfileTest.kt new file mode 100644 index 000000000..d02ab4feb --- /dev/null +++ b/app/src/test/kotlin/com/winlator/cmod/app/config/DeviceProfileTest.kt @@ -0,0 +1,136 @@ +package com.winlator.cmod.app.config + +import org.junit.Assert.assertEquals +import org.junit.Test + +class DeviceProfileTest { + private val astra2Build = "nubia redmagic NP06J PQ85P01_A PQ85P01-UN qcom" + private val astra2Marketing = "REDMAGIC Astra 2 Gaming Tablet" + + @Test + fun marketingNameIdentifiesAstra2() { + assertEquals(DeviceProfile.ASTRA_2, DeviceProfile.classify("", astra2Marketing)) + } + + @Test + fun buildTokensIdentifyAstra2WithoutMarketingName() { + assertEquals(DeviceProfile.ASTRA_2, DeviceProfile.classify(astra2Build, "")) + } + + @Test + fun regionalModelNamesStillMatch() { + val regional = + listOf( + "nubia redmagic NP06J PQ85P01_A PQ85P01-UN qcom", + "nubia redmagic NP06J PQ85P01_B PQ85P01-CN qcom", + "nubia redmagic NP06P PQ85P01_A PQ85P01-EEA qcom", + ) + regional.forEach { assertEquals(DeviceProfile.ASTRA_2, DeviceProfile.classify(it, "")) } + } + + @Test + fun otherRedMagicDevicesAreNotAstra2() { + val others = + listOf( + "nubia redmagic NX769J aurora aurora-global qcom" to "REDMAGIC 9 Pro", + "nubia redmagic NX729J peak peak-global qcom" to "REDMAGIC 8 Pro", + "nubia nubia NX721J muse muse-global qcom" to "REDMAGIC Astra Gaming Tablet", + ) + others.forEach { (build, marketing) -> + assertEquals(DeviceProfile.DEFAULT, DeviceProfile.classify(build, marketing)) + } + } + + @Test + fun unrelatedDevicesFallBackToDefault() { + val others = + listOf( + "oneplus oneplus CPH2649 OP5959L1 CPH2649 qcom" to "OnePlus 13", + "google google Pixel 9 Pro caiman caiman zuma" to "Pixel 9 Pro", + "samsung samsung SM-X910 gts9ultra gts9ultrxx qcom" to "Galaxy Tab S9 Ultra", + "" to "", + ) + others.forEach { (build, marketing) -> + assertEquals(DeviceProfile.DEFAULT, DeviceProfile.classify(build, marketing)) + } + } + + @Test + fun matchingIsCaseInsensitive() { + assertEquals(DeviceProfile.ASTRA_2, DeviceProfile.classify("NUBIA REDMAGIC PQ85P01_A", "")) + assertEquals(DeviceProfile.ASTRA_2, DeviceProfile.classify("", "redmagic ASTRA 2 gaming tablet")) + } + + @Test + fun prefValuesRoundTrip() { + DeviceProfile.entries.forEach { + assertEquals(it, DeviceProfile.fromPrefValue(it.prefValue)) + } + } + + @Test + fun unknownPrefValueFallsBackToDefault() { + assertEquals(DeviceProfile.DEFAULT, DeviceProfile.fromPrefValue(null)) + assertEquals(DeviceProfile.DEFAULT, DeviceProfile.fromPrefValue("")) + assertEquals(DeviceProfile.DEFAULT, DeviceProfile.fromPrefValue("some_retired_profile")) + } + + @Test + fun defaultProfileUsesTheLegacyAssetDirectory() { + assertEquals("", DeviceProfile.DEFAULT.assetToken) + assertEquals("astra2", DeviceProfile.ASTRA_2.assetToken) + } + + @Test + fun astra2PrefersWideArtworkAndDefaultDoesNot() { + assertEquals(true, DeviceProfileSettings.preferWideArtwork(DeviceProfile.ASTRA_2)) + assertEquals(false, DeviceProfileSettings.preferWideArtwork(DeviceProfile.DEFAULT)) + } + + @Test + fun astra2LibraryImagesMatchTheSteamHeaderAndDefaultIsStock() { + assertEquals(460f / 215f, DeviceProfileSettings.libraryImageAspect(DeviceProfile.ASTRA_2)!!, 0.001f) + assertEquals(null, DeviceProfileSettings.libraryImageAspect(DeviceProfile.DEFAULT)) + } + + @Test + fun astra2LibraryColumnsFollowOrientation() { + assertEquals(4, DeviceProfileSettings.libraryColumns(DeviceProfile.ASTRA_2, 1067, portrait = false)) + assertEquals(2, DeviceProfileSettings.libraryColumns(DeviceProfile.ASTRA_2, 668, portrait = true)) + } + + @Test + fun defaultLibraryColumnsKeepTheStockLadder() { + assertEquals(2, DeviceProfileSettings.libraryColumns(DeviceProfile.DEFAULT, 400, portrait = true)) + assertEquals(3, DeviceProfileSettings.libraryColumns(DeviceProfile.DEFAULT, 600, portrait = false)) + assertEquals(4, DeviceProfileSettings.libraryColumns(DeviceProfile.DEFAULT, 914, portrait = false)) + } + + @Test + fun astra2ActionCardsAreSquareOnTheTallDrawer() { + val cardWidth = (300f - 20f - 16f) / 3f + val tall = DeviceProfileSettings.actionCardRowHeight( + availableHeight = 450f, cardWidth = cardWidth, rows = 2, spacing = 8f, minHeight = 72f, + maxAspect = DeviceProfileSettings.sessionActionCardMaxAspect(DeviceProfile.ASTRA_2), + ) + assertEquals(cardWidth, tall, 0.01f) + } + + @Test + fun defaultActionCardsStillFillTheDrawer() { + val cardWidth = (300f - 20f - 16f) / 3f + val fill = DeviceProfileSettings.actionCardRowHeight( + availableHeight = 450f, cardWidth = cardWidth, rows = 2, spacing = 8f, minHeight = 72f, + maxAspect = DeviceProfileSettings.sessionActionCardMaxAspect(DeviceProfile.DEFAULT), + ) + assertEquals((450f - 8f) / 2f, fill, 0.01f) + } + + @Test + fun actionCardsNeverDropBelowTheMinimumHeight() { + val h = DeviceProfileSettings.actionCardRowHeight( + availableHeight = 100f, cardWidth = 40f, rows = 3, spacing = 8f, minHeight = 72f, maxAspect = 1.0f, + ) + assertEquals(72f, h, 0.01f) + } +}