diff --git a/src/android/app/src/main/java/org/citra/citra_emu/display/DisplayProfile.kt b/src/android/app/src/main/java/org/citra/citra_emu/display/DisplayProfile.kt new file mode 100644 index 000000000..934ba4586 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/display/DisplayProfile.kt @@ -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 +) diff --git a/src/android/app/src/main/java/org/citra/citra_emu/display/DisplayProfileManager.kt b/src/android/app/src/main/java/org/citra/citra_emu/display/DisplayProfileManager.kt new file mode 100644 index 000000000..ba6d8c5bd --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/display/DisplayProfileManager.kt @@ -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 = 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 = 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>(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(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}") + } + } + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/display/SecondaryDisplay.kt b/src/android/app/src/main/java/org/citra/citra_emu/display/SecondaryDisplay.kt index d09daab41..b972fd4b4 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/display/SecondaryDisplay.kt +++ b/src/android/app/src/main/java/org/citra/citra_emu/display/SecondaryDisplay.kt @@ -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() diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragmentPresenter.kt b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragmentPresenter.kt index 94bfe78a9..e284ffc7b 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragmentPresenter.kt +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragmentPresenter.kt @@ -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 @@ -1212,6 +1213,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, diff --git a/src/android/app/src/main/java/org/citra/citra_emu/fragments/DisplayProfilesDialogFragment.kt b/src/android/app/src/main/java/org/citra/citra_emu/fragments/DisplayProfilesDialogFragment.kt new file mode 100644 index 000000000..887bde13e --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/fragments/DisplayProfilesDialogFragment.kt @@ -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): 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(R.id.edit_profile_name) + val patternEdit = view.findViewById(R.id.edit_match_pattern) + val regexSwitch = view.findViewById(R.id.switch_use_regex) + val stereoSpinner = view.findViewById(R.id.spinner_stereo_mode) + val depthSlider = view.findViewById(R.id.slider_depth) + val swapEyesSwitch = view.findViewById(R.id.switch_swap_eyes) + val layoutSpinner = view.findViewById(R.id.spinner_layout) + val enabledSwitch = view.findViewById(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" + } +} diff --git a/src/android/app/src/main/jni/config.cpp b/src/android/app/src/main/jni/config.cpp index 967166e12..047b82ace 100644 --- a/src/android/app/src/main/jni/config.cpp +++ b/src/android/app/src/main/jni/config.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -339,11 +340,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 {}", diff --git a/src/android/app/src/main/res/layout/dialog_display_profile.xml b/src/android/app/src/main/res/layout/dialog_display_profile.xml new file mode 100644 index 000000000..c551013ae --- /dev/null +++ b/src/android/app/src/main/res/layout/dialog_display_profile.xml @@ -0,0 +1,163 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/android/app/src/main/res/values/strings.xml b/src/android/app/src/main/res/values/strings.xml index a94611efc..14cc19f43 100644 --- a/src/android/app/src/main/res/values/strings.xml +++ b/src/android/app/src/main/res/values/strings.xml @@ -502,6 +502,39 @@ Portrait Screen Layout Secondary Display Screen Layout The layout used by a connected secondary screen, wired or wireless (Chromecast, Miracast) + + + Display Profiles + Automatically apply 3D settings when specific external displays are connected + Enable Display Profiles + Manage Display Profiles + Add Profile + Edit Profile + Delete Profile + Delete this profile? + Profile Name + Display Name Pattern + e.g., XREAL or Samsung + Use Regular Expression + Stereo 3D Mode + 3D Depth + Swap Eyes + Screen Layout + Profile Enabled + Active: %s + No profile active + Connected Display: %s + No external display connected + Test Profile + Profile settings applied for testing + Settings reverted + Keep + Revert + Save + Cancel + Profile name cannot be empty + Match pattern cannot be empty + No display profiles configured.\nTap + to add one. Large Screen Portrait Single Screen