android: Add display profiles for automatic 3D settings on external displays

Add a feature that allows users to configure display profiles that
automatically apply 3D/stereo settings when specific external displays
are connected via USB-C (e.g., Xreal glasses).

Features:
- Create and manage display profiles in Settings > Layout
- Match displays by name pattern (substring or regex)
- Configure stereo mode, 3D depth, swap eyes, screen layout per profile
- Automatic settings snapshot/restore when display connects/disconnects
- Support for render_3d_which_display setting to control 3D output
- DP displays detected separately for profile matching while preserving
  secondary display functionality for other external screens

Technical details:
- DisplayProfile: Data class for profile configuration
- DisplayProfileManager: Handles profile persistence and settings application
- SecondaryDisplay: Detects DP displays and triggers profile changes
- Settings saved to config.ini via SettingsFile for native library access
- Uses NativeLibrary.reloadSettings() and updateFramebuffer() for runtime changes
This commit is contained in:
Arnaud 2026-04-14 06:38:37 -10:00
parent 1edc5de18e
commit 7d632189ae
8 changed files with 932 additions and 17 deletions

View file

@ -0,0 +1,90 @@
// Copyright Citra Emulator Project / Azahar Emulator Project
// Licensed under GPLv2 or any later version
// Refer to the license.txt file included.
package org.citra.citra_emu.display
import kotlinx.serialization.Serializable
/**
* Represents a display profile that can be automatically applied when a specific
* external display is connected (e.g., Xreal glasses, external monitor).
*/
@Serializable
data class DisplayProfile(
/** User-friendly name for this profile */
val profileName: String,
/** Pattern to match against the display name (substring match by default) */
val matchPattern: String,
/** If true, matchPattern is treated as a regex pattern */
val useRegex: Boolean = false,
/** Stereo rendering mode (matches StereoMode enum values) */
val stereoMode: Int = StereoMode.OFF.int,
/** 3D depth factor (0-255) */
val factor3d: Int = 0,
/** Whether to swap left/right eyes */
val swapEyes: Boolean = false,
/** Screen layout option (matches ScreenLayout enum values) */
val layoutOption: Int = ScreenLayout.ORIGINAL.int,
/** Which display to render 3D to (matches StereoWhichDisplay enum values) */
val stereoWhichDisplay: Int = StereoWhichDisplay.PRIMARY_ONLY.int,
/** Whether this profile is enabled */
val enabled: Boolean = true
) {
/**
* Check if this profile matches the given display name.
*/
fun matchesDisplay(displayName: String): Boolean {
if (!enabled) return false
return if (useRegex) {
try {
Regex(matchPattern, RegexOption.IGNORE_CASE).containsMatchIn(displayName)
} catch (e: Exception) {
// Invalid regex, fall back to substring match
displayName.contains(matchPattern, ignoreCase = true)
}
} else {
displayName.contains(matchPattern, ignoreCase = true)
}
}
companion object {
/**
* Create a default profile for Xreal glasses
*/
fun createXrealDefault(): DisplayProfile {
return DisplayProfile(
profileName = "Xreal Glasses",
matchPattern = "XREAL",
useRegex = false,
stereoMode = StereoMode.SIDE_BY_SIDE.int,
factor3d = 100,
swapEyes = false,
layoutOption = ScreenLayout.ORIGINAL.int,
enabled = true
)
}
}
}
/**
* Snapshot of current display settings that can be restored when
* the external display is disconnected.
*/
@Serializable
data class SettingsSnapshot(
val stereoMode: Int,
val factor3d: Int,
val swapEyes: Boolean,
val layoutOption: Int,
val stereoWhichDisplay: Int = StereoWhichDisplay.NONE.int
)

View file

