From 15f102d257a6da130febe7df8827dda44ac88216 Mon Sep 17 00:00:00 2001 From: izzy2lost Date: Mon, 23 Mar 2026 21:41:29 -0400 Subject: [PATCH] orientation preferences and settings UI for app and in-game orientations --- android/app/src/main/AndroidManifest.xml | 8 +- android/app/src/main/cpp/xemu_android.cpp | 23 +++++- .../izzy2lost/x1box/GameLibraryActivity.kt | 19 +---- .../java/com/izzy2lost/x1box/MainActivity.kt | 2 +- .../com/izzy2lost/x1box/OrientationLocker.kt | 45 +++-------- .../izzy2lost/x1box/OrientationPreferences.kt | 75 +++++++++++++++++ .../com/izzy2lost/x1box/SettingsActivity.kt | 81 ++++++++++++++++--- .../main/java/org/libsdl/app/SDLSurface.java | 8 +- .../src/main/res/layout/activity_settings.xml | 40 +++++++++ android/app/src/main/res/values/strings.xml | 8 ++ 10 files changed, 240 insertions(+), 69 deletions(-) create mode 100644 android/app/src/main/java/com/izzy2lost/x1box/OrientationPreferences.kt diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 1a1bf3d5b4..2215c48ddc 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -11,7 +11,7 @@ @@ -35,7 +35,7 @@ diff --git a/android/app/src/main/cpp/xemu_android.cpp b/android/app/src/main/cpp/xemu_android.cpp index 10fbc05013..2bfb2149da 100644 --- a/android/app/src/main/cpp/xemu_android.cpp +++ b/android/app/src/main/cpp/xemu_android.cpp @@ -55,6 +55,7 @@ static JNIEnv* GetEnv(); static jobject GetActivity(JNIEnv* env); static bool HasException(JNIEnv* env, const char* context); static std::string GetFilesDirPath(JNIEnv* env, jobject activity); +static std::string GetPrefString(JNIEnv* env, jobject activity, const char* key); static void ConfigureNativeDebugLogging(JNIEnv* env, jobject activity); static void ApplyHrtfDefaultOffMigration(JNIEnv* env, jobject activity); static bool NativeDebugLoggingEnabled(); @@ -341,6 +342,22 @@ static std::string ResolveAndroidAudioDriverHint() { return raw; } +static std::string ResolveAndroidOrientationHint(JNIEnv* env, jobject activity) { + constexpr const char* kDefaultOrientationHint = "LandscapeLeft LandscapeRight"; + if (!env || !activity) { + return kDefaultOrientationHint; + } + + std::string value = ToLowerAscii(GetPrefString(env, activity, "setting_game_orientation")); + if (value == "landscape") { + return "LandscapeLeft"; + } + if (value == "reverse_landscape") { + return "LandscapeRight"; + } + return kDefaultOrientationHint; +} + static JNIEnv* GetEnv() { return static_cast(SDL_AndroidGetJNIEnv()); } @@ -1390,11 +1407,15 @@ extern "C" int SDL_main(int argc, char* argv[]) { ConfigureNativeDebugLogging(GetEnv(), GetActivity(GetEnv())); LogInfo("SDL_main: start"); + JNIEnv* env = GetEnv(); + jobject activity = GetActivity(env); std::string audio_driver_hint = ResolveAndroidAudioDriverHint(); SDL_SetHintWithPriority(SDL_HINT_AUDIODRIVER, audio_driver_hint.c_str(), SDL_HINT_OVERRIDE); LogInfoFmt("SDL_HINT_AUDIODRIVER=%s", audio_driver_hint.c_str()); - SDL_SetHint(SDL_HINT_ORIENTATIONS, "LandscapeLeft LandscapeRight"); + std::string orientation_hint = ResolveAndroidOrientationHint(env, activity); + SDL_SetHint(SDL_HINT_ORIENTATIONS, orientation_hint.c_str()); + LogInfoFmt("SDL_HINT_ORIENTATIONS=%s", orientation_hint.c_str()); SDL_DisableScreenSaver(); if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_GAMECONTROLLER) != 0) { diff --git a/android/app/src/main/java/com/izzy2lost/x1box/GameLibraryActivity.kt b/android/app/src/main/java/com/izzy2lost/x1box/GameLibraryActivity.kt index d40ce049aa..7f87ef1de7 100644 --- a/android/app/src/main/java/com/izzy2lost/x1box/GameLibraryActivity.kt +++ b/android/app/src/main/java/com/izzy2lost/x1box/GameLibraryActivity.kt @@ -43,8 +43,6 @@ import java.util.concurrent.ConcurrentHashMap class GameLibraryActivity : AppCompatActivity() { companion object { const val EXTRA_RESTART_LAST_GAME = "com.izzy2lost.x1box.extra.RESTART_LAST_GAME" - const val EXTRA_INITIAL_ORIENTATION = - "com.izzy2lost.x1box.extra.INITIAL_ORIENTATION" private const val SNAPSHOT_PREVIEW_HEADER_SIZE = 12 private const val TOTAL_SNAPSHOT_SLOTS = 10 private const val XDVDFS_SECTOR_SIZE = 2048L @@ -155,7 +153,6 @@ class GameLibraryActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - applyInitialOrientationFromIntent() OrientationLocker(this).enable() setContentView(R.layout.activity_game_library) EdgeToEdgeHelper.enable(this) @@ -196,11 +193,7 @@ class GameLibraryActivity : AppCompatActivity() { launchDashboard() } btnSettings.setOnClickListener { - startActivity( - Intent(this, SettingsActivity::class.java).apply { - putExtra(SettingsActivity.EXTRA_INITIAL_ORIENTATION, requestedOrientation) - } - ) + startActivity(Intent(this, SettingsActivity::class.java)) } btnSnapshots.setOnClickListener { showSnapshotStartupPicker() @@ -231,13 +224,9 @@ class GameLibraryActivity : AppCompatActivity() { loadGames() } - private fun applyInitialOrientationFromIntent() { - val initialOrientation = intent.getIntExtra(EXTRA_INITIAL_ORIENTATION, Int.MIN_VALUE) - if (initialOrientation == android.content.pm.ActivityInfo.SCREEN_ORIENTATION_PORTRAIT || - initialOrientation == android.content.pm.ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE - ) { - requestedOrientation = initialOrientation - } + override fun onResume() { + super.onResume() + OrientationLocker(this).enable() } private fun tryRestartLastGameFromIntent(): Boolean { diff --git a/android/app/src/main/java/com/izzy2lost/x1box/MainActivity.kt b/android/app/src/main/java/com/izzy2lost/x1box/MainActivity.kt index 120291c1bf..1911dcd7f7 100644 --- a/android/app/src/main/java/com/izzy2lost/x1box/MainActivity.kt +++ b/android/app/src/main/java/com/izzy2lost/x1box/MainActivity.kt @@ -204,6 +204,7 @@ class MainActivity : SDLActivity(), InputManager.InputDeviceListener { override fun onResume() { super.onResume() + OrientationLocker(this, landscapeOnly = true).enable() window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) mLayout?.keepScreenOn = true @@ -805,7 +806,6 @@ class MainActivity : SDLActivity(), InputManager.InputDeviceListener { private fun exitToGameLibrary() { val intent = Intent(this, GameLibraryActivity::class.java).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) - putExtra(GameLibraryActivity.EXTRA_INITIAL_ORIENTATION, requestedOrientation) } startActivity(intent) terminateXemuProcessSoon("exit to library") diff --git a/android/app/src/main/java/com/izzy2lost/x1box/OrientationLocker.kt b/android/app/src/main/java/com/izzy2lost/x1box/OrientationLocker.kt index 343e6f09e3..026b814be5 100644 --- a/android/app/src/main/java/com/izzy2lost/x1box/OrientationLocker.kt +++ b/android/app/src/main/java/com/izzy2lost/x1box/OrientationLocker.kt @@ -1,45 +1,20 @@ package com.izzy2lost.x1box import android.app.Activity -import android.content.pm.ActivityInfo -import android.view.OrientationEventListener -/** - * Prevents reversed (upside-down) orientations. - * - * UI mode: portrait when upright/right-rotated, left landscape otherwise. - * Game mode: always left landscape, no exceptions. - */ class OrientationLocker(private val activity: Activity, private val landscapeOnly: Boolean = false) { - - private val listener = object : OrientationEventListener(activity) { - override fun onOrientationChanged(orientation: Int) { - if (orientation == ORIENTATION_UNKNOWN) return - - val target = if (landscapeOnly) { - // In-game: always left landscape regardless of how the device is held. - ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE - } else { - // UI: portrait when upright or rotating right, landscape otherwise. - // 315-360 / 0-134 → portrait (upright + right rotation) - // 135-314 → landscape (upside-down + left rotation) - if (orientation < 135 || orientation >= 315) - ActivityInfo.SCREEN_ORIENTATION_PORTRAIT - else - ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE - } - - if (activity.requestedOrientation != target) { - activity.requestedOrientation = target - } - } + fun enable() { + val target = if (landscapeOnly) { + OrientationPreferences.getGameRequestedOrientation(activity) + } else { + OrientationPreferences.getUiRequestedOrientation(activity) } - fun enable() { - if (listener.canDetectOrientation()) listener.enable() + if (activity.requestedOrientation != target) { + activity.requestedOrientation = target } + } - fun disable() { - listener.disable() - } + fun disable() { + } } diff --git a/android/app/src/main/java/com/izzy2lost/x1box/OrientationPreferences.kt b/android/app/src/main/java/com/izzy2lost/x1box/OrientationPreferences.kt new file mode 100644 index 0000000000..fe0e8f56c8 --- /dev/null +++ b/android/app/src/main/java/com/izzy2lost/x1box/OrientationPreferences.kt @@ -0,0 +1,75 @@ +package com.izzy2lost.x1box + +import android.content.Context +import android.content.SharedPreferences +import android.content.pm.ActivityInfo + +object OrientationPreferences { + private const val PREFS_NAME = "x1box_prefs" + + const val PREF_UI_ORIENTATION = "setting_ui_orientation" + const val PREF_GAME_ORIENTATION = "setting_game_orientation" + + enum class UiOrientation( + val prefValue: String, + val requestedOrientation: Int, + ) { + FOLLOW_DEVICE("follow_device", ActivityInfo.SCREEN_ORIENTATION_FULL_SENSOR), + PORTRAIT("portrait", ActivityInfo.SCREEN_ORIENTATION_PORTRAIT), + REVERSE_PORTRAIT("reverse_portrait", ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT), + LANDSCAPE("landscape", ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE), + REVERSE_LANDSCAPE("reverse_landscape", ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE), + ; + + companion object { + fun fromPrefValue(value: String?): UiOrientation { + return values().firstOrNull { it.prefValue == value } ?: FOLLOW_DEVICE + } + } + } + + enum class GameOrientation( + val prefValue: String, + val requestedOrientation: Int, + ) { + FOLLOW_DEVICE( + "follow_device", + ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE, + ), + LANDSCAPE( + "landscape", + ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE, + ), + REVERSE_LANDSCAPE( + "reverse_landscape", + ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE, + ), + ; + + companion object { + fun fromPrefValue(value: String?): GameOrientation { + return values().firstOrNull { it.prefValue == value } ?: FOLLOW_DEVICE + } + } + } + + fun getUiOrientation(context: Context): UiOrientation { + return UiOrientation.fromPrefValue(sharedPreferences(context).getString(PREF_UI_ORIENTATION, null)) + } + + fun getGameOrientation(context: Context): GameOrientation { + return GameOrientation.fromPrefValue(sharedPreferences(context).getString(PREF_GAME_ORIENTATION, null)) + } + + fun getUiRequestedOrientation(context: Context): Int { + return getUiOrientation(context).requestedOrientation + } + + fun getGameRequestedOrientation(context: Context): Int { + return getGameOrientation(context).requestedOrientation + } + + private fun sharedPreferences(context: Context): SharedPreferences { + return context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + } +} diff --git a/android/app/src/main/java/com/izzy2lost/x1box/SettingsActivity.kt b/android/app/src/main/java/com/izzy2lost/x1box/SettingsActivity.kt index e392560a76..eda895dbfd 100644 --- a/android/app/src/main/java/com/izzy2lost/x1box/SettingsActivity.kt +++ b/android/app/src/main/java/com/izzy2lost/x1box/SettingsActivity.kt @@ -31,8 +31,6 @@ import java.util.zip.ZipInputStream class SettingsActivity : AppCompatActivity() { companion object { - const val EXTRA_INITIAL_ORIENTATION = - "com.izzy2lost.x1box.extra.INITIAL_ORIENTATION" private const val PREFS_NAME = "x1box_prefs" private const val PREF_ADVANCED_EXPERIMENTAL_EXPANDED = "settings_advanced_experimental_expanded" private const val PREF_HRTF = "setting_hrtf" @@ -69,6 +67,16 @@ class SettingsActivity : AppCompatActivity() { val labelRes: Int, ) + private data class UiOrientationOption( + val value: OrientationPreferences.UiOrientation, + val labelRes: Int, + ) + + private data class GameOrientationOption( + val value: OrientationPreferences.GameOrientation, + val labelRes: Int, + ) + private data class CacheClearResult( val deletedEntries: Int, val hadFailures: Boolean, @@ -136,6 +144,20 @@ class SettingsActivity : AppCompatActivity() { EepromRefreshRateOption(XboxEepromEditor.RefreshRate.HZ_50, R.string.settings_eeprom_refresh_rate_50), ) + private val uiOrientationOptions = listOf( + UiOrientationOption(OrientationPreferences.UiOrientation.FOLLOW_DEVICE, R.string.settings_orientation_follow_device), + UiOrientationOption(OrientationPreferences.UiOrientation.PORTRAIT, R.string.settings_orientation_portrait), + UiOrientationOption(OrientationPreferences.UiOrientation.REVERSE_PORTRAIT, R.string.settings_orientation_reverse_portrait), + UiOrientationOption(OrientationPreferences.UiOrientation.LANDSCAPE, R.string.settings_orientation_landscape), + UiOrientationOption(OrientationPreferences.UiOrientation.REVERSE_LANDSCAPE, R.string.settings_orientation_reverse_landscape), + ) + + private val gameOrientationOptions = listOf( + GameOrientationOption(OrientationPreferences.GameOrientation.FOLLOW_DEVICE, R.string.settings_orientation_follow_device), + GameOrientationOption(OrientationPreferences.GameOrientation.LANDSCAPE, R.string.settings_orientation_landscape), + GameOrientationOption(OrientationPreferences.GameOrientation.REVERSE_LANDSCAPE, R.string.settings_orientation_reverse_landscape), + ) + private var pendingVulkanUri: String? = null private var pendingVulkanName: String? = null private var clearVulkan = false @@ -156,6 +178,8 @@ class SettingsActivity : AppCompatActivity() { private lateinit var btnRegisterInsignia: MaterialButton private lateinit var btnImportDashboard: MaterialButton private lateinit var layoutAdvancedExperimentalContent: LinearLayout + private lateinit var dropdownUiOrientation: AutoCompleteTextView + private lateinit var dropdownGameOrientation: AutoCompleteTextView private lateinit var inputEepromLanguage: TextInputLayout private lateinit var inputEepromVideoStandard: TextInputLayout private lateinit var inputEepromAspectRatio: TextInputLayout @@ -172,6 +196,8 @@ class SettingsActivity : AppCompatActivity() { private var selectedEepromVideoStandard = XboxEepromEditor.VideoStandard.NTSC_M private var selectedEepromAspectRatio = XboxEepromEditor.AspectRatio.NORMAL private var selectedEepromRefreshRate = XboxEepromEditor.RefreshRate.DEFAULT + private var selectedUiOrientation = OrientationPreferences.UiOrientation.FOLLOW_DEVICE + private var selectedGameOrientation = OrientationPreferences.GameOrientation.FOLLOW_DEVICE private var eepromEditable = false private var eepromMissing = false private var eepromError = false @@ -234,7 +260,6 @@ class SettingsActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - applyInitialOrientationFromIntent() OrientationLocker(this).enable() DebugLog.initialize(this) applyHrtfDefaultOffMigration() @@ -276,6 +301,8 @@ class SettingsActivity : AppCompatActivity() { btnRegisterInsignia = findViewById(R.id.btn_register_insignia) btnImportDashboard = findViewById(R.id.btn_import_dashboard) layoutAdvancedExperimentalContent = findViewById(R.id.layout_advanced_experimental_content) + dropdownUiOrientation = findViewById(R.id.dropdown_app_orientation) + dropdownGameOrientation = findViewById(R.id.dropdown_game_orientation) tvVulkanDriverName = findViewById(R.id.tv_vulkan_driver_name) val btnVulkanBrowse = findViewById(R.id.btn_vulkan_browse) val btnVulkanClear = findViewById(R.id.btn_vulkan_clear) @@ -322,6 +349,10 @@ class SettingsActivity : AppCompatActivity() { else -> toggleDisplayMode.check(R.id.btn_display_stretch) } + setupOrientationControls() + setUiOrientationSelection(OrientationPreferences.getUiOrientation(this)) + setGameOrientationSelection(OrientationPreferences.getGameOrientation(this)) + if (prefs.getInt("setting_frame_rate_limit", 60) != 60) { prefs.edit().putInt("setting_frame_rate_limit", 60).apply() } @@ -458,6 +489,8 @@ class SettingsActivity : AppCompatActivity() { .putInt("setting_surface_scale", selectedScale) .putInt("setting_frame_rate_limit", 60) .putInt("setting_system_memory_mib", selectedSystemMemoryMiB) + .putString(OrientationPreferences.PREF_UI_ORIENTATION, selectedUiOrientation.prefValue) + .putString(OrientationPreferences.PREF_GAME_ORIENTATION, selectedGameOrientation.prefValue) .putString("setting_tcg_thread", selectedThread) .putBoolean("setting_use_dsp", switchDsp.isChecked) .putBoolean(PREF_HRTF, switchHrtf.isChecked) @@ -544,15 +577,6 @@ class SettingsActivity : AppCompatActivity() { .apply() } - private fun applyInitialOrientationFromIntent() { - val initialOrientation = intent.getIntExtra(EXTRA_INITIAL_ORIENTATION, Int.MIN_VALUE) - if (initialOrientation == android.content.pm.ActivityInfo.SCREEN_ORIENTATION_PORTRAIT || - initialOrientation == android.content.pm.ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE - ) { - requestedOrientation = initialOrientation - } - } - private fun importCustomVulkanDriver( uri: Uri, selectedName: String?, @@ -1066,6 +1090,39 @@ class SettingsActivity : AppCompatActivity() { } } + private fun setupOrientationControls() { + val uiOrientationLabels = uiOrientationOptions.map { getString(it.labelRes) } + val gameOrientationLabels = gameOrientationOptions.map { getString(it.labelRes) } + + dropdownUiOrientation.setAdapter( + ArrayAdapter(this, android.R.layout.simple_list_item_1, uiOrientationLabels) + ) + dropdownGameOrientation.setAdapter( + ArrayAdapter(this, android.R.layout.simple_list_item_1, gameOrientationLabels) + ) + + dropdownUiOrientation.setOnItemClickListener { _, _, position, _ -> + selectedUiOrientation = uiOrientationOptions[position].value + } + dropdownGameOrientation.setOnItemClickListener { _, _, position, _ -> + selectedGameOrientation = gameOrientationOptions[position].value + } + } + + private fun setUiOrientationSelection(orientation: OrientationPreferences.UiOrientation) { + selectedUiOrientation = orientation + val option = uiOrientationOptions.firstOrNull { it.value == orientation } + ?: uiOrientationOptions.first() + dropdownUiOrientation.setText(getString(option.labelRes), false) + } + + private fun setGameOrientationSelection(orientation: OrientationPreferences.GameOrientation) { + selectedGameOrientation = orientation + val option = gameOrientationOptions.firstOrNull { it.value == orientation } + ?: gameOrientationOptions.first() + dropdownGameOrientation.setText(getString(option.labelRes), false) + } + private fun applyEepromEdits(): Pair { if (eepromMissing) { return Pair(R.string.settings_saved_eeprom_missing, Toast.LENGTH_LONG) diff --git a/android/app/src/main/java/org/libsdl/app/SDLSurface.java b/android/app/src/main/java/org/libsdl/app/SDLSurface.java index 0857e4b6f3..d63c653e82 100644 --- a/android/app/src/main/java/org/libsdl/app/SDLSurface.java +++ b/android/app/src/main/java/org/libsdl/app/SDLSurface.java @@ -140,11 +140,15 @@ public class SDLSurface extends SurfaceView implements SurfaceHolder.Callback, boolean skip = false; int requestedOrientation = SDLActivity.mSingleton.getRequestedOrientation(); - if (requestedOrientation == ActivityInfo.SCREEN_ORIENTATION_PORTRAIT || requestedOrientation == ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT) { + if (requestedOrientation == ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + || requestedOrientation == ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT + || requestedOrientation == ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT) { if (mWidth > mHeight) { skip = true; } - } else if (requestedOrientation == ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE || requestedOrientation == ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE) { + } else if (requestedOrientation == ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE + || requestedOrientation == ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE + || requestedOrientation == ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE) { if (mWidth < mHeight) { skip = true; } diff --git a/android/app/src/main/res/layout/activity_settings.xml b/android/app/src/main/res/layout/activity_settings.xml index f6cb601444..a972ae0f1d 100644 --- a/android/app/src/main/res/layout/activity_settings.xml +++ b/android/app/src/main/res/layout/activity_settings.xml @@ -218,6 +218,46 @@ + + + + + + + + + + + + + + Stretch 4:3 16:9 + App Orientation + In-Game Orientation + In-game rotation only uses the two landscape directions, so you can keep the charging port on the side that works best for you. + Follow Device + Portrait + Portrait (Upside Down) + Landscape + Landscape (Flipped) System Memory 64 MiB (Default) 128 MiB (Debug/Homebrew)