mirror of
https://github.com/azahar-emu/azahar.git
synced 2026-06-06 02:33:44 -04:00
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:
parent
1edc5de18e
commit
7d632189ae
8 changed files with 932 additions and 17 deletions
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
@ -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}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 {}",
|
||||||
|
|
|
||||||
163
src/android/app/src/main/res/layout/dialog_display_profile.xml
Normal file
163
src/android/app/src/main/res/layout/dialog_display_profile.xml
Normal 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>
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue