updated secondary menu with functionality to switch external displays

 Conflicts:
	src/android/app/src/main/java/org/citra/citra_emu/display/SecondaryDisplay.kt
This commit is contained in:
David Griswold 2025-11-11 22:05:43 +03:00
parent 7e78498b33
commit d5c95ac3d8
9 changed files with 183 additions and 67 deletions

View File

@ -63,7 +63,7 @@ class EmulationActivity : AppCompatActivity() {
private lateinit var binding: ActivityEmulationBinding
private lateinit var screenAdjustmentUtil: ScreenAdjustmentUtil
private lateinit var hotkeyUtility: HotkeyUtility
private lateinit var secondaryDisplay: SecondaryDisplay
lateinit var secondaryDisplay: SecondaryDisplay
private val onShutdown = Runnable {
if (intent.getBooleanExtra("launched_from_shortcut", false)) {

View File

@ -6,21 +6,28 @@ package org.citra.citra_emu.display
import android.app.Presentation
import android.content.Context
import android.graphics.SurfaceTexture
import android.hardware.display.DisplayManager
import android.hardware.display.VirtualDisplay
import android.os.Build
import android.os.Bundle
import android.util.Log
import android.view.Display
import android.view.MotionEvent
import android.view.Surface
import android.view.SurfaceHolder
import android.view.SurfaceView
import android.view.WindowManager
import org.citra.citra_emu.features.settings.model.IntSetting
import org.citra.citra_emu.display.SecondaryDisplayLayout
import org.citra.citra_emu.NativeLibrary
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
var preferredDisplayId = -1
var currentDisplayId = -1
init {
vd = displayManager.createVirtualDisplay(
@ -42,24 +49,26 @@ class SecondaryDisplay(val context: Context) : DisplayManager.DisplayListener {
NativeLibrary.secondarySurfaceDestroyed()
}
private fun getExternalDisplay(context: Context): Display? {
val dm = context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
val currentDisplayId = context.display.displayId
fun getSecondaryDisplays(context: Context): List<Display> {
val dm = context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
val currentDisplayId = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
context.display.displayId
} else {
@Suppress("DEPRECATION")
(context.getSystemService(Context.WINDOW_SERVICE) as WindowManager)
.defaultDisplay.displayId
}
val displays = dm.displays
val presDisplays = dm.getDisplays(DisplayManager.DISPLAY_CATEGORY_PRESENTATION);
val extDisplays = displays.filter {
return displays.filter {
val isPresentable = presDisplays.any { pd -> pd.displayId == it.displayId }
val isNotDefaultOrPresentable = it.displayId != Display.DEFAULT_DISPLAY || isPresentable
isNotDefaultOrPresentable &&
val isNotDefaultOrPresentable = it != null && it.displayId != Display.DEFAULT_DISPLAY || isPresentable
isNotDefaultOrPresentable &&
it.displayId != currentDisplayId &&
it.name != "HiddenDisplay" &&
it.state != Display.STATE_OFF &&
it.isValid
}
// 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) }
?: extDisplays.firstOrNull()
return selected
}
fun updateDisplay() {
@ -67,12 +76,20 @@ class SecondaryDisplay(val context: Context) : DisplayManager.DisplayListener {
if (context is android.app.Activity && (context.isFinishing || context.isDestroyed)) {
return
}
val displays = getSecondaryDisplays(context)
val display = if (displays.isEmpty() ||
IntSetting.SECONDARY_DISPLAY_LAYOUT.int == SecondaryDisplayLayout.NONE.int
) {
currentDisplayId = -1
vd.display
} else if (preferredDisplayId >=0 && displays.any { it.displayId == preferredDisplayId }) {
// 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
currentDisplayId = preferredDisplayId
displays.first { it.displayId == preferredDisplayId }
} else {
//TODO: re-enable the filter of "built-in displays" odin style to pick default
currentDisplayId = displays[0].displayId
displays[0]
}
// if our presentation is already on the right display, ignore
@ -137,16 +154,18 @@ class SecondaryDisplayPresentation(
surfaceView = SurfaceView(context)
surfaceView.holder.addCallback(object : SurfaceHolder.Callback {
override fun surfaceCreated(holder: SurfaceHolder) {
Log.d("SecondaryDisplay", "Surface created")
}
override fun surfaceChanged(
holder: SurfaceHolder, format: Int, width: Int, height: Int
) {
Log.d("SecondaryDisplay", "Surface changed: ${width}x${height}")
parent.updateSurface()
}
override fun surfaceDestroyed(holder: SurfaceHolder) {
Log.d("SecondaryDisplay", "Surface destroyed")
parent.destroySurface()
}
})

View File

@ -70,7 +70,6 @@ import org.citra.citra_emu.display.ScreenLayout
import org.citra.citra_emu.display.SecondaryDisplayLayout
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.model.Settings
import org.citra.citra_emu.features.settings.model.SettingsViewModel
import org.citra.citra_emu.features.settings.ui.SettingsActivity
import org.citra.citra_emu.features.settings.utils.SettingsFile
@ -108,8 +107,8 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback, Choreographer.Fram
private val settingsViewModel: SettingsViewModel by viewModels()
private val settings get() = settingsViewModel.settings
private val onPause = Runnable{ togglePause() }
private val onShutdown = Runnable{ emulationState.stop() }
private val onPause = Runnable { togglePause() }
private val onShutdown = Runnable { emulationState.stop() }
// Only used if a game is passed through intent on google play variant
private var gameFd: Int? = null
@ -182,7 +181,8 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback, Choreographer.Fram
retainInstance = true
emulationState = EmulationState(game.path)
emulationActivity = requireActivity() as EmulationActivity
screenAdjustmentUtil = ScreenAdjustmentUtil(requireContext(), requireActivity().windowManager, settings)
screenAdjustmentUtil =
ScreenAdjustmentUtil(requireContext(), requireActivity().windowManager, settings)
EmulationLifecycleUtil.addPauseResumeHook(onPause)
EmulationLifecycleUtil.addShutdownHook(onShutdown)
}
@ -624,17 +624,21 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback, Choreographer.Fram
}
add(text).setEnabled(enableClick).setOnMenuItemClickListener {
if(isSaving) {
if (isSaving) {
NativeLibrary.saveState(slot)
Toast.makeText(context,
Toast.makeText(
context,
getString(R.string.saving),
Toast.LENGTH_SHORT).show()
Toast.LENGTH_SHORT
).show()
} else {
NativeLibrary.loadState(slot)
binding.drawerLayout.close()
Toast.makeText(context,
Toast.makeText(
context,
getString(R.string.loading),
Toast.LENGTH_SHORT).show()
Toast.LENGTH_SHORT
).show()
}
true
}
@ -643,9 +647,9 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback, Choreographer.Fram
savestates?.forEach {
var enableClick = true
val text = if(it.slot == NativeLibrary.QUICKSAVE_SLOT) {
val text = if (it.slot == NativeLibrary.QUICKSAVE_SLOT) {
getString(R.string.emulation_occupied_quicksave_slot, it.time)
} else{
} else {
getString(R.string.emulation_occupied_state_slot, it.slot, it.time)
}
popupMenu.menu.getItem(it.slot).setTitle(text).setEnabled(enableClick)
@ -727,8 +731,12 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback, Choreographer.Fram
}
R.id.menu_performance_overlay_show -> {
BooleanSetting.PERF_OVERLAY_ENABLE.boolean = !BooleanSetting.PERF_OVERLAY_ENABLE.boolean
settings.saveSetting(BooleanSetting.PERF_OVERLAY_ENABLE, SettingsFile.FILE_NAME_CONFIG)
BooleanSetting.PERF_OVERLAY_ENABLE.boolean =
!BooleanSetting.PERF_OVERLAY_ENABLE.boolean
settings.saveSetting(
BooleanSetting.PERF_OVERLAY_ENABLE,
SettingsFile.FILE_NAME_CONFIG
)
updateShowPerformanceOverlay()
true
}
@ -999,10 +1007,13 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback, Choreographer.Fram
val layoutOptionMenuItem = when (IntSetting.PORTRAIT_SCREEN_LAYOUT.int) {
PortraitScreenLayout.TOP_FULL_WIDTH.int ->
R.id.menu_portrait_layout_top_full
PortraitScreenLayout.ORIGINAL.int ->
R.id.menu_portrait_layout_original
PortraitScreenLayout.CUSTOM_PORTRAIT_LAYOUT.int ->
R.id.menu_portrait_layout_custom
else ->
R.id.menu_portrait_layout_top_full
@ -1044,35 +1055,82 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback, Choreographer.Fram
requireContext(),
binding.inGameMenu.findViewById(R.id.menu_secondary_screen_layout)
)
popupMenu.menuInflater.inflate(R.menu.menu_secondary_screen_layout, popupMenu.menu)
val layoutOptionMenuItem = when (IntSetting.SECONDARY_DISPLAY_LAYOUT.int) {
SecondaryDisplayLayout.NONE.int ->
R.id.menu_secondary_layout_none
var selectedLayout = IntSetting.SECONDARY_DISPLAY_LAYOUT.int
val chooserMenu = popupMenu.menu.findItem(R.id.menu_secondary_choose)
val enableSecondaryCheckbox = popupMenu.menu.findItem(R.id.menu_secondary_layout_none)
chooserMenu?.subMenu?.removeGroup(R.id.menu_secondary_management_display_group)
val displays =
emulationActivity.secondaryDisplay.getSecondaryDisplays(emulationActivity)
if (selectedLayout == SecondaryDisplayLayout.NONE.int) {
enableSecondaryCheckbox.isChecked = false
chooserMenu.isVisible = false
popupMenu.menu.setGroupEnabled(R.id.menu_secondary_layout_group, false)
selectedLayout = SecondaryDisplayLayout.REVERSE_PRIMARY.int
} else {
popupMenu.menu.setGroupEnabled(R.id.menu_secondary_layout_group, true)
chooserMenu.isVisible = (displays.size > 1)
}
val layoutOptionMenuItem = when (selectedLayout) {
SecondaryDisplayLayout.NONE.int -> {
R.id.menu_secondary_layout_reverse_primary
}
SecondaryDisplayLayout.REVERSE_PRIMARY.int ->
R.id.menu_secondary_layout_reverse_primary
SecondaryDisplayLayout.TOP_SCREEN.int ->
R.id.menu_secondary_layout_top
SecondaryDisplayLayout.BOTTOM_SCREEN.int ->
R.id.menu_secondary_layout_bottom
SecondaryDisplayLayout.HYBRID.int ->
R.id.menu_secondary_layout_hybrid
SecondaryDisplayLayout.LARGE_SCREEN.int ->
R.id.menu_secondary_layout_largescreen
SecondaryDisplayLayout.ORIGINAL.int ->
R.id.menu_secondary_layout_original
else ->
R.id.menu_secondary_layout_side_by_side
}
popupMenu.menu.findItem(layoutOptionMenuItem).isChecked = true
popupMenu.menu.findItem(layoutOptionMenuItem).setChecked(true)
if (displays.size > 1 && selectedLayout != SecondaryDisplayLayout.NONE.int) {
val current = emulationActivity.secondaryDisplay.currentDisplayId
chooserMenu.isVisible = true
displays.forEachIndexed { index, display ->
chooserMenu?.subMenu?.add(
R.id.menu_secondary_management_display_group,
display.displayId,
index,
"Display ${display.displayId} - ${display.name}"
)?.apply {
isChecked = (display.displayId == current)
}
}
chooserMenu.subMenu?.setGroupCheckable(
R.id.menu_secondary_management_display_group,
true,
true
)
}
popupMenu.setOnMenuItemClickListener {
when (it.itemId) {
R.id.menu_secondary_layout_none -> {
screenAdjustmentUtil.changeSecondaryOrientation(SecondaryDisplayLayout.NONE.int)
if (!it.isChecked) {
screenAdjustmentUtil.changeSecondaryOrientation(selectedLayout)
} else {
screenAdjustmentUtil.changeSecondaryOrientation(SecondaryDisplayLayout.NONE.int)
}
emulationActivity.secondaryDisplay.updateDisplay()
showSecondaryScreenLayoutMenu() // reopen menu to get new behaviors
true
}
@ -1080,38 +1138,52 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback, Choreographer.Fram
screenAdjustmentUtil.changeSecondaryOrientation(SecondaryDisplayLayout.REVERSE_PRIMARY.int)
true
}
R.id.menu_secondary_layout_top -> {
screenAdjustmentUtil.changeSecondaryOrientation(SecondaryDisplayLayout.TOP_SCREEN.int)
true
}
R.id.menu_secondary_layout_bottom -> {
screenAdjustmentUtil.changeSecondaryOrientation(SecondaryDisplayLayout.BOTTOM_SCREEN.int)
true
}
R.id.menu_secondary_layout_side_by_side -> {
screenAdjustmentUtil.changeSecondaryOrientation(SecondaryDisplayLayout.SIDE_BY_SIDE.int)
true
}
R.id.menu_secondary_layout_hybrid -> {
screenAdjustmentUtil.changeSecondaryOrientation(SecondaryDisplayLayout.HYBRID.int)
true
}
R.id.menu_secondary_layout_original -> {
screenAdjustmentUtil.changeSecondaryOrientation(SecondaryDisplayLayout.ORIGINAL.int)
true
}
R.id.menu_secondary_layout_largescreen -> {
screenAdjustmentUtil.changeSecondaryOrientation(SecondaryDisplayLayout.LARGE_SCREEN.int)
true
}
R.id.menu_secondary_choose -> {
true
}
else -> true
else -> {
// display ID selection
emulationActivity.secondaryDisplay.preferredDisplayId = it.itemId
emulationActivity.secondaryDisplay.updateDisplay()
true
}
}
}
popupMenu.show()
}
private fun editControlsPlacement() {
if (binding.surfaceInputOverlay.isInEditMode) {
binding.doneControlConfig.visibility = View.GONE
@ -1168,7 +1240,7 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback, Choreographer.Fram
slider.valueFrom = 0f
slider.value = preferences.getInt(target, 50).toFloat()
textValue.setText((slider.value + 50).toInt().toString())
textValue.addTextChangedListener( object : TextWatcher {
textValue.addTextChangedListener(object : TextWatcher {
override fun afterTextChanged(s: Editable) {
val value = s.toString().toIntOrNull()
if (value == null || value < 50 || value > 150) {
@ -1178,6 +1250,7 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback, Choreographer.Fram
slider.value = value.toFloat() - 50
}
}
override fun beforeTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) {}
override fun onTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) {}
})
@ -1218,7 +1291,7 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback, Choreographer.Fram
slider.value = preferences.getInt("controlOpacity", 50).toFloat()
textValue.setText(slider.value.toInt().toString())
textValue.addTextChangedListener( object : TextWatcher {
textValue.addTextChangedListener(object : TextWatcher {
override fun afterTextChanged(s: Editable) {
val value = s.toString().toIntOrNull()
if (value == null || value < slider.valueFrom || value > slider.valueTo) {
@ -1228,6 +1301,7 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback, Choreographer.Fram
slider.value = value.toFloat()
}
}
override fun beforeTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) {}
override fun onTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) {}
})
@ -1236,11 +1310,11 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback, Choreographer.Fram
slider.addOnChangeListener { _: Slider, value: Float, _: Boolean ->
if (textValue.text.toString() != slider.value.toInt().toString()) {
textValue.setText(slider.value.toInt().toString())
textValue.setSelection(textValue.length())
setControlOpacity(slider.value.toInt())
}
textValue.setText(slider.value.toInt().toString())
textValue.setSelection(textValue.length())
setControlOpacity(slider.value.toInt())
}
}
textInput.suffixText = "%"
}
@ -1425,7 +1499,8 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback, Choreographer.Fram
}
private fun updateStatsPosition(position: Int) {
val params = binding.performanceOverlayShowText.layoutParams as CoordinatorLayout.LayoutParams
val params =
binding.performanceOverlayShowText.layoutParams as CoordinatorLayout.LayoutParams
val padding = (20 * resources.displayMetrics.density).toInt() // 20dp
params.setMargins(padding, 0, padding, 0)
@ -1460,7 +1535,8 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback, Choreographer.Fram
private fun getBatteryTemperature(): Float {
try {
val batteryIntent = requireContext().registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
val batteryIntent =
requireContext().registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
// Temperature in tenths of a degree Celsius
val temperature = batteryIntent?.getIntExtra(BatteryManager.EXTRA_TEMPERATURE, 0) ?: 0
// Convert to degrees Celsius

View File

@ -157,7 +157,7 @@ void Config::ReadValues() {
ReadSetting("Renderer", Settings::values.turbo_limit);
// Workaround to map Android setting for enabling the frame limiter to the format Citra expects
if (android_config->GetBoolean("Renderer", "use_frame_limit", true)) {
ReadSetting("Renderer", Settings::values.frame_limit);
ReadSetting("Renderer", Settings::values.frame_limit);
} else {
Settings::values.frame_limit = 0;
}

View File

@ -18,10 +18,13 @@
#include "video_core/renderer_base.h"
bool EmuWindow_Android::OnSurfaceChanged(ANativeWindow* surface) {
if (render_window == surface) {
int w = ANativeWindow_getWidth(surface);
int h = ANativeWindow_getHeight(surface);
if (render_window == surface && w == window_width && h == window_height) {
return false;
}
window_width = w;
window_height = h;
render_window = surface;
window_info.type = Frontend::WindowSystemType::Android;
window_info.render_surface = surface;
@ -47,15 +50,9 @@ void EmuWindow_Android::OnTouchMoved(int x, int y) {
}
void EmuWindow_Android::OnFramebufferSizeChanged() {
const bool is_portrait_mode{IsPortraitMode()};
const bool is_portrait_mode = IsPortraitMode() && !is_secondary;
const int bigger{window_width > window_height ? window_width : window_height};
const int smaller{window_width < window_height ? window_width : window_height};
if (is_portrait_mode && !is_secondary) {
UpdateCurrentFramebufferLayout(smaller, bigger, is_portrait_mode);
} else {
UpdateCurrentFramebufferLayout(bigger, smaller, is_portrait_mode);
}
UpdateCurrentFramebufferLayout(window_width,window_height,is_portrait_mode);
}
EmuWindow_Android::EmuWindow_Android(ANativeWindow* surface, bool is_secondary)

View File

@ -391,6 +391,11 @@ void Java_org_citra_citra_1emu_NativeLibrary_secondarySurfaceChanged(JNIEnv* env
if (secondary_window) {
// Second window already created, so update it
notify = secondary_window->OnSurfaceChanged(s_secondary_surface);
// Log the dimensions for debugging
int32_t width = ANativeWindow_getWidth(s_secondary_surface);
int32_t height = ANativeWindow_getHeight(s_secondary_surface);
LOG_INFO(Frontend, "Secondary Surface changed to {}x{}", width, height);
} else {
LOG_WARNING(Frontend,
"Second Window does not exist in native.cpp but surface changed. Ignoring.");

View File

@ -35,7 +35,7 @@
<item
android:id="@+id/menu_secondary_screen_layout"
android:icon="@drawable/ic_secondary_fit_screen"
android:title="@string/emulation_switch_secondary_layout" />
android:title="@string/emulation_secondary_display_management" />
<item
android:id="@+id/menu_swap_screens"

View File

@ -1,12 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<group android:checkableBehavior="single">
<item
android:id="@+id/menu_secondary_layout_none"
android:title="@string/emulation_secondary_display_default" />
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/menu_secondary_layout_none"
android:title="@string/emulation_secondary_display_enable"
android:checkable="true"
android:checked="true"/>
<item
android:id="@+id/menu_secondary_choose"
android:title="@string/emulation_select_secondary_display">
<menu>
<group
android:id="@+id/menu_secondary_management_display_group"
android:checkableBehavior="single">
</group>
</menu>
</item>
<item
android:title="@string/preferences_layout"
android:enabled="false"/>
<group
android:checkableBehavior="single"
android:id="@+id/menu_secondary_layout_group">
<item
android:id="@+id/menu_secondary_layout_reverse_primary"
android:title="@string/emulation_secondary_display_reverse_primary" />

View File

@ -467,7 +467,10 @@
<string name="emulation_aspect_ratio">Aspect Ratio</string>
<string name="emulation_switch_screen_layout">Landscape Screen Layout</string>
<string name="emulation_switch_portrait_layout">Portrait Screen Layout</string>
<string name="emulation_secondary_display_enable">Enable Secondary Display</string>
<string name="emulation_switch_secondary_layout">Secondary Display Layout</string>
<string name="emulation_secondary_display_management">Secondary Display</string>
<string name="emulation_select_secondary_display">Choose Display</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_screen_layout_largescreen">Large Screen</string>
<string name="emulation_screen_layout_portrait">Portrait</string>
@ -476,7 +479,7 @@
<string name="emulation_screen_layout_hybrid">Hybrid Screens</string>
<string name="emulation_screen_layout_original">Original</string>
<string name="emulation_portrait_layout_top_full">Default</string>
<string name="emulation_secondary_display_default">System Default (mirror)</string>
<string name="emulation_secondary_display_default">None (system default)</string>
<string name="emulation_secondary_display_reverse_primary">Opposite of Primary Display</string>
<string name="emulation_screen_layout_custom">Custom Layout</string>
<string name="bg_color">Background Color</string>