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.content.Context
import android.hardware.display.DisplayManager import android.hardware.display.DisplayManager
import android.hardware.display.VirtualDisplay import android.hardware.display.VirtualDisplay
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.Display import android.view.Display
import android.view.MotionEvent import android.view.MotionEvent
import android.view.SurfaceHolder import android.view.SurfaceHolder
@ -21,6 +24,14 @@ class SecondaryDisplay(val context: Context) : DisplayManager.DisplayListener {
private var pres: SecondaryDisplayPresentation? = null private var pres: SecondaryDisplayPresentation? = null
private val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager private val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
private val vd: VirtualDisplay 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 { init {
vd = displayManager.createVirtualDisplay( vd = displayManager.createVirtualDisplay(
@ -32,10 +43,22 @@ class SecondaryDisplay(val context: Context) : DisplayManager.DisplayListener {
DisplayManager.VIRTUAL_DISPLAY_FLAG_PRESENTATION DisplayManager.VIRTUAL_DISPLAY_FLAG_PRESENTATION
) )
displayManager.registerDisplayListener(this, null) 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() { fun updateSurface() {
NativeLibrary.secondarySurfaceChanged(pres!!.getSurfaceHolder().surface) pres?.let {
NativeLibrary.secondarySurfaceChanged(it.getSurfaceHolder().surface)
}
} }
fun destroySurface() { fun destroySurface() {
@ -44,22 +67,57 @@ class SecondaryDisplay(val context: Context) : DisplayManager.DisplayListener {
private fun getExternalDisplay(context: Context): Display? { private fun getExternalDisplay(context: Context): Display? {
val dm = context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager 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 displays = dm.displays
val presDisplays = dm.getDisplays(DisplayManager.DISPLAY_CATEGORY_PRESENTATION); val presDisplays = dm.getDisplays(DisplayManager.DISPLAY_CATEGORY_PRESENTATION)
val extDisplays = displays.filter { val extDisplays = displays.filter {
val isPresentable = presDisplays.any { pd -> pd.displayId == it.displayId } val isPresentable = presDisplays.any { pd -> pd.displayId == it.displayId }
val isNotDefaultOrPresentable = it.displayId != Display.DEFAULT_DISPLAY || isPresentable val isExternal = it.displayId != Display.DEFAULT_DISPLAY && it.displayId != currentDisplayId
isNotDefaultOrPresentable && val isUsable = it.name != "HiddenDisplay" && it.state != Display.STATE_OFF
it.displayId != currentDisplayId && // EXCLUDE DP/USB-C displays - those are for mirroring the main screen, not for SecondaryDisplay
it.name != "HiddenDisplay" && val isDPDisplay = it.name.contains("DP", true)
it.state != Display.STATE_OFF &&
it.isValid (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() ?: 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() { fun updateDisplay() {
@ -68,8 +126,13 @@ class SecondaryDisplay(val context: Context) : DisplayManager.DisplayListener {
return 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 // decide if we are going to the external display or the internal one
var display = getExternalDisplay(context) var display = getExternalDisplay(context)
if (display == null || if (display == null ||
IntSetting.SECONDARY_DISPLAY_LAYOUT.int == SecondaryDisplayLayout.NONE.int) { IntSetting.SECONDARY_DISPLAY_LAYOUT.int == SecondaryDisplayLayout.NONE.int) {
display = vd.display 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() { fun releasePresentation() {
try { try {
pres?.dismiss() 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.SubmenuSetting
import org.citra.citra_emu.features.settings.model.view.SwitchSetting import org.citra.citra_emu.features.settings.model.view.SwitchSetting
import org.citra.citra_emu.features.settings.utils.SettingsFile 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.fragments.ResetSettingsDialogFragment
import org.citra.citra_emu.utils.BirthdayMonth import org.citra.citra_emu.utils.BirthdayMonth
import org.citra.citra_emu.utils.Log import org.citra.citra_emu.utils.Log
@ -1203,6 +1204,20 @@ class SettingsFragmentPresenter(private val fragmentView: SettingsFragmentView)
IntSetting.SECONDARY_DISPLAY_LAYOUT.defaultValue 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( add(
SingleChoiceSetting( SingleChoiceSetting(
IntSetting.ASPECT_RATIO, 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 <memory>
#include <ranges> #include <ranges>
#include <sstream> #include <sstream>
#include <string_view>
#include <unordered_map> #include <unordered_map>
#include <INIReader.h> #include <INIReader.h>
#include <boost/hana/string.hpp> #include <boost/hana/string.hpp>
@ -335,11 +336,15 @@ void Config::Reload() {
for (auto key = Settings::Keys::keys_array.begin(); key != Settings::Keys::keys_array.end(); for (auto key = Settings::Keys::keys_array.begin(); key != Settings::Keys::keys_array.end();
++key) { ++key) {
const auto key_declaration_string = std::string(*key) + " ="; const auto key_declaration_string = std::string(*key) + " =";
// FIXME: This code looks so ass when formatted by clang-format -OS const auto key_view = std::string_view(*key);
if (std::ranges::find(DefaultINI::android_config_omitted_keys, *key) == // Check if key is in omitted list using string comparison (not pointer comparison)
std::end(DefaultINI::android_config_omitted_keys) && const auto is_omitted =
std::string(DefaultINI::android_config_default_file_content) std::ranges::find_if(DefaultINI::android_config_omitted_keys,
.find(key_declaration_string) == std::string::npos) { [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, ASSERT_MSG(false,
"Validation of default content config failed: Missing or malformed key " "Validation of default content config failed: Missing or malformed key "
"declaration {}", "declaration {}",

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_portrait_layout">Portrait Screen Layout</string>
<string name="emulation_switch_secondary_layout">Secondary Display 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> <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_largescreen">Large Screen</string>
<string name="emulation_screen_layout_portrait">Portrait</string> <string name="emulation_screen_layout_portrait">Portrait</string>
<string name="emulation_screen_layout_single">Single Screen</string> <string name="emulation_screen_layout_single">Single Screen</string>