@ -0,0 +1,287 @@
// Copyright Citra Emulator Project / Azahar Emulator Project
// Licensed under GPLv2 or any later version
// Refer to the license.txt file included.
package org.citra.citra_emu.display
import android.content.Context
import android.content.SharedPreferences
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.citra.citra_emu.CitraApplication
import org.citra.citra_emu.NativeLibrary
import org.citra.citra_emu.features.settings.model.BooleanSetting
import org.citra.citra_emu.features.settings.model.IntSetting
import org.citra.citra_emu.features.settings.utils.SettingsFile
import org.citra.citra_emu.utils.Log
import java.util.concurrent.CopyOnWriteArrayList
/**
* Manages display profiles for automatic configuration when external displays
* are connected (e.g., Xreal glasses, external monitors).
*/
object DisplayProfileManager {
private const val PREFS_NAME = "display_profiles"
private const val KEY_PROFILES = "profiles"
private const val KEY_ENABLED = "enabled"
private const val KEY_SNAPSHOT = "settings_snapshot"
private const val KEY_ACTIVE_PROFILE = "active_profile"
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
private val preferences: SharedPreferences by lazy {
CitraApplication.appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
}
private val profiles: CopyOnWriteArrayList<DisplayProfile> = CopyOnWriteArrayList()
@Volatile private var settingsSnapshot: SettingsSnapshot? = null
@Volatile private var activeProfileName: String? = null
/**
* Initialize the manager by loading saved profiles
*/
fun initialize() {
loadProfiles()
loadSnapshot()
activeProfileName = preferences.getString(KEY_ACTIVE_PROFILE, null)
}
/**
* Check if per-display profiles feature is enabled
*/
var isEnabled: Boolean
get() = preferences.getBoolean(KEY_ENABLED, true)
set(value) {
preferences.edit().putBoolean(KEY_ENABLED, value).apply()
}
/**
* Get all configured profiles
*/
fun getProfiles(): List<DisplayProfile> = profiles.toList()
/**
* Add a new profile
*/
fun addProfile(profile: DisplayProfile) {
profiles.add(profile)
saveProfiles()
}
/**
* Update an existing profile
*/
fun updateProfile(oldName: String, newProfile: DisplayProfile) {
val index = profiles.indexOfFirst { it.profileName == oldName }
if (index != -1) {
profiles[index] = newProfile
saveProfiles()
}
}
/**
* Remove a profile by name
*/
fun removeProfile(profileName: String) {
profiles.removeAll { it.profileName == profileName }
saveProfiles()
}
/**
* Find a matching profile for the given display name
*/
fun findMatchingProfile(displayName: String): DisplayProfile? {
if (!isEnabled) return null
return profiles.firstOrNull { it.matchesDisplay(displayName) }
}
/**
* Get the currently active profile name (if any)
*/
fun getActiveProfileName(): String? = activeProfileName
/**
* Check if a profile is currently active
*/
fun hasActiveProfile(): Boolean = activeProfileName != null
/**
* Save the current settings as a snapshot before applying a profile
*/
fun saveSettingsSnapshot() {
settingsSnapshot = SettingsSnapshot(
stereoMode = IntSetting.STEREOSCOPIC_3D_MODE.int,
factor3d = IntSetting.STEREOSCOPIC_3D_DEPTH.int,
swapEyes = BooleanSetting.SWAP_EYES_3D.boolean,
layoutOption = IntSetting.SCREEN_LAYOUT.int,
stereoWhichDisplay = IntSetting.RENDER_3D_WHICH_DISPLAY.int
)
saveSnapshot()
Log.info("[DisplayProfileManager] Settings snapshot saved")
}
/**
* Apply a display profile's settings
*/
fun applyProfile(profile: DisplayProfile) {
Log.info("[DisplayProfileManager] Applying profile: ${profile.profileName}")
// Update in-memory values
IntSetting.STEREOSCOPIC_3D_MODE.int = profile.stereoMode
IntSetting.STEREOSCOPIC_3D_DEPTH.int = profile.factor3d
BooleanSetting.SWAP_EYES_3D.boolean = profile.swapEyes
IntSetting.SCREEN_LAYOUT.int = profile.layoutOption
IntSetting.RENDER_3D_WHICH_DISPLAY.int = profile.stereoWhichDisplay
// Save to config file so native library can read them
try {
SettingsFile.saveFile(SettingsFile.FILE_NAME_CONFIG, IntSetting.STEREOSCOPIC_3D_MODE)
SettingsFile.saveFile(SettingsFile.FILE_NAME_CONFIG, IntSetting.STEREOSCOPIC_3D_DEPTH)
SettingsFile.saveFile(SettingsFile.FILE_NAME_CONFIG, BooleanSetting.SWAP_EYES_3D)
SettingsFile.saveFile(SettingsFile.FILE_NAME_CONFIG, IntSetting.SCREEN_LAYOUT)
SettingsFile.saveFile(SettingsFile.FILE_NAME_CONFIG, IntSetting.RENDER_3D_WHICH_DISPLAY)
} catch (e: Exception) {
Log.error("[DisplayProfileManager] Failed to save settings: ${e.message}")
}
activeProfileName = profile.profileName
preferences.edit().putString(KEY_ACTIVE_PROFILE, profile.profileName).apply()
// Notify the native side to reload settings and update framebuffer
if (NativeLibrary.isRunning()) {
NativeLibrary.reloadSettings()
NativeLibrary.updateFramebuffer(NativeLibrary.isPortraitMode)
}
}
/**
* Restore settings from the saved snapshot
*/
fun restoreSettingsSnapshot() {
val snapshot = settingsSnapshot
if (snapshot == null) {
Log.warning("[DisplayProfileManager] No settings snapshot to restore")
return
}
Log.info("[DisplayProfileManager] Restoring settings from snapshot")
IntSetting.STEREOSCOPIC_3D_MODE.int = snapshot.stereoMode
IntSetting.STEREOSCOPIC_3D_DEPTH.int = snapshot.factor3d
BooleanSetting.SWAP_EYES_3D.boolean = snapshot.swapEyes
IntSetting.SCREEN_LAYOUT.int = snapshot.layoutOption
IntSetting.RENDER_3D_WHICH_DISPLAY.int = snapshot.stereoWhichDisplay
// Save to config file so native library can read them
try {
SettingsFile.saveFile(SettingsFile.FILE_NAME_CONFIG, IntSetting.STEREOSCOPIC_3D_MODE)
SettingsFile.saveFile(SettingsFile.FILE_NAME_CONFIG, IntSetting.STEREOSCOPIC_3D_DEPTH)
SettingsFile.saveFile(SettingsFile.FILE_NAME_CONFIG, BooleanSetting.SWAP_EYES_3D)
SettingsFile.saveFile(SettingsFile.FILE_NAME_CONFIG, IntSetting.SCREEN_LAYOUT)
SettingsFile.saveFile(SettingsFile.FILE_NAME_CONFIG, IntSetting.RENDER_3D_WHICH_DISPLAY)
} catch (e: Exception) {
Log.error("[DisplayProfileManager] Failed to save snapshot settings: ${e.message}")
}
activeProfileName = null
preferences.edit().remove(KEY_ACTIVE_PROFILE).apply()
// Clear the snapshot after restoring
settingsSnapshot = null
preferences.edit().remove(KEY_SNAPSHOT).apply()
// Notify the native side to reload settings and update framebuffer
if (NativeLibrary.isRunning()) {
NativeLibrary.reloadSettings()
NativeLibrary.updateFramebuffer(NativeLibrary.isPortraitMode)
}
}
/**
* Handle display connected event
*/
fun onDisplayConnected(displayName: String) {
if (!isEnabled) {
Log.debug("[DisplayProfileManager] Feature disabled, skipping display: $displayName")
return
}
Log.info("[DisplayProfileManager] Display connected: $displayName")
val profile = findMatchingProfile(displayName)
if (profile != null) {
Log.info("[DisplayProfileManager] Found matching profile: ${profile.profileName}")
// Save current settings before applying profile
if (!hasActiveProfile()) {
saveSettingsSnapshot()
}
applyProfile(profile)
} else {
Log.debug("[DisplayProfileManager] No matching profile for display: $displayName")
}
}
/**
* Handle display disconnected event
*/
fun onDisplayDisconnected() {
if (!isEnabled) return
if (hasActiveProfile()) {
Log.info("[DisplayProfileManager] External display disconnected, restoring settings")
restoreSettingsSnapshot()
}
}
private fun loadProfiles() {
val jsonString = preferences.getString(KEY_PROFILES, null)
profiles.clear()
if (jsonString != null) {
try {
profiles.addAll(json.decodeFromString<List<DisplayProfile>>(jsonString))
} catch (e: Exception) {
Log.error("[DisplayProfileManager] Failed to load profiles: ${e.message}")
}
}
}
private fun saveProfiles() {
try {
val jsonString = json.encodeToString(profiles.toList())
preferences.edit().putString(KEY_PROFILES, jsonString).apply()
} catch (e: Exception) {
Log.error("[DisplayProfileManager] Failed to save profiles: ${e.message}")
}
}
private fun loadSnapshot() {
val jsonString = preferences.getString(KEY_SNAPSHOT, null)
settingsSnapshot = if (jsonString != null) {
try {
json.decodeFromString<SettingsSnapshot>(jsonString)
} catch (e: Exception) {
Log.error("[DisplayProfileManager] Failed to load snapshot: ${e.message}")
null
}
} else {
null
}
}
private fun saveSnapshot() {
val snapshot = settingsSnapshot
if (snapshot != null) {
try {
val jsonString = json.encodeToString(snapshot)
preferences.edit().putString(KEY_SNAPSHOT, jsonString).apply()
} catch (e: Exception) {
Log.error("[DisplayProfileManager] Failed to save snapshot: ${e.message}")
}
}
}
}

View file

@ -8,7 +8,10 @@ import android.app.Presentation
import android.content.Context
import android.hardware.display.DisplayManager
import android.hardware.display.VirtualDisplay
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.Display
import android.view.MotionEvent
import android.view.SurfaceHolder
@ -21,6 +24,14 @@ class SecondaryDisplay(val context: Context) : DisplayManager.DisplayListener {
private var pres: SecondaryDisplayPresentation? = null
private val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
private val vd: VirtualDisplay
// Track DP displays separately for profile matching (e.g., XREAL via USB-C)
@Volatile private var lastDPDisplayName: String? = null
// Handler for debouncing display profile changes
private val handler = Handler(Looper.getMainLooper())
private var pendingProfileChange: Runnable? = null
private companion object {
const val PROFILE_CHANGE_DEBOUNCE_MS = 300L
}
init {
vd = displayManager.createVirtualDisplay(
@ -32,10 +43,22 @@ class SecondaryDisplay(val context: Context) : DisplayManager.DisplayListener {
DisplayManager.VIRTUAL_DISPLAY_FLAG_PRESENTATION
)
displayManager.registerDisplayListener(this, null)
// Initialize the display profile manager
DisplayProfileManager.initialize()
// Check for already connected DP display at init
val currentDPDisplay = getDPDisplayForProfileMatching()
if (currentDPDisplay != null) {
lastDPDisplayName = null // Force the "connected" path
handleDisplayProfileChange(currentDPDisplay)
}
}
fun updateSurface() {
NativeLibrary.secondarySurfaceChanged(pres!!.getSurfaceHolder().surface)
pres?.let {
NativeLibrary.secondarySurfaceChanged(it.getSurfaceHolder().surface)
}
}
fun destroySurface() {
@ -44,22 +67,57 @@ class SecondaryDisplay(val context: Context) : DisplayManager.DisplayListener {
private fun getExternalDisplay(context: Context): Display? {
val dm = context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
val currentDisplayId = context.display.displayId
val currentDisplayId = context.display?.displayId ?: Display.DEFAULT_DISPLAY
val displays = dm.displays
val presDisplays = dm.getDisplays(DisplayManager.DISPLAY_CATEGORY_PRESENTATION);
val presDisplays = dm.getDisplays(DisplayManager.DISPLAY_CATEGORY_PRESENTATION)
val extDisplays = displays.filter {
val isPresentable = presDisplays.any { pd -> pd.displayId == it.displayId }
val isNotDefaultOrPresentable = it.displayId != Display.DEFAULT_DISPLAY || isPresentable
isNotDefaultOrPresentable &&
it.displayId != currentDisplayId &&
it.name != "HiddenDisplay" &&
it.state != Display.STATE_OFF &&
it.isValid
val isExternal = it.displayId != Display.DEFAULT_DISPLAY && it.displayId != currentDisplayId
val isUsable = it.name != "HiddenDisplay" && it.state != Display.STATE_OFF
// EXCLUDE DP/USB-C displays - those are for mirroring the main screen, not for SecondaryDisplay
val isDPDisplay = it.name.contains("DP", true)
(isPresentable || isExternal) && isUsable && !isDPDisplay
}
// if there is a display called Built-In Display or Built-In Screen, prioritize the OTHER screen
val selected = extDisplays.firstOrNull { ! it.name.contains("Built",true) }
// Select first non-Built-in display for SecondaryDisplay
return extDisplays.firstOrNull { !it.name.contains("Built", true) }
?: extDisplays.firstOrNull()
return selected
}
/**
* Get the product name of a display for profile matching.
* Uses deviceProductInfo.name (e.g., "XREAL One") when available (API 31+),
* falls back to display.name (e.g., "DP Screen") on older APIs.
*/
private fun getDisplayProductName(display: Display): String {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val productInfo = display.deviceProductInfo
val productName = productInfo?.name
if (!productName.isNullOrBlank()) {
return productName
}
}
return display.name
}
/**
* Get the DP/USB-C display for profile matching (separate from SecondaryDisplay selection).
* Returns the product name of any connected DP display for automatic profile application.
*/
private fun getDPDisplayForProfileMatching(): String? {
val dm = context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
val displays = dm.displays
for (display in displays) {
if (display.name.contains("DP", true) &&
display.state != Display.STATE_OFF &&
display.name != "HiddenDisplay") {
return getDisplayProductName(display)
}
}
return null
}
fun updateDisplay() {
@ -68,8 +126,13 @@ class SecondaryDisplay(val context: Context) : DisplayManager.DisplayListener {
return
}
// Check for DP display for profile matching (tracked separately from SecondaryDisplay)
val dpDisplayName = getDPDisplayForProfileMatching()
handleDisplayProfileChange(dpDisplayName)
// decide if we are going to the external display or the internal one
var display = getExternalDisplay(context)
if (display == null ||
IntSetting.SECONDARY_DISPLAY_LAYOUT.int == SecondaryDisplayLayout.NONE.int) {
display = vd.display
@ -95,6 +158,54 @@ class SecondaryDisplay(val context: Context) : DisplayManager.DisplayListener {
}
}
/**
* Handle display profile changes when DP display connects/disconnects.
* Only tracks DP displays (like XREAL via USB-C), not regular external displays.
* Uses debouncing to avoid race conditions from rapid connect/disconnect events.
*/
private fun handleDisplayProfileChange(currentDPDisplayName: String?) {
val previousDPDisplayName = lastDPDisplayName
// Skip if no change
if (previousDPDisplayName == currentDPDisplayName) return
// Cancel any pending profile change
pendingProfileChange?.let { handler.removeCallbacks(it) }
// Debounce the profile change to avoid race conditions
pendingProfileChange = Runnable {
val latestDPDisplayName = getDPDisplayForProfileMatching()
val storedPreviousName = lastDPDisplayName
lastDPDisplayName = latestDPDisplayName
when {
// DP display connected (was null, now has a name)
storedPreviousName == null && latestDPDisplayName != null -> {
DisplayProfileManager.onDisplayConnected(latestDPDisplayName)
}
// DP display disconnected (had a name, now null)
storedPreviousName != null && latestDPDisplayName == null -> {
DisplayProfileManager.onDisplayDisconnected()
}
// DP display changed to a different one
storedPreviousName != null && latestDPDisplayName != null &&
storedPreviousName != latestDPDisplayName -> {
DisplayProfileManager.onDisplayDisconnected()
DisplayProfileManager.onDisplayConnected(latestDPDisplayName)
}
}
pendingProfileChange = null
}
handler.postDelayed(pendingProfileChange!!, PROFILE_CHANGE_DEBOUNCE_MS)
}
/**
* Get the name of the currently connected DP display (if any) for profile matching.
*/
fun getConnectedDisplayName(): String? {
return getDPDisplayForProfileMatching()
}
fun releasePresentation() {
try {
pres?.dismiss()

View file

@ -45,6 +45,7 @@ import org.citra.citra_emu.features.settings.model.view.StringSingleChoiceSettin
import org.citra.citra_emu.features.settings.model.view.SubmenuSetting
import org.citra.citra_emu.features.settings.model.view.SwitchSetting
import org.citra.citra_emu.features.settings.utils.SettingsFile
import org.citra.citra_emu.fragments.DisplayProfilesDialogFragment
import org.citra.citra_emu.fragments.ResetSettingsDialogFragment
import org.citra.citra_emu.utils.BirthdayMonth
import org.citra.citra_emu.utils.Log
@ -1203,6 +1204,20 @@ class SettingsFragmentPresenter(private val fragmentView: SettingsFragmentView)
IntSetting.SECONDARY_DISPLAY_LAYOUT.defaultValue
)
)
add(
RunnableSetting(
R.string.display_profiles,
R.string.display_profiles_description,
false,
R.drawable.ic_fit_screen,
{
DisplayProfilesDialogFragment().show(
settingsActivity.supportFragmentManager,
DisplayProfilesDialogFragment.TAG
)
}
)
)
add(
SingleChoiceSetting(
IntSetting.ASPECT_RATIO,

View file

@ -0,0 +1,211 @@
// Copyright Citra Emulator Project / Azahar Emulator Project
// Licensed under GPLv2 or any later version
// Refer to the license.txt file included.
package org.citra.citra_emu.fragments
import android.app.Dialog
import android.os.Bundle
import android.view.LayoutInflater
import android.widget.ArrayAdapter
import android.widget.EditText
import android.widget.Spinner
import android.widget.Toast
import androidx.appcompat.widget.SwitchCompat
import androidx.fragment.app.DialogFragment
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.slider.Slider
import org.citra.citra_emu.R
import org.citra.citra_emu.display.DisplayProfile
import org.citra.citra_emu.display.DisplayProfileManager
import org.citra.citra_emu.display.ScreenLayout
import org.citra.citra_emu.display.StereoMode
/**
* Dialog fragment for managing display profiles that automatically apply
* 3D settings when specific external displays are connected.
*/
class DisplayProfilesDialogFragment : DialogFragment() {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val profiles = DisplayProfileManager.getProfiles()
if (profiles.isEmpty()) {
return showEmptyStateDialog()
}
return showProfileListDialog(profiles)
}
private fun showEmptyStateDialog(): Dialog {
return MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.display_profiles)
.setMessage(R.string.display_profiles_empty)
.setPositiveButton(R.string.display_profiles_add) { _, _ ->
showAddEditProfileDialog(null)
}
.setNegativeButton(android.R.string.cancel, null)
.create()
}
private fun showProfileListDialog(profiles: List<DisplayProfile>): Dialog {
val profileNames = profiles.map { profile ->
val status = if (profile.enabled) "" else " (disabled)"
"${profile.profileName}$status - ${profile.matchPattern}"
}.toTypedArray()
return MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.display_profiles)
.setItems(profileNames) { _, which ->
showProfileOptionsDialog(profiles[which])
}
.setPositiveButton(R.string.display_profiles_add) { _, _ ->
showAddEditProfileDialog(null)
}
.setNegativeButton(android.R.string.cancel, null)
.create()
}
private fun showProfileOptionsDialog(profile: DisplayProfile) {
val ctx = requireContext()
val fragmentMgr = parentFragmentManager
val options = arrayOf(
getString(R.string.display_profiles_edit),
getString(R.string.display_profile_test),
getString(R.string.display_profiles_delete)
)
MaterialAlertDialogBuilder(ctx)
.setTitle(profile.profileName)
.setItems(options) { _, which ->
when (which) {
0 -> showAddEditProfileDialog(profile, ctx, fragmentMgr)
1 -> testProfile(profile, ctx)
2 -> confirmDeleteProfile(profile, ctx, fragmentMgr)
}
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}
private fun showAddEditProfileDialog(
existingProfile: DisplayProfile?,
ctx: android.content.Context = requireContext(),
fragmentMgr: androidx.fragment.app.FragmentManager = parentFragmentManager
) {
val isEdit = existingProfile != null
val inflater = LayoutInflater.from(ctx)
val view = inflater.inflate(R.layout.dialog_display_profile, null)
val nameEdit = view.findViewById<EditText>(R.id.edit_profile_name)
val patternEdit = view.findViewById<EditText>(R.id.edit_match_pattern)
val regexSwitch = view.findViewById<SwitchCompat>(R.id.switch_use_regex)
val stereoSpinner = view.findViewById<Spinner>(R.id.spinner_stereo_mode)
val depthSlider = view.findViewById<Slider>(R.id.slider_depth)
val swapEyesSwitch = view.findViewById<SwitchCompat>(R.id.switch_swap_eyes)
val layoutSpinner = view.findViewById<Spinner>(R.id.spinner_layout)
val enabledSwitch = view.findViewById<SwitchCompat>(R.id.switch_enabled)
// Setup spinners
val stereoModes = arrayOf("Off", "Side by Side", "Side by Side (Full)", "Anaglyph", "Interlaced", "Reverse Interlaced", "Cardboard VR")
stereoSpinner.adapter = ArrayAdapter(ctx, android.R.layout.simple_spinner_dropdown_item, stereoModes)
val layouts = arrayOf("Original", "Single Screen", "Large Screen", "Side by Side", "Hybrid", "Custom")
layoutSpinner.adapter = ArrayAdapter(ctx, android.R.layout.simple_spinner_dropdown_item, layouts)
// Populate with existing values if editing
if (existingProfile != null) {
nameEdit.setText(existingProfile.profileName)
patternEdit.setText(existingProfile.matchPattern)
regexSwitch.isChecked = existingProfile.useRegex
stereoSpinner.setSelection(existingProfile.stereoMode)
depthSlider.value = existingProfile.factor3d.coerceIn(0, 255).toFloat()
swapEyesSwitch.isChecked = existingProfile.swapEyes
layoutSpinner.setSelection(existingProfile.layoutOption.coerceIn(0, 5))
enabledSwitch.isChecked = existingProfile.enabled
} else {
// Defaults for new profile
stereoSpinner.setSelection(StereoMode.SIDE_BY_SIDE.int)
depthSlider.value = 100f
enabledSwitch.isChecked = true
}
MaterialAlertDialogBuilder(ctx)
.setTitle(if (isEdit) R.string.display_profiles_edit else R.string.display_profiles_add)
.setView(view)
.setPositiveButton(R.string.display_profile_save) { _, _ ->
val name = nameEdit.text.toString().trim()
val pattern = patternEdit.text.toString().trim()
if (name.isEmpty()) {
Toast.makeText(ctx, R.string.display_profile_name_empty, Toast.LENGTH_SHORT).show()
return@setPositiveButton
}
if (pattern.isEmpty()) {
Toast.makeText(ctx, R.string.display_profile_pattern_empty, Toast.LENGTH_SHORT).show()
return@setPositiveButton
}
val newProfile = DisplayProfile(
profileName = name,
matchPattern = pattern,
useRegex = regexSwitch.isChecked,
stereoMode = stereoSpinner.selectedItemPosition,
factor3d = depthSlider.value.toInt(),
swapEyes = swapEyesSwitch.isChecked,
layoutOption = layoutSpinner.selectedItemPosition,
enabled = enabledSwitch.isChecked
)
if (isEdit) {
DisplayProfileManager.updateProfile(existingProfile!!.profileName, newProfile)
} else {
DisplayProfileManager.addProfile(newProfile)
}
// Refresh the dialog
dismiss()
DisplayProfilesDialogFragment().show(fragmentMgr, TAG)
}
.setNegativeButton(R.string.display_profile_cancel, null)
.show()
}
private fun testProfile(profile: DisplayProfile, ctx: android.content.Context = requireContext()) {
DisplayProfileManager.saveSettingsSnapshot()
DisplayProfileManager.applyProfile(profile)
Toast.makeText(ctx, R.string.display_profile_test_applied, Toast.LENGTH_SHORT).show()
MaterialAlertDialogBuilder(ctx)
.setTitle(R.string.display_profile_test)
.setMessage(profile.profileName)
.setPositiveButton(R.string.display_profile_test_revert) { _, _ ->
DisplayProfileManager.restoreSettingsSnapshot()
Toast.makeText(ctx, R.string.display_profile_test_reverted, Toast.LENGTH_SHORT).show()
}
.setNegativeButton(R.string.display_profile_test_keep, null)
.show()
}
private fun confirmDeleteProfile(
profile: DisplayProfile,
ctx: android.content.Context = requireContext(),
fragmentMgr: androidx.fragment.app.FragmentManager = parentFragmentManager
) {
MaterialAlertDialogBuilder(ctx)
.setTitle(R.string.display_profiles_delete)
.setMessage(ctx.getString(R.string.display_profiles_delete_confirm))
.setPositiveButton(android.R.string.ok) { _, _ ->
DisplayProfileManager.removeProfile(profile.profileName)
// Refresh the dialog
dismiss()
DisplayProfilesDialogFragment().show(fragmentMgr, TAG)
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}
companion object {
const val TAG = "DisplayProfilesDialogFragment"
}
}

View file

@ -6,6 +6,7 @@
#include <memory>
#include <ranges>
#include <sstream>
#include <string_view>
#include <unordered_map>
#include <INIReader.h>
#include <boost/hana/string.hpp>
@ -335,10 +336,14 @@ void Config::Reload() {
for (auto key = Settings::Keys::keys_array.begin(); key != Settings::Keys::keys_array.end();
++key) {
const auto key_declaration_string = std::string(*key) + " =";
// FIXME: This code looks so ass when formatted by clang-format -OS
if (std::ranges::find(DefaultINI::android_config_omitted_keys, *key) ==
std::end(DefaultINI::android_config_omitted_keys) &&
std::string(DefaultINI::android_config_default_file_content)
const auto key_view = std::string_view(*key);
// Check if key is in omitted list using string comparison (not pointer comparison)
const auto is_omitted =
std::ranges::find_if(DefaultINI::android_config_omitted_keys,
[key_view](const char* omitted_key) {
return std::string_view(omitted_key) == key_view;
}) != std::end(DefaultINI::android_config_omitted_keys);
if (!is_omitted && std::string(DefaultINI::android_config_default_file_content)
.find(key_declaration_string) == std::string::npos) {
ASSERT_MSG(false,
"Validation of default content config failed: Missing or malformed key "

View file

@ -0,0 +1,163 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright Citra Emulator Project / Azahar Emulator Project -->
<!-- Licensed under GPLv2 or any later version -->
<!-- Refer to the license.txt file included. -->
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<!-- Profile Name -->
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/display_profile_name">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/edit_profile_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text"
android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout>
<!-- Match Pattern -->
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:hint="@string/display_profile_match_pattern">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/edit_match_pattern"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text"
android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="@string/display_profile_match_pattern_hint"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:textColor="?android:attr/textColorSecondary" />
<!-- Use Regex Switch -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/display_profile_use_regex"
android:textAppearance="@style/TextAppearance.Material3.BodyLarge" />
<androidx.appcompat.widget.SwitchCompat
android:id="@+id/switch_use_regex"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>
<!-- Stereo Mode Spinner -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/display_profile_stereo_mode"
android:textAppearance="@style/TextAppearance.Material3.BodyLarge" />
<Spinner
android:id="@+id/spinner_stereo_mode"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp" />
<!-- 3D Depth Slider -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/display_profile_depth"
android:textAppearance="@style/TextAppearance.Material3.BodyLarge" />
<com.google.android.material.slider.Slider
android:id="@+id/slider_depth"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:valueFrom="0"
android:valueTo="255"
android:stepSize="1"
app:labelBehavior="gone" />
<!-- Swap Eyes Switch -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/display_profile_swap_eyes"
android:textAppearance="@style/TextAppearance.Material3.BodyLarge" />
<androidx.appcompat.widget.SwitchCompat
android:id="@+id/switch_swap_eyes"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>
<!-- Layout Spinner -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/display_profile_layout"
android:textAppearance="@style/TextAppearance.Material3.BodyLarge" />
<Spinner
android:id="@+id/spinner_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp" />
<!-- Enabled Switch -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/display_profile_enabled"
android:textAppearance="@style/TextAppearance.Material3.BodyLarge" />
<androidx.appcompat.widget.SwitchCompat
android:id="@+id/switch_enabled"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>
</LinearLayout>
</ScrollView>

View file

@ -469,6 +469,39 @@
<string name="emulation_switch_portrait_layout">Portrait Screen Layout</string>
<string name="emulation_switch_secondary_layout">Secondary Display Screen Layout</string>
<string name="emulation_switch_secondary_layout_description">The layout used by a connected secondary screen, wired or wireless (Chromecast, Miracast)</string>
<!-- Display Profiles -->
<string name="display_profiles">Display Profiles</string>
<string name="display_profiles_description">Automatically apply 3D settings when specific external displays are connected</string>
<string name="display_profiles_enable">Enable Display Profiles</string>
<string name="display_profiles_manage">Manage Display Profiles</string>
<string name="display_profiles_add">Add Profile</string>
<string name="display_profiles_edit">Edit Profile</string>
<string name="display_profiles_delete">Delete Profile</string>
<string name="display_profiles_delete_confirm">Delete this profile?</string>
<string name="display_profile_name">Profile Name</string>
<string name="display_profile_match_pattern">Display Name Pattern</string>
<string name="display_profile_match_pattern_hint">e.g., XREAL or Samsung</string>
<string name="display_profile_use_regex">Use Regular Expression</string>
<string name="display_profile_stereo_mode">Stereo 3D Mode</string>
<string name="display_profile_depth">3D Depth</string>
<string name="display_profile_swap_eyes">Swap Eyes</string>
<string name="display_profile_layout">Screen Layout</string>
<string name="display_profile_enabled">Profile Enabled</string>
<string name="display_profile_active">Active: %s</string>
<string name="display_profile_none_active">No profile active</string>
<string name="display_profile_connected_display">Connected Display: %s</string>
<string name="display_profile_no_display">No external display connected</string>
<string name="display_profile_test">Test Profile</string>
<string name="display_profile_test_applied">Profile settings applied for testing</string>
<string name="display_profile_test_reverted">Settings reverted</string>
<string name="display_profile_test_keep">Keep</string>
<string name="display_profile_test_revert">Revert</string>
<string name="display_profile_save">Save</string>
<string name="display_profile_cancel">Cancel</string>
<string name="display_profile_name_empty">Profile name cannot be empty</string>
<string name="display_profile_pattern_empty">Match pattern cannot be empty</string>
<string name="display_profiles_empty">No display profiles configured.\nTap + to add one.</string>
<string name="emulation_screen_layout_largescreen">Large Screen</string>
<string name="emulation_screen_layout_portrait">Portrait</string>
<string name="emulation_screen_layout_single">Single Screen</string>