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.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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 <ranges>
|
||||
#include <sstream>
|
||||
#include <string_view>
|
||||
#include <unordered_map>
|
||||
#include <INIReader.h>
|
||||
#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();
|
||||
++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)
|
||||
.find(key_declaration_string) == std::string::npos) {
|
||||
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 "
|
||||
"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_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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue